mirror of
https://github.com/hanzoai/insights-js.git
synced 2026-10-30 18:34:19 +00:00
We own the ingestion server (hanzoai/insights: Django + rust/capture + nodejs plugin) AND this SDK, so there is no external wire contract to preserve. The server is already 100% posthog-free; this SDK was the last laggard. - $last_posthog_reset -> $last_insights_reset. This FIXES A LIVE MISMATCH: the server taxonomy (insights/taxonomy/taxonomy.py, insights/insightsql/ai.py) already expected the insights form while the SDK emitted the posthog form. - __PosthogExtensions__ -> __InsightsExtensions__; all PostHog/posthog/POSTHOG identifiers -> Insights/insights/INSIGHTS (src, tests, playground, playwright, scripts, rrweb subpackages, package.json keywords). - Hosts: DEFAULT_PLUGIN_HOST + DEFAULT_NUXT_HOST + the core host default pointed at PostHog's cloud (us.i.posthog.com) — a real bug. All -> insights.hanzo.ai. - Removed the us-assets/eu-assets CDN split in getRemoteConfig (PostHog-specific; Hanzo serves remote config from the same host) — it had degenerated to a no-op. - Fixed generate-docs writing to a stray references/@hanzo/ subdir. - Renamed the 3 posthog-named test files; dropped 58MB of stale reference snapshots named for upstream posthog versions we never shipped (they are generated output — references/ is now gitignored). Verified: typecheck errors 42 (HEAD) -> 18 (all pre-existing test-mock looseness); types package 0; every package.json valid with @hanzo/* names. Guard: grep -rioE 'posthog' packages ... | wc -l => 0
488 lines
18 KiB
TypeScript
488 lines
18 KiB
TypeScript
import React from 'react'
|
|
import { render, screen } from '@testing-library/react'
|
|
import { InsightsProvider } from '../src/app/InsightsProvider'
|
|
|
|
// Mock ClientInsightsProvider
|
|
const mockClientProvider = jest.fn(({ children }: { children: React.ReactNode }) => (
|
|
<div data-testid="client-provider">{children}</div>
|
|
))
|
|
jest.mock('../src/client/ClientInsightsProvider', () => ({
|
|
ClientInsightsProvider: (props: any) => mockClientProvider(props),
|
|
}))
|
|
|
|
// Mock next/headers
|
|
jest.mock('next/headers.js', () => ({
|
|
cookies: jest.fn(),
|
|
}))
|
|
|
|
// Mock nodeClientCache — the mock fn must be declared with `var` so it's
|
|
// hoisted and available inside the jest.mock factory (which Jest hoists above
|
|
// `const`/`let` declarations).
|
|
var mockGetAllFlagsAndPayloads = jest.fn()
|
|
|
|
jest.mock('../src/server/nodeClientCache', () => ({
|
|
getOrCreateNodeClient: jest.fn().mockImplementation(() => ({
|
|
getAllFlagsAndPayloads: (...args: any[]) => mockGetAllFlagsAndPayloads(...args),
|
|
})),
|
|
}))
|
|
|
|
// in test, just call the function directly (no caching)
|
|
;(React as any).cache = (fn: Function) => fn
|
|
|
|
describe('InsightsProvider', () => {
|
|
const originalEnv = process.env
|
|
|
|
beforeEach(() => {
|
|
mockClientProvider.mockClear()
|
|
process.env = { ...originalEnv }
|
|
})
|
|
|
|
afterAll(() => {
|
|
process.env = originalEnv
|
|
})
|
|
|
|
it('renders children via ClientInsightsProvider', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
children: <div data-testid="child">Hello</div>,
|
|
})
|
|
render(element)
|
|
expect(screen.getByTestId('child')).toBeInTheDocument()
|
|
})
|
|
|
|
it('passes apiKey and clientOptions to ClientInsightsProvider', async () => {
|
|
const clientOptions = { api_host: 'https://custom.insights.example.com' }
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
clientOptions,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
apiKey: 'phc_test123',
|
|
options: expect.objectContaining({ api_host: 'https://custom.insights.example.com' }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('trims apiKey and api_host before passing them to ClientInsightsProvider', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: ' phc_test123\n',
|
|
clientOptions: { api_host: ' https://custom.insights.example.com/\t ' },
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
apiKey: 'phc_test123',
|
|
options: expect.objectContaining({ api_host: 'https://custom.insights.example.com/' }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('does not pass bootstrap when bootstrapFlags is not set', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bootstrap: undefined,
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('Next.js client defaults', () => {
|
|
it('applies Next.js-specific defaults when no options provided', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.objectContaining({
|
|
api_host: 'https://insights.hanzo.ai',
|
|
persistence: 'localStorage+cookie',
|
|
opt_out_capturing_persistence_type: 'cookie',
|
|
opt_out_persistence_by_default: true,
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('allows user clientOptions to override defaults', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
clientOptions: {
|
|
persistence: 'memory',
|
|
opt_out_persistence_by_default: false,
|
|
},
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.objectContaining({
|
|
persistence: 'memory',
|
|
opt_out_capturing_persistence_type: 'cookie',
|
|
opt_out_persistence_by_default: false,
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
it('does not call cookies() when bootstrapFlags is off (static-safe)', async () => {
|
|
const { cookies } = require('next/headers.js')
|
|
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(cookies).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('warns and renders children without ClientInsightsProvider when apiKey is empty and env var is not set', async () => {
|
|
delete process.env.NEXT_PUBLIC_INSIGHTS_KEY
|
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
|
|
const element = await InsightsProvider({
|
|
apiKey: '',
|
|
children: <div data-testid="child">Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(screen.getByTestId('child')).toBeInTheDocument()
|
|
expect(mockClientProvider).not.toHaveBeenCalled()
|
|
expect(warnSpy).toHaveBeenCalledWith('[Insights Next.js] apiKey is required — Insights will not be initialized')
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('warns when apiKey does not start with phc_', async () => {
|
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
const element = await InsightsProvider({
|
|
apiKey: 'not_a_valid_key',
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('does not start with "phc_"'))
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('does not warn when apiKey starts with phc_', async () => {
|
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(warnSpy).not.toHaveBeenCalled()
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
describe('environment variable defaults', () => {
|
|
it('reads apiKey from NEXT_PUBLIC_INSIGHTS_KEY when prop is omitted', async () => {
|
|
process.env.NEXT_PUBLIC_INSIGHTS_KEY = 'phc_from_env'
|
|
const element = await InsightsProvider({
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'phc_from_env' }))
|
|
})
|
|
|
|
it('prefers apiKey prop over env var', async () => {
|
|
process.env.NEXT_PUBLIC_INSIGHTS_KEY = 'phc_from_env'
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_from_prop',
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'phc_from_prop' }))
|
|
})
|
|
|
|
it('reads api_host from NEXT_PUBLIC_INSIGHTS_HOST when not in clientOptions', async () => {
|
|
process.env.NEXT_PUBLIC_INSIGHTS_HOST = 'https://insights.hanzo.ai'
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.objectContaining({ api_host: 'https://insights.hanzo.ai' }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('trims apiKey and api_host from env vars', async () => {
|
|
process.env.NEXT_PUBLIC_INSIGHTS_KEY = ' phc_from_env\n'
|
|
process.env.NEXT_PUBLIC_INSIGHTS_HOST = ' https://insights.hanzo.ai/\t '
|
|
const element = await InsightsProvider({
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
apiKey: 'phc_from_env',
|
|
options: expect.objectContaining({ api_host: 'https://insights.hanzo.ai/' }),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('prefers clientOptions.api_host over env var', async () => {
|
|
process.env.NEXT_PUBLIC_INSIGHTS_HOST = 'https://insights.hanzo.ai'
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
clientOptions: { api_host: 'https://custom.insights.example.com' },
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.objectContaining({ api_host: 'https://custom.insights.example.com' }),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('with bootstrapFlags', () => {
|
|
const identifiedCookieValue = JSON.stringify({
|
|
distinct_id: 'user_abc',
|
|
$device_id: 'device_xyz',
|
|
$user_state: 'identified',
|
|
})
|
|
const anonymousCookieValue = JSON.stringify({ distinct_id: 'device_xyz', $device_id: 'device_xyz' })
|
|
|
|
function setupCookieMock(cookieValue: string) {
|
|
const { cookies } = require('next/headers.js')
|
|
cookies.mockResolvedValue({
|
|
get: jest.fn((name: string) => {
|
|
if (name === 'ph_phc_test123_insights') {
|
|
return { name, value: cookieValue }
|
|
}
|
|
// Consent cookie — opted in so flag evaluation proceeds
|
|
if (name === '__ph_opt_in_out_phc_test123') {
|
|
return { name, value: '1' }
|
|
}
|
|
return undefined
|
|
}),
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockGetAllFlagsAndPayloads.mockReset()
|
|
|
|
mockGetAllFlagsAndPayloads.mockResolvedValue({
|
|
featureFlags: { 'flag-1': true },
|
|
featureFlagPayloads: { 'flag-1': { color: 'blue' } },
|
|
})
|
|
|
|
setupCookieMock(identifiedCookieValue)
|
|
})
|
|
|
|
it('evaluates all flags when bootstrapFlags is true', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockGetAllFlagsAndPayloads).toHaveBeenCalledWith('user_abc', {})
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bootstrap: expect.objectContaining({
|
|
featureFlags: { 'flag-1': true },
|
|
featureFlagPayloads: { 'flag-1': { color: 'blue' } },
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('evaluates specific flags when flagKeys provided', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: { flags: ['flag-1'] },
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockGetAllFlagsAndPayloads).toHaveBeenCalledWith('user_abc', { flagKeys: ['flag-1'] })
|
|
})
|
|
|
|
it('always includes payloads in bootstrap', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockGetAllFlagsAndPayloads).toHaveBeenCalled()
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bootstrap: expect.objectContaining({
|
|
featureFlagPayloads: { 'flag-1': { color: 'blue' } },
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('disables first flag load on client when bootstrap succeeds', async () => {
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.objectContaining({
|
|
advanced_disable_feature_flags_on_first_load: true,
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
|
|
it('renders without bootstrap when flag evaluation fails', async () => {
|
|
mockGetAllFlagsAndPayloads.mockRejectedValue(new Error('network timeout'))
|
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation()
|
|
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
bootstrap: undefined,
|
|
})
|
|
)
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'[Insights Next.js] Failed to evaluate bootstrap flags:',
|
|
expect.any(Error)
|
|
)
|
|
warnSpy.mockRestore()
|
|
})
|
|
|
|
it('does not disable first flag load when bootstrap fails', async () => {
|
|
mockGetAllFlagsAndPayloads.mockRejectedValue(new Error('network timeout'))
|
|
jest.spyOn(console, 'warn').mockImplementation()
|
|
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockClientProvider).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
options: expect.not.objectContaining({
|
|
advanced_disable_feature_flags_on_first_load: true,
|
|
}),
|
|
})
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('consent awareness', () => {
|
|
const identifiedCookieValue = JSON.stringify({
|
|
distinct_id: 'user_abc',
|
|
$device_id: 'device_xyz',
|
|
$user_state: 'identified',
|
|
})
|
|
|
|
function setupCookiesWithConsent(cookies: Record<string, string>) {
|
|
const { cookies: cookiesFn } = require('next/headers.js')
|
|
cookiesFn.mockResolvedValue({
|
|
get: jest.fn((name: string) => {
|
|
const value = cookies[name]
|
|
return value !== undefined ? { name, value } : undefined
|
|
}),
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mockGetAllFlagsAndPayloads.mockReset()
|
|
mockGetAllFlagsAndPayloads.mockResolvedValue({
|
|
featureFlags: { 'flag-1': true },
|
|
featureFlagPayloads: { 'flag-1': { color: 'blue' } },
|
|
})
|
|
})
|
|
|
|
it('skips flag evaluation when consent cookie is 0', async () => {
|
|
setupCookiesWithConsent({
|
|
ph_phc_test123_insights: identifiedCookieValue,
|
|
__ph_opt_in_out_phc_test123: '0',
|
|
})
|
|
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockGetAllFlagsAndPayloads).not.toHaveBeenCalled()
|
|
expect(mockClientProvider).toHaveBeenCalledWith(expect.objectContaining({ bootstrap: undefined }))
|
|
})
|
|
|
|
it('evaluates flags when consent cookie is 1', async () => {
|
|
setupCookiesWithConsent({
|
|
ph_phc_test123_insights: identifiedCookieValue,
|
|
__ph_opt_in_out_phc_test123: '1',
|
|
})
|
|
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockGetAllFlagsAndPayloads).toHaveBeenCalledWith('user_abc', {})
|
|
})
|
|
|
|
it('skips flag evaluation when no consent cookie and opt_out_capturing_by_default is true', async () => {
|
|
setupCookiesWithConsent({
|
|
ph_phc_test123_insights: identifiedCookieValue,
|
|
})
|
|
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
clientOptions: { opt_out_capturing_by_default: true },
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockGetAllFlagsAndPayloads).not.toHaveBeenCalled()
|
|
expect(mockClientProvider).toHaveBeenCalledWith(expect.objectContaining({ bootstrap: undefined }))
|
|
})
|
|
|
|
it('evaluates flags when no consent cookie and opt_out_capturing_by_default is false (default)', async () => {
|
|
setupCookiesWithConsent({
|
|
ph_phc_test123_insights: identifiedCookieValue,
|
|
})
|
|
|
|
const element = await InsightsProvider({
|
|
apiKey: 'phc_test123',
|
|
bootstrapFlags: true,
|
|
children: <div>Child</div>,
|
|
})
|
|
render(element)
|
|
|
|
expect(mockGetAllFlagsAndPayloads).toHaveBeenCalledWith('user_abc', {})
|
|
})
|
|
})
|
|
})
|