@copilotkit/web-inspector 1.66.4 → 1.67.1

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.
Files changed (56) hide show
  1. package/README.md +92 -13
  2. package/dist/index.cjs +1436 -429
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +118 -8
  5. package/dist/index.d.cts.map +1 -1
  6. package/dist/index.d.mts +118 -8
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +1438 -431
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/index.umd.js +1721 -533
  11. package/dist/index.umd.js.map +1 -1
  12. package/dist/lib/inspector-metadata.cjs +60 -0
  13. package/dist/lib/inspector-metadata.cjs.map +1 -0
  14. package/dist/lib/inspector-metadata.mjs +60 -0
  15. package/dist/lib/inspector-metadata.mjs.map +1 -0
  16. package/dist/lib/telemetry.cjs +117 -32
  17. package/dist/lib/telemetry.cjs.map +1 -1
  18. package/dist/lib/telemetry.mjs +116 -33
  19. package/dist/lib/telemetry.mjs.map +1 -1
  20. package/dist/lib/thread-selection.cjs +36 -0
  21. package/dist/lib/thread-selection.cjs.map +1 -0
  22. package/dist/lib/thread-selection.mjs +35 -0
  23. package/dist/lib/thread-selection.mjs.map +1 -0
  24. package/dist/package.cjs +1 -1
  25. package/dist/package.mjs +1 -1
  26. package/dist/styles/generated.cjs +1 -1
  27. package/dist/styles/generated.cjs.map +1 -1
  28. package/dist/styles/generated.mjs +1 -1
  29. package/dist/styles/generated.mjs.map +1 -1
  30. package/package.json +9 -4
  31. package/CHANGELOG.md +0 -505
  32. package/dev/css-raw-import.spec.ts +0 -31
  33. package/dev/index.html +0 -115
  34. package/dev/main.ts +0 -165
  35. package/dev/vite.config.ts +0 -30
  36. package/src/__tests__/memory-recall.spec.ts +0 -76
  37. package/src/__tests__/telemetry-egress-guard.spec.ts +0 -41
  38. package/src/__tests__/web-inspector.spec.ts +0 -3935
  39. package/src/assets/inspector-logo-icon.svg +0 -8
  40. package/src/assets/inspector-logo.svg +0 -40
  41. package/src/components.d.ts +0 -20
  42. package/src/index.ts +0 -11849
  43. package/src/lib/__tests__/telemetry.test.ts +0 -557
  44. package/src/lib/context-helpers.ts +0 -166
  45. package/src/lib/persistence.ts +0 -206
  46. package/src/lib/telemetry.ts +0 -474
  47. package/src/lib/testing/telemetry-egress-guard.ts +0 -33
  48. package/src/lib/types.ts +0 -19
  49. package/src/styles/generated.css +0 -2
  50. package/src/styles/tailwind.css +0 -23
  51. package/src/types/css.d.ts +0 -4
  52. package/src/types/svg.d.ts +0 -4
  53. package/tsconfig.json +0 -14
  54. package/tsdown.config.ts +0 -45
  55. package/vitest.config.ts +0 -66
  56. package/vitest.setup.ts +0 -84
@@ -1,557 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import type { MockInstance } from "vitest";
3
-
4
- import { readFileSync } from "node:fs";
5
- import { resolve } from "node:path";
6
-
7
- import {
8
- TELEMETRY_DOCS_URL,
9
- TELEMETRY_EVENTS,
10
- TELEMETRY_INGEST_URL,
11
- getRuntimeUrlType,
12
- getTelemetryDistinctIdForUrl,
13
- maybeShowDisclosure,
14
- track,
15
- trackBannerClicked,
16
- trackBannerDismissed,
17
- trackBannerViewed,
18
- trackInspectorOpened,
19
- trackTalkToEngineerClicked,
20
- trackThreadsEmptyEnabledViewed,
21
- trackThreadsEnabledViewed,
22
- trackThreadsIntelligenceSignupClicked,
23
- trackThreadsLockedViewed,
24
- trackThreadsTabClicked,
25
- trackThreadsTalkToEngineerClicked,
26
- } from "../telemetry.js";
27
- import {
28
- _resetTelemetryPersistenceForTesting,
29
- getOrCreateTelemetryDistinctId,
30
- hasTelemetryDisclosureBeenShown,
31
- isTelemetryOptedOut,
32
- markTelemetryDisclosureShown,
33
- setTelemetryOptOut,
34
- } from "../persistence.js";
35
-
36
- // The wrapper short-circuits before any network call when opted out, but
37
- // for the network-touching cases we mock fetch globally so we can read
38
- // what would have been sent without making real HTTP requests.
39
- let fetchMock: MockInstance<typeof fetch>;
40
- let consoleInfoSpy: MockInstance<typeof console.info>;
41
- const webInspectorPackage = JSON.parse(
42
- readFileSync(resolve(process.cwd(), "package.json"), "utf8"),
43
- ) as { version: string };
44
-
45
- beforeEach(() => {
46
- // Each test starts from a clean localStorage so distinct-ID + opt-out
47
- // + disclosure-shown flags don't leak across cases.
48
- window.localStorage.clear();
49
- _resetTelemetryPersistenceForTesting();
50
-
51
- // The wrapper POSTs via globalThis.fetch with a 3s AbortController
52
- // timeout. Stub it with a resolving Response so happy-path sends
53
- // complete synchronously (the wrapper does `void` on the promise).
54
- fetchMock = vi
55
- .spyOn(globalThis, "fetch")
56
- .mockResolvedValue(new Response(null, { status: 204 }));
57
-
58
- consoleInfoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
59
- });
60
-
61
- afterEach(() => {
62
- vi.restoreAllMocks();
63
- vi.unstubAllGlobals();
64
- });
65
-
66
- // ─── Wire body shape ────────────────────────────────────────────────────────
67
-
68
- describe("track()", () => {
69
- it("posts to telemetry.copilotkit.ai/ingest with confirmed IngestPayload shape", async () => {
70
- track(TELEMETRY_EVENTS.bannerViewed, {
71
- banner_id: "2025-05-01T00:00:00Z",
72
- });
73
-
74
- await Promise.resolve();
75
-
76
- expect(fetchMock).toHaveBeenCalledTimes(1);
77
- const [url, init] = fetchMock.mock.calls[0]!;
78
- expect(url).toBe(TELEMETRY_INGEST_URL);
79
- expect(init?.method).toBe("POST");
80
- expect((init?.headers as Record<string, string>)["Content-Type"]).toBe(
81
- "application/json",
82
- );
83
- expect(
84
- (init?.headers as Record<string, string>)["X-CopilotKit-Telemetry-Id"],
85
- ).toMatch(/^[0-9a-f-]{36}$/);
86
-
87
- // Ben confirmed shape (telemetry-sink-ingest/index.ts:127-134):
88
- // package is a top-level object { name, version? }, NOT inside properties.
89
- const body = JSON.parse((init?.body as string) ?? "{}") as {
90
- event: string;
91
- properties: Record<string, unknown>;
92
- package: { name: string; version?: string };
93
- ts: number;
94
- };
95
- expect(body.event).toBe("oss.inspector.banner_viewed");
96
- expect(body.properties.banner_id).toBe("2025-05-01T00:00:00Z");
97
- expect(typeof body.properties.distinct_id).toBe("string");
98
- // package is top-level object, not a string inside properties
99
- expect(body.package).toEqual({ name: "@copilotkit/web-inspector" });
100
- expect(body.properties).not.toHaveProperty("package");
101
- expect(body.properties).not.toHaveProperty("package_name");
102
- expect(body.properties).not.toHaveProperty("inspector_distinct_id");
103
- expect(typeof body.ts).toBe("number");
104
- });
105
-
106
- it("short-circuits when localStorage opt-out is set", async () => {
107
- setTelemetryOptOut(true);
108
- expect(isTelemetryOptedOut()).toBe(true);
109
-
110
- track(TELEMETRY_EVENTS.bannerClicked, {
111
- banner_id: "x",
112
- cta: "body",
113
- });
114
- await Promise.resolve();
115
-
116
- expect(fetchMock).not.toHaveBeenCalled();
117
- });
118
-
119
- it("swallows unserializable properties before dispatching", async () => {
120
- const circular: Record<string, unknown> = {};
121
- circular.self = circular;
122
-
123
- expect(() => track(TELEMETRY_EVENTS.bannerClicked, circular)).not.toThrow();
124
- expect(() =>
125
- track(TELEMETRY_EVENTS.bannerClicked, { value: 1n }),
126
- ).not.toThrow();
127
- await Promise.resolve();
128
-
129
- expect(fetchMock).not.toHaveBeenCalled();
130
- });
131
-
132
- it("swallows fetch failures (telemetry is best-effort)", async () => {
133
- fetchMock.mockRejectedValueOnce(new Error("network down"));
134
-
135
- expect(() => track(TELEMETRY_EVENTS.threadsTabClicked)).not.toThrow();
136
-
137
- await new Promise((resolve) => setTimeout(resolve, 0));
138
- });
139
-
140
- it("does not send when fetch is unavailable (SSR / pre-fetch environment)", async () => {
141
- vi.stubGlobal("fetch", undefined);
142
-
143
- expect(() =>
144
- track(TELEMETRY_EVENTS.bannerViewed, { banner_id: "abc" }),
145
- ).not.toThrow();
146
-
147
- // No fetch call possible — restore happens in afterEach via unstubAllGlobals
148
- expect(fetchMock).not.toHaveBeenCalled();
149
- });
150
-
151
- it("never includes message content or agent state in the payload", async () => {
152
- track(TELEMETRY_EVENTS.bannerViewed, { banner_id: "abc" });
153
- await Promise.resolve();
154
-
155
- const [, init] = fetchMock.mock.calls[0]!;
156
- const raw = (init?.body as string) ?? "{}";
157
- // Forbidden content keys (privacy invariant — never send inspector content)
158
- expect(raw).not.toMatch(
159
- /messages|completion|prompt|state_snapshot|agent_state|content|user_id/i,
160
- );
161
- });
162
- });
163
-
164
- // ─── Typed per-event helpers ─────────────────────────────────────────────────
165
-
166
- describe("typed helpers", () => {
167
- it("trackBannerViewed sends banner_id, surface, and optional cta_label", async () => {
168
- trackBannerViewed({
169
- banner_id: "ts-2025",
170
- surface: "collapsed_preview",
171
- cta_label: "Try threads",
172
- });
173
- await Promise.resolve();
174
- const [, init] = fetchMock.mock.calls[0]!;
175
- const body = JSON.parse((init?.body as string) ?? "{}") as {
176
- event: string;
177
- properties: Record<string, unknown>;
178
- };
179
- expect(body.event).toBe(TELEMETRY_EVENTS.bannerViewed);
180
- expect(body.properties.banner_id).toBe("ts-2025");
181
- expect(body.properties.surface).toBe("collapsed_preview");
182
- expect(body.properties.cta_label).toBe("Try threads");
183
- });
184
-
185
- it("trackBannerViewed omits cta_label when undefined (JSON.stringify drops it)", async () => {
186
- trackBannerViewed({ banner_id: "ts-2025", surface: "expanded_card" });
187
- await Promise.resolve();
188
- const [, init] = fetchMock.mock.calls[0]!;
189
- const raw = (init?.body as string) ?? "{}";
190
- expect(raw).not.toContain("cta_label");
191
- });
192
-
193
- it("trackBannerDismissed sends a first-class dismissed event with its surface", async () => {
194
- trackBannerDismissed({
195
- banner_id: "ts-2025",
196
- surface: "expanded_card",
197
- cta_label: "Try threads",
198
- });
199
- await Promise.resolve();
200
- const [, init] = fetchMock.mock.calls[0]!;
201
- const body = JSON.parse((init?.body as string) ?? "{}") as {
202
- event: string;
203
- properties: Record<string, unknown>;
204
- package: { name: string; version?: string };
205
- };
206
- expect(body.event).toBe("oss.inspector.banner_dismissed");
207
- expect(body.properties.banner_id).toBe("ts-2025");
208
- expect(body.properties.surface).toBe("expanded_card");
209
- expect(body.properties.cta_label).toBe("Try threads");
210
- // Banner events keep the flat shape their existing dashboards read.
211
- expect(body.properties).not.toHaveProperty("package_version");
212
- expect(body.package).toEqual({ name: "@copilotkit/web-inspector" });
213
- });
214
-
215
- it("trackInspectorOpened sends open_source and is enriched with package identity", async () => {
216
- trackInspectorOpened({
217
- open_source: "floating_button",
218
- license_status: "none",
219
- runtime_mode: "sse",
220
- runtime_url_type: "localhost",
221
- has_unseen_announcement: true,
222
- });
223
- await Promise.resolve();
224
- const [, init] = fetchMock.mock.calls[0]!;
225
- const body = JSON.parse((init?.body as string) ?? "{}") as {
226
- event: string;
227
- properties: Record<string, unknown>;
228
- package: { name: string; version?: string };
229
- };
230
- expect(body.event).toBe("oss.inspector.opened");
231
- expect(body.properties).toMatchObject({
232
- open_source: "floating_button",
233
- license_status: "none",
234
- runtime_mode: "sse",
235
- runtime_url_type: "localhost",
236
- has_unseen_announcement: true,
237
- package_name: "@copilotkit/web-inspector",
238
- package_version: webInspectorPackage.version,
239
- });
240
- expect(body.properties.inspector_distinct_id).toBe(
241
- body.properties.distinct_id,
242
- );
243
- expect(body.package).toEqual({
244
- name: "@copilotkit/web-inspector",
245
- version: webInspectorPackage.version,
246
- });
247
- });
248
-
249
- it("opened and banner events carry no message, state, or announcement content", async () => {
250
- trackInspectorOpened({ open_source: "announcement_preview" });
251
- trackBannerDismissed({
252
- banner_id: "ts-2025",
253
- surface: "collapsed_preview",
254
- });
255
- await Promise.resolve();
256
- for (const [, init] of fetchMock.mock.calls) {
257
- const properties = (
258
- JSON.parse((init?.body as string) ?? "{}") as {
259
- properties: Record<string, unknown>;
260
- }
261
- ).properties;
262
- // Allow-list assertion: any new key has to be added here deliberately,
263
- // so an accidental content/PII payload fails the test instead of
264
- // shipping.
265
- const allowed = new Set([
266
- "open_source",
267
- "banner_id",
268
- "surface",
269
- "distinct_id",
270
- "inspector_distinct_id",
271
- "package_name",
272
- "package_version",
273
- ]);
274
- expect(Object.keys(properties).filter((k) => !allowed.has(k))).toEqual(
275
- [],
276
- );
277
- }
278
- });
279
-
280
- it("trackBannerClicked sends banner_id, cta, and optional cta_label", async () => {
281
- trackBannerClicked({ banner_id: "ts-2025", cta: "body" });
282
- await Promise.resolve();
283
- const [, init] = fetchMock.mock.calls[0]!;
284
- const body = JSON.parse((init?.body as string) ?? "{}") as {
285
- event: string;
286
- properties: Record<string, unknown>;
287
- };
288
- expect(body.event).toBe(TELEMETRY_EVENTS.bannerClicked);
289
- expect(body.properties.banner_id).toBe("ts-2025");
290
- expect(body.properties.cta).toBe("body");
291
- });
292
-
293
- it("trackThreadsTabClicked sends thread metadata without content", async () => {
294
- trackThreadsTabClicked({
295
- intelligence_status: "intelligence_not_enabled",
296
- thread_service_status: "unavailable",
297
- runtime_mode: "sse",
298
- runtime_url_type: "localhost",
299
- license_status: "none",
300
- telemetry_disabled: false,
301
- });
302
- await Promise.resolve();
303
- const [, init] = fetchMock.mock.calls[0]!;
304
- const body = JSON.parse((init?.body as string) ?? "{}") as {
305
- event: string;
306
- properties: Record<string, unknown>;
307
- package: { name: string; version?: string };
308
- };
309
- expect(body.event).toBe(TELEMETRY_EVENTS.threadsTabClicked);
310
- expect(body.properties).toMatchObject({
311
- intelligence_status: "intelligence_not_enabled",
312
- thread_service_status: "unavailable",
313
- runtime_mode: "sse",
314
- runtime_url_type: "localhost",
315
- license_status: "none",
316
- telemetry_disabled: false,
317
- package_name: "@copilotkit/web-inspector",
318
- package_version: webInspectorPackage.version,
319
- });
320
- expect(body.properties).toHaveProperty("inspector_distinct_id");
321
- expect(body.properties.inspector_distinct_id).toBe(
322
- body.properties.distinct_id,
323
- );
324
- expect(body.package).toEqual({
325
- name: "@copilotkit/web-inspector",
326
- version: webInspectorPackage.version,
327
- });
328
- });
329
-
330
- it("sends required threads CTA and viewed events", async () => {
331
- trackThreadsLockedViewed({
332
- intelligence_status: "intelligence_not_enabled",
333
- thread_service_status: "unavailable",
334
- });
335
- trackThreadsIntelligenceSignupClicked({
336
- cta: "signup",
337
- cta_surface: "threads_locked",
338
- posthog_distinct_id: "abc-123",
339
- });
340
- trackThreadsTalkToEngineerClicked({
341
- cta: "talk_to_engineer",
342
- cta_surface: "threads_locked",
343
- posthog_distinct_id: "abc-123",
344
- });
345
- trackTalkToEngineerClicked({
346
- cta: "talk_to_engineer",
347
- cta_surface: "threads_header",
348
- posthog_distinct_id: "abc-123",
349
- });
350
- trackThreadsEmptyEnabledViewed({
351
- intelligence_status: "intelligence_enabled",
352
- thread_service_status: "available",
353
- thread_count: 0,
354
- });
355
- trackThreadsEnabledViewed({
356
- intelligence_status: "intelligence_enabled",
357
- thread_service_status: "available",
358
- thread_count: 2,
359
- });
360
-
361
- await Promise.resolve();
362
-
363
- const payloads = fetchMock.mock.calls.map(([, init]) => {
364
- return JSON.parse((init?.body as string) ?? "{}") as {
365
- event: string;
366
- properties: Record<string, unknown>;
367
- };
368
- });
369
- const events = payloads.map((payload) => payload.event);
370
- expect(events).toEqual([
371
- TELEMETRY_EVENTS.threadsLockedViewed,
372
- TELEMETRY_EVENTS.threadsIntelligenceSignupClicked,
373
- TELEMETRY_EVENTS.threadsTalkToEngineerClicked,
374
- TELEMETRY_EVENTS.talkToEngineerClicked,
375
- TELEMETRY_EVENTS.threadsEmptyEnabledViewed,
376
- TELEMETRY_EVENTS.threadsEnabledViewed,
377
- ]);
378
- expect(payloads[0]!.properties).toMatchObject({
379
- intelligence_status: "intelligence_not_enabled",
380
- thread_service_status: "unavailable",
381
- });
382
- expect(payloads[1]!.properties).toMatchObject({
383
- cta: "signup",
384
- cta_surface: "threads_locked",
385
- posthog_distinct_id: "abc-123",
386
- });
387
- expect(payloads[2]!.properties).toMatchObject({
388
- cta: "talk_to_engineer",
389
- cta_surface: "threads_locked",
390
- posthog_distinct_id: "abc-123",
391
- });
392
- expect(payloads[3]!.properties).toMatchObject({
393
- cta: "talk_to_engineer",
394
- cta_surface: "threads_header",
395
- posthog_distinct_id: "abc-123",
396
- });
397
- expect(payloads[4]!.properties).toMatchObject({
398
- intelligence_status: "intelligence_enabled",
399
- thread_service_status: "available",
400
- thread_count: 0,
401
- });
402
- expect(payloads[5]!.properties).toMatchObject({
403
- intelligence_status: "intelligence_enabled",
404
- thread_service_status: "available",
405
- thread_count: 2,
406
- });
407
- });
408
- });
409
-
410
- // ─── Safe URL classification ────────────────────────────────────────────────
411
-
412
- describe("getRuntimeUrlType()", () => {
413
- it("classifies runtime URLs without exposing origins", () => {
414
- expect(getRuntimeUrlType(undefined)).toBe("missing");
415
- expect(getRuntimeUrlType("/api/copilotkit")).toBe("relative");
416
- expect(getRuntimeUrlType("http://localhost:4000")).toBe("localhost");
417
- expect(getRuntimeUrlType("https://example.com/api")).toBe("remote");
418
- });
419
- });
420
-
421
- // ─── Distinct ID lifecycle ───────────────────────────────────────────────────
422
-
423
- describe("distinct ID lifecycle", () => {
424
- it("persists across calls within the same session", () => {
425
- const first = getOrCreateTelemetryDistinctId();
426
- const second = getOrCreateTelemetryDistinctId();
427
- expect(first).toBe(second);
428
- expect(
429
- window.localStorage.getItem("cpk:inspector:telemetry:distinct_id"),
430
- ).toBe(first);
431
- });
432
-
433
- it("generates a UUID-v4-shaped value", () => {
434
- const id = getOrCreateTelemetryDistinctId();
435
- expect(id).toMatch(
436
- /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
437
- );
438
- });
439
-
440
- it("returns a UUID without throwing in SSR (window undefined)", () => {
441
- vi.stubGlobal("window", undefined);
442
- expect(() => getOrCreateTelemetryDistinctId()).not.toThrow();
443
- const id = getOrCreateTelemetryDistinctId();
444
- expect(id).toMatch(
445
- /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
446
- );
447
- });
448
-
449
- it("returns a UUID without throwing when localStorage.getItem throws", () => {
450
- vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
451
- throw new DOMException("QuotaExceededError");
452
- });
453
- expect(() => getOrCreateTelemetryDistinctId()).not.toThrow();
454
- const id = getOrCreateTelemetryDistinctId();
455
- expect(id).toMatch(/^[0-9a-f]{8}-/);
456
- });
457
-
458
- it("returns the same UUID across calls when localStorage throws (funnel coherence)", () => {
459
- vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
460
- throw new DOMException("QuotaExceededError");
461
- });
462
- vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
463
- throw new DOMException("QuotaExceededError");
464
- });
465
- const first = getOrCreateTelemetryDistinctId();
466
- const second = getOrCreateTelemetryDistinctId();
467
- expect(first).toBe(second);
468
- });
469
- });
470
-
471
- // ─── Persistence error-resilience ───────────────────────────────────────────
472
-
473
- describe("persistence error-resilience", () => {
474
- it("isTelemetryOptedOut returns false (not disabled) when localStorage throws", () => {
475
- vi.spyOn(window.localStorage, "getItem").mockImplementation(() => {
476
- throw new DOMException("SecurityError");
477
- });
478
- // Must fail to "not opted out" — if it returned true, all users in
479
- // restricted-storage contexts would have telemetry silently disabled.
480
- expect(isTelemetryOptedOut()).toBe(false);
481
- });
482
-
483
- it("setTelemetryOptOut does not throw when localStorage.setItem throws", () => {
484
- vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
485
- throw new DOMException("QuotaExceededError");
486
- });
487
- expect(() => setTelemetryOptOut(true)).not.toThrow();
488
- });
489
-
490
- it("markTelemetryDisclosureShown does not throw when localStorage.setItem throws", () => {
491
- vi.spyOn(window.localStorage, "setItem").mockImplementation(() => {
492
- throw new DOMException("QuotaExceededError");
493
- });
494
- // Failure means the disclosure fires on every mount instead of once —
495
- // a UX regression, not a data leak. The important invariant is no throw.
496
- expect(() => markTelemetryDisclosureShown()).not.toThrow();
497
- });
498
- });
499
-
500
- // ─── maybeShowDisclosure() ───────────────────────────────────────────────────
501
-
502
- describe("maybeShowDisclosure()", () => {
503
- it("logs once and sets the disclosure-shown flag", () => {
504
- maybeShowDisclosure();
505
- expect(consoleInfoSpy).toHaveBeenCalledTimes(1);
506
- const [message] = consoleInfoSpy.mock.calls[0]!;
507
- expect(message).toContain(TELEMETRY_DOCS_URL);
508
- expect(hasTelemetryDisclosureBeenShown()).toBe(true);
509
-
510
- maybeShowDisclosure();
511
- // No second log — flag short-circuits.
512
- expect(consoleInfoSpy).toHaveBeenCalledTimes(1);
513
- });
514
-
515
- it("does not log when the user is already opted out", () => {
516
- setTelemetryOptOut(true);
517
-
518
- maybeShowDisclosure();
519
-
520
- expect(consoleInfoSpy).not.toHaveBeenCalled();
521
- // The flag stays unset so a future opt-in flips back to "first run"
522
- // behavior — see the wrapper's design comment.
523
- expect(hasTelemetryDisclosureBeenShown()).toBe(false);
524
- });
525
- });
526
-
527
- // ─── getTelemetryDistinctIdForUrl() ─────────────────────────────────────────
528
-
529
- describe("getTelemetryDistinctIdForUrl()", () => {
530
- it("returns the persisted distinct-ID when not opted out", () => {
531
- const id = getTelemetryDistinctIdForUrl();
532
- expect(id).not.toBeNull();
533
- expect(id).toMatch(
534
- /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
535
- );
536
- // Same ID across calls — ensures URL propagation matches the ID
537
- // that goes on event sends.
538
- expect(getTelemetryDistinctIdForUrl()).toBe(id);
539
- });
540
-
541
- it("returns null when the user is opted out (no cross-domain leak)", () => {
542
- setTelemetryOptOut(true);
543
- expect(getTelemetryDistinctIdForUrl()).toBeNull();
544
- });
545
- });
546
-
547
- // ─── Opt-out round-trip ──────────────────────────────────────────────────────
548
-
549
- describe("opt-out round-trip", () => {
550
- it("setTelemetryOptOut(true) → isTelemetryOptedOut() is true", () => {
551
- expect(isTelemetryOptedOut()).toBe(false);
552
- setTelemetryOptOut(true);
553
- expect(isTelemetryOptedOut()).toBe(true);
554
- setTelemetryOptOut(false);
555
- expect(isTelemetryOptedOut()).toBe(false);
556
- });
557
- });