Files
insights/products/mcp_analytics/frontend/mcpDashboardOverviewLogic.test.ts
zeekay bc4ddf332b chore: rebase the tree onto upstream master fa1a9a60
Mechanically re-derived: bin/debrand runs over a fresh import of
posthog/posthog@fa1a9a60d4 (2026-08-06).
The pipeline (.hanzo/workflows), the Dockerfiles and bin/docker*
entrypoints, compose files, LLM.md and the squashed initial migration
are restored from the shipped tree, same as the previous import.

The prune's import-closure guard kept 21 products the new core now
imports; dropping them is a refactor now, recorded rather than forced.
2026-08-06 01:25:36 -07:00

600 lines
25 KiB
TypeScript

import { MOCK_DEFAULT_TEAM } from 'lib/api.mock'
import { router } from 'kea-router'
import { expectLogic } from 'kea-test-utils'
import api from 'lib/api'
import { dayjs } from 'lib/dayjs'
import { urls } from 'scenes/urls'
import { initKeaTests } from '~/test/init'
import { AnyPropertyFilter, PropertyFilterType, PropertyOperator } from '~/types'
import { HARNESS_BY_LABEL, harnessLogo } from './dashboard/harnessRegistry'
import {
type ActivityRow,
type BucketRow,
buildDailyActivity,
buildKPIs,
buildKpiWindow,
buildToolDailySeries,
deltaPct,
mcpDashboardOverviewLogic,
pickNotableSessions,
type SessionRow,
type ToolDailyRow,
} from './mcpDashboardOverviewLogic'
import { BUCKET_FORMAT } from './timeBuckets'
jest.mock('lib/api')
jest.mock('./generated/api', () => ({
mcpAnalyticsIntentClustersRetrieve: jest.fn().mockResolvedValue({ status: 'idle', clusters: [] }),
mcpAnalyticsIntentClustersRecompute: jest.fn(),
}))
const mockApi = api as jest.Mocked<typeof api>
function session(overrides: Partial<SessionRow> & { session_id: string }): SessionRow {
return {
tool_calls: 0,
errors: 0,
error_rate_pct: 0,
duration_seconds: 0,
distinct_tools: 0,
last_seen: '',
...overrides,
}
}
describe('mcpDashboardOverviewLogic', () => {
describe('harnessLogo', () => {
// The expected labels mirror HARNESS_LABELS in mcp_harness.py, minus "Other".
// If the backend renames or adds a label, update this list to keep it in sync
// and add the corresponding entry to HARNESS_BY_LABEL in harnessRegistry.ts.
const EXPECTED_HARNESS_LABELS = [
'Claude Desktop',
'Claude Code (VS Code)',
'Claude Agent SDK',
'Claude Code',
'Claude.ai',
'Anthropic API',
'Cowork',
'Claude Design',
'ChatGPT',
'OpenAI Agent Builder',
'OpenAI Responses API',
'OpenAI',
'OpenAI Codex',
'Grok',
'Cursor',
'VS Code',
'Windsurf',
'Replit',
'Lovable',
'Manus',
'CodeRabbit',
'Notion',
'Linear',
'LibreChat',
'Pi',
'Antigravity',
'Poke',
'opencode',
'Kiro',
'Desktop Commander',
'Insights CLI',
]
it.each(EXPECTED_HARNESS_LABELS)('HARNESS_BY_LABEL has an entry for backend label %s', (label) => {
expect(Object.prototype.hasOwnProperty.call(HARNESS_BY_LABEL, label)).toBe(true)
})
it.each([
'Claude Code',
'OpenAI',
'Cursor',
'Linear',
'CodeRabbit',
'Notion',
'Replit',
'Windsurf',
'opencode',
'Lovable',
'Manus',
'LibreChat',
'Pi',
'Antigravity',
])('resolves a logo for the %s category', (category) => {
expect(harnessLogo(category)?.src).toBeTruthy()
})
it.each(['Anthropic API', 'Poke', 'Kiro', 'Desktop Commander', 'Other'])(
'has no logo for the logo-less %s category',
(category) => {
expect(harnessLogo(category)).toBeUndefined()
}
)
})
describe('deltaPct', () => {
it.each([
[150, 100, 50],
[50, 100, -50],
[0, 0, 0],
[100, 0, null],
])('deltaPct(%s, %s) = %s', (current, previous, expected) => {
expect(deltaPct(current, previous)).toBe(expected)
})
})
describe('buildToolDailySeries', () => {
it('pivots flat rows into sorted day labels and per-tool series ordered by volume', () => {
const rows: ToolDailyRow[] = [
{ day: '2024-01-02', tool: 'a', calls: 5 },
{ day: '2024-01-01', tool: 'a', calls: 3 },
{ day: '2024-01-01', tool: 'b', calls: 10 },
]
expect(buildToolDailySeries(rows)).toEqual({
labels: ['2024-01-01', '2024-01-02'],
tools: [
{ tool: 'b', data: [10, 0] },
{ tool: 'a', data: [3, 5] },
],
})
})
it('keeps the top 8 tools and folds the rest into a single "Other" series', () => {
// 10 tools on one day, each with a distinct volume (tool-0 busiest … tool-9 quietest).
const rows: ToolDailyRow[] = Array.from({ length: 10 }, (_, i) => ({
day: '2024-01-01',
tool: `tool-${i}`,
calls: 100 - i,
}))
const { tools } = buildToolDailySeries(rows)
expect(tools).toHaveLength(9) // 8 named + Other
expect(tools.slice(0, 8).map((t) => t.tool)).toEqual([
'tool-0',
'tool-1',
'tool-2',
'tool-3',
'tool-4',
'tool-5',
'tool-6',
'tool-7',
])
// Other = tool-8 (92) + tool-9 (91)
expect(tools[8]).toEqual({ tool: 'Other', data: [183] })
})
it('spans the supplied bucket keys and zero-fills days without calls', () => {
const rows: ToolDailyRow[] = [{ day: '2024-01-02', tool: 'a', calls: 5 }]
const bucketKeys = ['2024-01-01', '2024-01-02', '2024-01-03']
expect(buildToolDailySeries(rows, bucketKeys)).toEqual({
labels: bucketKeys,
tools: [{ tool: 'a', data: [0, 5, 0] }],
})
})
it('returns empty tools with the supplied labels when there are no rows', () => {
expect(buildToolDailySeries([], ['2024-01-01'])).toEqual({ labels: ['2024-01-01'], tools: [] })
})
})
describe('buildDailyActivity', () => {
it('projects rows onto the bucket keys, defaulting missing buckets to zero', () => {
const rows: ActivityRow[] = [
{ day: '2024-01-01 00:00:00', successes: 10, errors: 2 },
{ day: '2024-01-03 00:00:00', successes: 4, errors: 1 },
]
const bucketKeys = ['2024-01-01 00:00:00', '2024-01-02 00:00:00', '2024-01-03 00:00:00']
expect(buildDailyActivity(rows, bucketKeys)).toEqual({
labels: bucketKeys,
successes: [10, 0, 4],
errors: [2, 0, 1],
})
})
it('returns all-zero series when there are no rows', () => {
const bucketKeys = ['2024-01-01 00:00:00', '2024-01-02 00:00:00', '2024-01-03 00:00:00']
expect(buildDailyActivity([], bucketKeys)).toEqual({
labels: bucketKeys,
successes: [0, 0, 0],
errors: [0, 0, 0],
})
})
// The in-progress-tail dash applies `fromIndex = successes.length - 1` to line up with the
// last bucket key, so the series must stay exactly bucketKeys-length — including when rows
// fall outside the window. If this drifts, the dashed segment lands on the wrong point.
it('keeps series length equal to bucketKeys, ignoring rows outside the window', () => {
const bucketKeys = ['2024-01-01 00:00:00', '2024-01-02 00:00:00', '2024-01-03 00:00:00']
const rows: ActivityRow[] = [
{ day: '2024-01-02 00:00:00', successes: 5, errors: 1 },
{ day: '2023-12-31 00:00:00', successes: 9, errors: 9 }, // outside bucketKeys — must be dropped
]
const result = buildDailyActivity(rows, bucketKeys)
expect(result.labels).toHaveLength(bucketKeys.length)
expect(result.successes).toHaveLength(bucketKeys.length)
expect(result.errors).toHaveLength(bucketKeys.length)
expect(result.successes).toEqual([0, 5, 0])
})
})
describe('buildKpiWindow', () => {
it.each([
['2024-01-08', '2024-01-15', 'day', '2024-01-08 00:00:00', '2023-12-31'],
['2024-01-01', '2024-01-31', 'day', '2024-01-01 00:00:00', '2023-12-01'],
])(
'extends [%s, %s] back to an equal-length prior window with cutoff at the selected start',
(dateFrom, dateTo, interval, expectedCutoff, expectedPriorStart) => {
const window = buildKpiWindow({ dateFrom, dateTo }, 'UTC', interval as 'day')
expect(window.currentStartBucket).toBe(expectedCutoff)
expect(dayjs(window.dateFrom).format('YYYY-MM-DD')).toBe(expectedPriorStart)
}
)
it('rolls an hour-level range from now and steps the prior window back equally', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-18T12:30:00Z'))
try {
// "-1h" resolves to the trailing hour; prior window is the hour before that.
const window = buildKpiWindow({ dateFrom: '-1h', dateTo: null }, 'UTC', 'minute')
expect(window.currentStartBucket).toBe('2026-06-18 11:30:00')
expect(dayjs(window.dateFrom).toISOString()).toBe('2026-06-18T10:29:00.000Z')
} finally {
jest.useRealTimers()
}
})
it('resolves the relative -7d default against now', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-06-18T12:00:00Z'))
try {
const window = buildKpiWindow({ dateFrom: '-7d', dateTo: null }, 'UTC', 'day')
expect(window.currentStartBucket).toBe('2026-06-11 00:00:00')
// doubled window: prior 8 day-buckets before the cutoff
expect(dayjs(window.dateFrom).format('YYYY-MM-DD')).toBe('2026-06-03')
} finally {
jest.useRealTimers()
}
})
})
describe('buildKPIs', () => {
it('splits current vs prior buckets and computes values, deltas, and sparklines', () => {
const rows: BucketRow[] = [
{ bucket: '2024-01-09', sessions: 20, tool_calls: 200, errors: 15, p95: 300 },
{ bucket: '2024-01-08', sessions: 10, tool_calls: 100, errors: 5, p95: 200 },
{ bucket: '2024-01-01', sessions: 5, tool_calls: 50, errors: 5, p95: 150 },
]
const kpis = buildKPIs(rows, '2024-01-08')
expect(kpis.sessions).toEqual({
value: 30,
previousValue: 5,
deltaPct: 500,
sparkline: [10, 20], // current sorted by bucket
sparklineLabels: ['2024-01-08', '2024-01-09'],
goodDirection: 'up',
})
expect(kpis.toolCalls).toMatchObject({
value: 300,
previousValue: 50,
deltaPct: 500,
sparkline: [100, 200],
})
expect(kpis.p95LatencyMs).toMatchObject({
value: 300,
previousValue: 150,
deltaPct: 100,
goodDirection: 'down',
})
expect(kpis.errorRatePct).toMatchObject({
value: 7.5,
previousValue: 10,
deltaPct: -25,
goodDirection: 'down',
})
})
it('returns null deltas when there is no prior-period data', () => {
const rows: BucketRow[] = [{ bucket: '2024-01-08', sessions: 10, tool_calls: 100, errors: 0, p95: 200 }]
expect(buildKPIs(rows, '2024-01-08').sessions.deltaPct).toBeNull()
})
})
describe('pickNotableSessions', () => {
it('returns nothing for an empty set', () => {
expect(pickNotableSessions([])).toEqual([])
})
it('picks one session per rule', () => {
const rows: SessionRow[] = [
session({
session_id: 'A',
tool_calls: 20,
errors: 10,
error_rate_pct: 50,
duration_seconds: 100,
distinct_tools: 2,
}),
session({
session_id: 'B',
tool_calls: 5,
errors: 5,
error_rate_pct: 100,
duration_seconds: 30,
distinct_tools: 1,
}),
session({
session_id: 'C',
tool_calls: 8,
errors: 1,
error_rate_pct: 12,
duration_seconds: 40,
distinct_tools: 9,
}),
session({
session_id: 'D',
tool_calls: 15,
errors: 0,
error_rate_pct: 0,
duration_seconds: 5,
distinct_tools: 3,
}),
session({
session_id: 'E',
tool_calls: 12,
errors: 1,
error_rate_pct: 8,
duration_seconds: 60,
distinct_tools: 2,
}),
]
// A is the busiest, but it is already listed under worst_error_rate, so no high_activity row.
expect(pickNotableSessions(rows).map((p) => ({ id: p.session.session_id, rule: p.rule }))).toEqual([
{ id: 'A', rule: 'worst_error_rate' },
{ id: 'B', rule: 'all_fail' },
{ id: 'C', rule: 'most_exploratory' },
{ id: 'D', rule: 'exemplar' },
])
})
it('lists a session that satisfies two rules once, under the first that matched', () => {
const rows: SessionRow[] = [
session({ session_id: 'both', tool_calls: 50, distinct_tools: 8, duration_seconds: 100 }),
session({ session_id: 'filler-a', tool_calls: 2, distinct_tools: 1 }),
session({ session_id: 'filler-b', tool_calls: 2, distinct_tools: 1 }),
]
// 'both' is the most exploratory session and the volume outlier. Two rows would mean a
// duplicate session_id, which is the table's React key.
expect(pickNotableSessions(rows).map((p) => ({ id: p.session.session_id, rule: p.rule }))).toEqual([
{ id: 'both', rule: 'most_exploratory' },
])
})
it('returns nothing when every session is small, rather than reaching for a filler row', () => {
const rows: SessionRow[] = [
session({ session_id: 'errored', tool_calls: 2, errors: 1, error_rate_pct: 50, distinct_tools: 2 }),
session({ session_id: 'clean', tool_calls: 2, distinct_tools: 2 }),
]
expect(pickNotableSessions(rows)).toEqual([])
})
it('flags a genuine volume outlier and leaves unremarkable sessions out entirely', () => {
const rows: SessionRow[] = [
session({ session_id: 'firehose', tool_calls: 40, distinct_tools: 2, duration_seconds: 100 }),
session({ session_id: 'explorer', tool_calls: 4, distinct_tools: 6, duration_seconds: 10 }),
session({ session_id: 'single-a', tool_calls: 1, distinct_tools: 1 }),
session({ session_id: 'single-b', tool_calls: 1, distinct_tools: 1 }),
]
expect(pickNotableSessions(rows).map((p) => ({ id: p.session.session_id, rule: p.rule }))).toEqual([
{ id: 'explorer', rule: 'most_exploratory' },
{ id: 'firehose', rule: 'high_activity' },
])
})
})
describe('kpiIncompleteTail', () => {
beforeEach(() => {
jest.clearAllMocks()
initKeaTests()
jest.spyOn(mockApi, 'query').mockResolvedValue({ results: [] })
})
// Reads the sparkline's own labels rather than the zero-filled axis: on a day with no calls
// yet the KPI series stops at yesterday, which is settled, so dashing its last point would
// mark a complete bucket as in progress.
it('tracks the KPI sparkline labels, not the chart axis', async () => {
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
const bucket = (daysAgo: number): string =>
dayjs().tz(logic.values.timezone).subtract(daysAgo, 'day').startOf('day').format(BUCKET_FORMAT)
const row = (daysAgo: number): BucketRow => ({
bucket: bucket(daysAgo),
sessions: 3,
tool_calls: 30,
errors: 1,
p95: 100,
})
logic.actions.loadKPIsSuccess(buildKPIs([row(1), row(0)], bucket(1)))
expect(logic.values.kpiIncompleteTail).toBe(true)
logic.actions.loadKPIsSuccess(buildKPIs([row(3), row(2)], bucket(3)))
expect(logic.values.kpiIncompleteTail).toBe(false)
})
})
describe('filter wiring', () => {
beforeEach(() => {
jest.clearAllMocks()
initKeaTests()
jest.spyOn(mockApi, 'query').mockResolvedValue({ results: [] } as any)
})
function reloadCallsSince(callIndex: number): any[] {
return mockApi.query.mock.calls.slice(callIndex).map((call) => call[0] as any)
}
// InsightsQL query nodes carry filters under `.filters`; the typed
// MCPHarnessBreakdownQuery node carries dateRange/properties/filterTestAccounts
// at the top level. This reads whichever shape a reload used.
const filtersOf = (call: any): Record<string, any> => call.filters ?? call
// The users query returns a single [current_users, prior_users] row; loadUsers maps
// column 0 → value, column 1 → previousValue, and derives the delta. Pins that column
// mapping and the deltaPct wiring — a swap or a dropped delta would slip past the other
// tests, which only ever see empty results.
it('maps the users query columns to the current/prior metric', async () => {
mockApi.query.mockImplementation(async (node: any) =>
typeof node?.query === 'string' && node.query.includes('current_users')
? ({ results: [[42, 30]] } as any)
: ({ results: [] } as any)
)
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
expect(logic.values.users).toEqual({
value: 42,
previousValue: 30,
deltaPct: 40,
sparkline: [],
sparklineLabels: [],
goodDirection: 'up',
})
})
// A bare dateTrunc returns a typed DateTime that the query API stamps with the project's UTC
// offset, which the client reads back as an instant and converts, shifting the bucket away
// from the wall-clock keys it joins and compares against (an empty activity chart and a
// skewed KPI split on any non-UTC project). Pins the toString on all three bucketed queries.
it('renders every bucketed query with a stringified dateTrunc', async () => {
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
const bucketed = mockApi.query.mock.calls
.map((call) => (call[0] as any).query)
.filter((query: string | undefined): query is string => !!query?.includes('dateTrunc('))
expect(bucketed).toHaveLength(1)
expect(bucketed.filter((query) => !query.includes('toString(dateTrunc('))).toEqual([])
})
it('reloads every tile when the date filter changes', async () => {
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
const callsBefore = mockApi.query.mock.calls.length
await expectLogic(logic, () => {
logic.actions.setDateFilter('-30d', null)
}).toFinishAllListeners()
const reloads = reloadCallsSince(callsBefore)
// Seven tiles: KPI + users + the five breakdown queries.
expect(reloads.length).toBe(7)
// The five breakdowns pass the raw selected range straight through.
const breakdowns = reloads.filter((call) => filtersOf(call).dateRange?.date_from === '-30d')
expect(breakdowns).toHaveLength(5)
// The KPI and users tiles widen to an absolute doubled window so they can compare against the prior period.
const kpi = reloads.find((call) => call.query?.includes('AS bucket'))
expect(kpi?.filters.dateRange.date_from).not.toBe('-30d')
expect(dayjs(kpi?.filters.dateRange.date_from).isValid()).toBe(true)
const usersTile = reloads.find((call) => call.query?.includes('current_users'))
expect(usersTile?.filters.dateRange.date_from).not.toBe('-30d')
expect(dayjs(usersTile?.filters.dateRange.date_from).isValid()).toBe(true)
})
it.each([[false], [true]])('passes filterTestAccounts=%s to every tile', async (enabled) => {
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
// enabled=false is the default mount state; enabled=true reloads after toggling.
const callsBefore = enabled ? mockApi.query.mock.calls.length : 0
if (enabled) {
await expectLogic(logic, () => {
logic.actions.setFilterTestAccounts(true)
}).toFinishAllListeners()
}
const reloads = reloadCallsSince(callsBefore)
expect(reloads.length).toBe(7)
expect(reloads.every((call) => filtersOf(call).filterTestAccounts === enabled)).toBe(true)
})
it('defaults the filter from the team test_account_filters_default_checked setting', async () => {
initKeaTests(true, { ...MOCK_DEFAULT_TEAM, test_account_filters_default_checked: true })
jest.spyOn(mockApi, 'query').mockResolvedValue({ results: [] } as any)
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
// No explicit toggle, yet every tile filters internal users because the team default is on.
const reloads = mockApi.query.mock.calls.map((call) => call[0] as any)
expect(reloads.length).toBeGreaterThanOrEqual(7)
expect(reloads.every((call) => filtersOf(call).filterTestAccounts === true)).toBe(true)
})
const EVENT_FILTER: AnyPropertyFilter = {
key: '$mcp_tool_name',
value: ['create_insight'],
operator: PropertyOperator.Exact,
type: PropertyFilterType.Event,
}
// Feature-flag filters arrive as ordinary $feature/<key> event-property filters.
const FLAG_FILTER: AnyPropertyFilter = {
key: '$feature/mcp-new-thing',
value: ['test'],
operator: PropertyOperator.Exact,
type: PropertyFilterType.Event,
}
it.each([
['event property', EVENT_FILTER],
['feature flag', FLAG_FILTER],
])('passes %s filters to every tile', async (_label, filter) => {
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
const callsBefore = mockApi.query.mock.calls.length
await expectLogic(logic, () => {
logic.actions.setPropertyFilters([filter])
}).toFinishAllListeners()
const reloads = reloadCallsSince(callsBefore)
expect(reloads.length).toBe(7)
expect(
reloads.every((call) => JSON.stringify(filtersOf(call).properties) === JSON.stringify([filter]))
).toBe(true)
})
it('syncs property filters to the URL and clears the param when emptied', async () => {
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
await expectLogic(logic, () => {
logic.actions.setPropertyFilters([EVENT_FILTER])
}).toFinishAllListeners()
expect(router.values.searchParams.properties).toEqual([EVENT_FILTER])
await expectLogic(logic, () => {
logic.actions.setPropertyFilters([])
}).toFinishAllListeners()
expect(router.values.searchParams.properties).toBeUndefined()
})
it('hydrates property filters from the URL on mount', async () => {
router.actions.push(urls.mcpAnalyticsDashboard(), { properties: [EVENT_FILTER] })
const logic = mcpDashboardOverviewLogic()
logic.mount()
await expectLogic(logic).toFinishAllListeners()
expect(logic.values.propertyFilters).toEqual([EVENT_FILTER])
})
})
})