mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
setTelemetry(undefined) deleted @hanzo/event's page-client registry — the map that makes "one stream per page" true. It reads as test hygiene, and that is where it came from, but setTelemetry(undefined) is also what TelemetryProvider calls from its cleanup effect: every unmount, every consent change, every prop change that rebuilds the client. So the guarantee this package exists to hold was being dropped by this package. The next mount gets a client that has never seen the page and counts the view again — one page load, two pageviews on the wire, with nothing on either row to say they came from different clients. owner.test.tsx is entirely about not emitting twice and could not see it, because the delete happened inside the very call the suite used to reset between tests. The reset belongs to the page, so it belongs to the package that owns the registry: @hanzo/event 0.3.40 names it resetClients(), and the suites call it in beforeEach, where "each `it` is its own page load" is true and says so. Pins the double as a test: mount, unmount, mount, expect one pageview.
251 lines
8.1 KiB
TypeScript
251 lines
8.1 KiB
TypeScript
// @vitest-environment happy-dom
|
|
//
|
|
// The no-double-fire guarantee, tested from the outside: count what reaches the
|
|
// wire. `GuiProvider` mounts `<TelemetryProvider owner="gui">` for EVERY gui app,
|
|
// and several apps already mount their own analytics provider — so the one thing
|
|
// that must never happen is two clients emitting into `/v1/event`.
|
|
//
|
|
// The fleet holds both nestings (verified across the apps that mount both), so
|
|
// both are tested here:
|
|
//
|
|
// gui OUTSIDE, app INSIDE — hanzo.app, console
|
|
// app OUTSIDE, gui INSIDE — store, identity
|
|
//
|
|
// plus the two shapes that have to keep working: a bare gui app collects with no
|
|
// wiring at all, and two nested GuiProviders are still one stream.
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { act } from 'react'
|
|
import { createRoot, type Root } from 'react-dom/client'
|
|
import { createAnalytics, resetClients } from '@hanzo/event'
|
|
import { AnalyticsProvider } from '@hanzo/event/react'
|
|
import { TelemetryProvider } from '../src/TelemetryProvider'
|
|
import { getTelemetry, setTelemetry } from '../src/telemetry'
|
|
|
|
interface Sent {
|
|
url: string
|
|
batch: Array<Record<string, unknown>>
|
|
}
|
|
|
|
let sent: Sent[]
|
|
let container: HTMLDivElement
|
|
let root: Root
|
|
|
|
function installStorage(): void {
|
|
const map = new Map<string, string>()
|
|
Object.defineProperty(window, 'localStorage', {
|
|
configurable: true,
|
|
value: {
|
|
get length() {
|
|
return map.size
|
|
},
|
|
clear: () => map.clear(),
|
|
getItem: (k: string) => map.get(k) ?? null,
|
|
key: (i: number) => Array.from(map.keys())[i] ?? null,
|
|
removeItem: (k: string) => void map.delete(k),
|
|
setItem: (k: string, v: string) => void map.set(k, String(v)),
|
|
} satisfies Storage,
|
|
})
|
|
}
|
|
|
|
const events = (): Array<Record<string, unknown>> => sent.flatMap((s) => s.batch)
|
|
const pageviews = (): Array<Record<string, unknown>> =>
|
|
events().filter((e) => e.type === 'pageview')
|
|
|
|
beforeEach(() => {
|
|
;(
|
|
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
|
|
).IS_REACT_ACT_ENVIRONMENT = true
|
|
installStorage()
|
|
vi.stubGlobal('navigator', {})
|
|
setTelemetry(undefined)
|
|
resetClients() // each `it` is its own page load
|
|
sent = []
|
|
vi.stubGlobal('fetch', (url: string, init?: { body?: string }) => {
|
|
const body = JSON.parse(init?.body ?? '{"batch":[]}') as {
|
|
batch: Array<Record<string, unknown>>
|
|
}
|
|
sent.push({ url, batch: body.batch })
|
|
return Promise.resolve({ ok: true } as Response)
|
|
})
|
|
container = document.createElement('div')
|
|
document.body.appendChild(container)
|
|
root = createRoot(container)
|
|
})
|
|
|
|
afterEach(() => {
|
|
act(() => root.unmount())
|
|
container.remove()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
/** An app's own @hanzo/event client. `batchSize: 1` makes it flush on every
|
|
* event, so the wire is readable synchronously instead of on its 5s timer. */
|
|
const appAnalytics = (product: string) =>
|
|
createAnalytics({ product, batchSize: 1, enabled: true })
|
|
|
|
/** Drain the ambient client (app clients above flush themselves). */
|
|
function flushAll(): void {
|
|
act(() => getTelemetry().flush())
|
|
}
|
|
|
|
describe('owner="gui" — what GuiProvider mounts', () => {
|
|
it('collects for a BARE gui app, with no app-side wiring at all', () => {
|
|
act(() => {
|
|
root.render(
|
|
<TelemetryProvider owner="gui" product="desktop" replay={false}>
|
|
<p>bare</p>
|
|
</TelemetryProvider>
|
|
)
|
|
})
|
|
flushAll()
|
|
|
|
expect(container.textContent).toBe('bare')
|
|
expect(pageviews()).toHaveLength(1)
|
|
expect(sent[0]!.url).toBe('https://api.hanzo.ai/v1/event')
|
|
expect(pageviews()[0]!.product).toBe('desktop')
|
|
})
|
|
|
|
it('yields to an app provider BELOW it — one stream, not two', () => {
|
|
act(() => {
|
|
root.render(
|
|
<TelemetryProvider owner="gui" product="gui-should-not-emit" replay={false}>
|
|
<TelemetryProvider product="app" replay={false}>
|
|
<p>app owns it</p>
|
|
</TelemetryProvider>
|
|
</TelemetryProvider>
|
|
)
|
|
})
|
|
flushAll()
|
|
|
|
expect(pageviews()).toHaveLength(1)
|
|
expect(pageviews()[0]!.product).toBe('app')
|
|
expect(events().some((e) => e.product === 'gui-should-not-emit')).toBe(false)
|
|
})
|
|
|
|
it('yields to an app provider ABOVE it — one stream, not two', () => {
|
|
act(() => {
|
|
root.render(
|
|
<AnalyticsProvider client={appAnalytics('store')}>
|
|
<TelemetryProvider owner="gui" product="gui-should-not-emit" replay={false}>
|
|
<p>store owns it</p>
|
|
</TelemetryProvider>
|
|
</AnalyticsProvider>
|
|
)
|
|
})
|
|
flushAll()
|
|
|
|
expect(pageviews()).toHaveLength(1)
|
|
expect(pageviews()[0]!.product).toBe('store')
|
|
expect(events().some((e) => e.product === 'gui-should-not-emit')).toBe(false)
|
|
})
|
|
|
|
// The ONE shape gui cannot detect, pinned so it can never surprise anyone: a
|
|
// raw <AnalyticsProvider> BELOW gui. Context does not look down, and unlike
|
|
// <TelemetryProvider> a raw one claims nothing, so gui has no way to know it
|
|
// is there. hanzo.ai, chat and hanzo.industries ship exactly this, which is
|
|
// why each carries an explicit `telemetry={false}` on its GuiProvider until it
|
|
// drops its own wiring — the opt-out tested below.
|
|
it('cannot see a raw <AnalyticsProvider> BELOW it — hence the opt-out', () => {
|
|
act(() => {
|
|
root.render(
|
|
<TelemetryProvider owner="gui" product="gui" replay={false}>
|
|
<AnalyticsProvider client={appAnalytics('site')}>
|
|
<p>site</p>
|
|
</AnalyticsProvider>
|
|
</TelemetryProvider>
|
|
)
|
|
})
|
|
flushAll()
|
|
expect(
|
|
pageviews()
|
|
.map((e) => e.product)
|
|
.sort()
|
|
).toEqual(['gui', 'site'])
|
|
})
|
|
|
|
it('emits nothing when the app opts out — what telemetry={false} compiles to', () => {
|
|
act(() => {
|
|
root.render(
|
|
<AnalyticsProvider client={appAnalytics('site')}>
|
|
<p>site only</p>
|
|
</AnalyticsProvider>
|
|
)
|
|
})
|
|
flushAll()
|
|
expect(pageviews().map((e) => e.product)).toEqual(['site'])
|
|
})
|
|
|
|
it('nested GuiProviders are still ONE stream', () => {
|
|
act(() => {
|
|
root.render(
|
|
<TelemetryProvider owner="gui" product="desktop" replay={false}>
|
|
<TelemetryProvider owner="gui" product="desktop" replay={false}>
|
|
<p>nested</p>
|
|
</TelemetryProvider>
|
|
</TelemetryProvider>
|
|
)
|
|
})
|
|
flushAll()
|
|
|
|
expect(pageviews()).toHaveLength(1)
|
|
})
|
|
|
|
// A React remount is not a new page. The registry that makes "one stream per
|
|
// page" true is page state, so a provider that discards it on unmount hands
|
|
// the next mount a client that has never seen this page — and it counts the
|
|
// view a second time. Nothing about the wire says the two came from different
|
|
// clients, so the page is simply over-counted, quietly and forever.
|
|
it('a remount does not restart the page stream', () => {
|
|
const Page = (): React.ReactNode => (
|
|
<TelemetryProvider product="app" replay={false}>
|
|
<p>x</p>
|
|
</TelemetryProvider>
|
|
)
|
|
|
|
act(() => root.render(<Page />))
|
|
flushAll()
|
|
act(() => root.render(<p>between</p>))
|
|
act(() => root.render(<Page />))
|
|
flushAll()
|
|
|
|
expect(pageviews()).toHaveLength(1)
|
|
})
|
|
|
|
it('hands the stream back when the app provider unmounts', () => {
|
|
const Tree = ({ withApp }: { withApp: boolean }): React.ReactNode => (
|
|
<TelemetryProvider owner="gui" product="gui" replay={false}>
|
|
{withApp ? (
|
|
<TelemetryProvider product="app" replay={false}>
|
|
<p>x</p>
|
|
</TelemetryProvider>
|
|
) : (
|
|
<p>x</p>
|
|
)}
|
|
</TelemetryProvider>
|
|
)
|
|
|
|
act(() => root.render(<Tree withApp />))
|
|
flushAll()
|
|
expect(pageviews().map((e) => e.product)).toEqual(['app'])
|
|
|
|
act(() => root.render(<Tree withApp={false} />))
|
|
flushAll()
|
|
// gui resumes ownership rather than leaving the app dark.
|
|
expect(pageviews().map((e) => e.product)).toEqual(['app', 'gui'])
|
|
})
|
|
|
|
it('still honors Do-Not-Track', () => {
|
|
vi.stubGlobal('navigator', { globalPrivacyControl: true })
|
|
act(() => {
|
|
root.render(
|
|
<TelemetryProvider owner="gui" replay={false}>
|
|
<p>hi</p>
|
|
</TelemetryProvider>
|
|
)
|
|
})
|
|
flushAll()
|
|
expect(sent).toHaveLength(0)
|
|
})
|
|
})
|