thread-telemetry.test.ts7.5 KBView on GitHub /**
* Scoped store telemetry: thread list, thread get, send — and nothing else.
*
* The scope is the point. CedarStore is one store holding ~30 slices, message bodies included,
* so a blanket subscription would be both a privacy leak and a volume problem. These tests pin
* the two halves of that contract: the three thread operations DO produce an event, an unrelated
* write produces none, and what does get emitted is counts and ids — never a body, a subject or
* a recipient, even when the action that fired carried all three.
*/
import { act } from '@testing-library/react';
import { useCedarStore } from '@/modules/store';
import {
peekClientTelemetryBufferForTests,
resetClientTelemetryForTests,
} from '@/lib/client-telemetry';
import {
recordThreadStoreTransition,
resetThreadTelemetryThrottleForTests,
} from '@/modules/store/threadTelemetry';
import type { ParsedMessage, ThreadData } from '@/modules/threads/threadList/store/threadSlice';
const THREAD_ID = 'thread-telemetry-1';
const DRAFT_SESSION_ID = 'draft-session-1';
const SECRET_BODY = '<p>Confidential: our floor is 4.2M ARR</p>';
const SECRET_SUBJECT = 'Re: pricing for Acme';
const SECRET_RECIPIENT = '<email>';
const makeDraft = (): ParsedMessage => ({
id: 'msg-draft-1',
threadId: THREAD_ID,
isDraft: true,
draftSessionId: DRAFT_SESSION_ID,
subject: SECRET_SUBJECT,
sender: { email: '<email>', name: 'Jesse' },
to: [{ email: SECRET_RECIPIENT, name: 'Buyer' }],
cc: null,
bcc: null,
tls: true,
receivedOn: '2026-01-01T00:00:00.000Z',
unread: false,
processedHtml: SECRET_BODY,
blobUrl: '',
tags: [{ id: 'DRAFT', name: 'DRAFT', type: 'system' }],
snippet: '',
attachments: [],
});
const seedThread = () => {
const draft = makeDraft();
const thread: ThreadData = {
id: THREAD_ID,
messages: [draft],
latest: draft,
hasUnread: false,
totalReplies: 1,
labels: [],
lastLoadedAt: Date.now(),
};
act(() => {
// setState directly, NOT through a named thread action — this seeding must not itself
// be what the assertions below are reading.
useCedarStore.setState((state) => ({
...state,
threadData: { ...state.threadData, [THREAD_ID]: thread },
}));
});
};
const events = () => peekClientTelemetryBufferForTests();
describe('scoped thread store telemetry', () => {
beforeEach(() => {
resetThreadTelemetryThrottleForTests();
resetClientTelemetryForTests();
// No transport: keep everything in the buffer where the test can read it.
Object.defineProperty(globalThis, 'fetch', {
value: undefined,
configurable: true,
writable: true,
});
});
afterEach(() => {
resetClientTelemetryForTests();
resetThreadTelemetryThrottleForTests();
});
it('emits threads.list when the thread list is replaced', () => {
act(() => {
useCedarStore.getState().setCurrentThreadList([
{ id: 'a', historyId: null },
{ id: 'b', historyId: null },
]);
});
const [event] = events();
expect(event).toMatchObject({ kind: 'store', name: 'threads.list', status: 'applied' });
expect(event.meta).toMatchObject({ listSize: 2, action: 'thread/setCurrentThreadList' });
});
it('emits thread.get when a thread is selected', () => {
seedThread();
act(() => {
useCedarStore.getState().selectThreadId(THREAD_ID);
});
const emitted = events().filter((e) => e.name === 'thread.get');
expect(emitted.length).toBeGreaterThan(0);
expect(emitted[0].meta).toMatchObject({ threadId: THREAD_ID, messageCount: 1 });
});
it('emits thread.send on an optimistic send, and carries no body, subject or recipient', () => {
seedThread();
resetClientTelemetryForTests();
act(() => {
useCedarStore.getState().optimisticSendDraft({
threadId: THREAD_ID,
draftSessionId: DRAFT_SESSION_ID,
sentMessageData: {
subject: SECRET_SUBJECT,
message: SECRET_BODY,
sender: { email: '<email>', name: 'Jesse' },
to: [{ email: SECRET_RECIPIENT, name: 'Buyer' }],
},
});
});
const sendEvents = events().filter((e) => e.name === 'thread.send');
expect(sendEvents).toHaveLength(1);
expect(sendEvents[0]).toMatchObject({
kind: 'store',
status: 'applied',
meta: { action: 'thread/optimisticSendDraft' },
});
// Everything the store just moved around was content. None of it may be on the wire.
const wire = JSON.stringify(events());
expect(wire).not.toContain('Confidential');
expect(wire).not.toContain(SECRET_RECIPIENT);
expect(wire).not.toContain('pricing');
expect(wire).not.toContain('4.2M');
});
it('emits NOTHING for unrelated store writes', () => {
act(() => {
const store = useCedarStore.getState();
store.setShowImages(true);
store.toggleBulkSelection('thread-x');
store.setUnreadOnly('inbox', true);
store.openThreadRemindDialog(['thread-x']);
});
expect(events()).toHaveLength(0);
});
it('throttles list churn to one event per action per second', () => {
act(() => {
const setList = useCedarStore.getState().setCurrentThreadList;
setList([{ id: 'a', historyId: null }]);
setList([{ id: 'a', historyId: null }, { id: 'b', historyId: null }]);
setList([
{ id: 'a', historyId: null },
{ id: 'b', historyId: null },
{ id: 'c', historyId: null },
]);
});
expect(events().filter((e) => e.name === 'threads.list')).toHaveLength(1);
});
});
describe('recordThreadStoreTransition', () => {
const STATE = { currentThreadList: [{ id: 'a' }], activeListSource: 'threads' };
beforeEach(() => {
resetThreadTelemetryThrottleForTests();
resetClientTelemetryForTests();
Object.defineProperty(globalThis, 'fetch', {
value: undefined,
configurable: true,
writable: true,
});
});
afterEach(() => {
jest.restoreAllMocks();
resetClientTelemetryForTests();
resetThreadTelemetryThrottleForTests();
});
it('reports how many transitions the throttle stood in for', () => {
const clock = jest.spyOn(Date, 'now');
clock.mockReturnValue(1_000_000);
recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);
// Three more inside the same second: dropped, but counted.
recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);
recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);
recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);
clock.mockReturnValue(1_002_000);
recordThreadStoreTransition('thread/setCurrentThreadList', () => STATE);
const emitted = events();
expect(emitted).toHaveLength(2);
expect(emitted[0].meta?.droppedSinceLast).toBeUndefined();
expect(emitted[1].meta?.droppedSinceLast).toBe(3);
});
it('never throttles a send, however fast they arrive', () => {
jest.spyOn(Date, 'now').mockReturnValue(1_000_000);
recordThreadStoreTransition('thread/optimisticSendDraft', () => STATE);
recordThreadStoreTransition('thread/optimisticSendDraft', () => STATE);
recordThreadStoreTransition('thread/popUndoSend', () => STATE);
expect(events().filter((e) => e.name === 'thread.send')).toHaveLength(3);
});
it('ignores an action name it does not know, and a non-string one', () => {
recordThreadStoreTransition('crm/setColumns', () => STATE);
recordThreadStoreTransition(undefined, () => STATE);
recordThreadStoreTransition(42, () => STATE);
expect(events()).toHaveLength(0);
});
});