mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
169 lines
7.8 KiB
TypeScript
169 lines
7.8 KiB
TypeScript
import { InsightType } from '~/types'
|
|
|
|
import { InsightPage } from '../../page-models/insightPage'
|
|
import { createEvent, daysAgo } from '../../utils/event-data'
|
|
import { PlaywrightWorkspaceSetupResult, expect, test } from '../../utils/workspace-test-base'
|
|
|
|
const EVENT_NAME = 'retention_test_event'
|
|
const user = (n: number): string => `retention-user-${n}`
|
|
|
|
const events = [
|
|
...createEvent({ event: EVENT_NAME, user, timestamp: daysAgo(6) }).repeat(10),
|
|
...createEvent({ event: EVENT_NAME, user, timestamp: daysAgo(5) }).repeat(8),
|
|
...createEvent({ event: EVENT_NAME, user, timestamp: daysAgo(4) }).repeat(6),
|
|
...createEvent({ event: EVENT_NAME, user, timestamp: daysAgo(3) }).repeat(4),
|
|
...createEvent({ event: EVENT_NAME, user, timestamp: daysAgo(2) }).repeat(3),
|
|
...createEvent({ event: EVENT_NAME, user, timestamp: daysAgo(1) }).repeat(2),
|
|
...createEvent({ event: EVENT_NAME, user, timestamp: daysAgo(0) }).repeat(1),
|
|
]
|
|
|
|
const isWeekHeader = (h: string): boolean => /week/i.test(h)
|
|
const isDayHeader = (h: string): boolean => /Days?\s+1/.test(h)
|
|
const isRangeHeader = (h: string): boolean => /\d+-\d+/.test(h)
|
|
const isTooltipLike = (text: string): boolean => /Day|Cohort/.test(text)
|
|
|
|
test.describe('Retention', () => {
|
|
let workspace: PlaywrightWorkspaceSetupResult | null = null
|
|
|
|
test.beforeAll(async ({ playwrightSetup }) => {
|
|
workspace = await playwrightSetup.createWorkspace({
|
|
use_current_time: true,
|
|
skip_onboarding: true,
|
|
no_demo_data: true,
|
|
events,
|
|
})
|
|
})
|
|
|
|
test.beforeEach(async ({ page, playwrightSetup }) => {
|
|
await playwrightSetup.login(page, workspace!)
|
|
})
|
|
|
|
test('Retention calculations, period, breakdown, and chart', async ({ page }) => {
|
|
const insight = new InsightPage(page)
|
|
|
|
await test.step('create a retention insight with our event', async () => {
|
|
await insight.goToNewInsight(InsightType.RETENTION)
|
|
await insight.retention.waitForChart()
|
|
await insight.retention.selectTargetEvent(EVENT_NAME)
|
|
await insight.retention.selectReturningEvent(EVENT_NAME)
|
|
})
|
|
|
|
await test.step('verify cohort sizes and retention curve', async () => {
|
|
const sizes = await insight.retention.getCohortSizes()
|
|
expect(sizes[0]).toBe(0)
|
|
expect(sizes[1]).toBe(10)
|
|
|
|
const percentages = await insight.retention.getCellPercentages(1)
|
|
expect(percentages).toEqual(['100.0%', '80.0%', '60.0%', '40.0%', '30.0%', '20.0%', '10.0%'])
|
|
|
|
const emptyPercentages = await insight.retention.getCellPercentages(0)
|
|
expect(emptyPercentages.every((p) => p === '0.0%')).toBe(true)
|
|
})
|
|
|
|
await test.step('change period from Day to Week', async () => {
|
|
await insight.retention.selectPeriod('weeks')
|
|
// The period change refetches the query; poll until the weekly headers render
|
|
// instead of reading the (possibly still daily/loading) table immediately.
|
|
await expect(async () => {
|
|
const headerTexts = await insight.retention.getColumnHeaderTexts()
|
|
expect(headerTexts.filter(isWeekHeader).length).toBe(8)
|
|
}).toPass({ timeout: 15000 })
|
|
})
|
|
|
|
await test.step('toggle to cumulative retention', async () => {
|
|
await insight.retention.toggleCumulative()
|
|
})
|
|
|
|
await test.step('add breakdown by Browser and verify section header', async () => {
|
|
await insight.retention.addBreakdown('Browser')
|
|
const sectionCount = await insight.retention.sectionHeaders.count()
|
|
expect(sectionCount).toBe(1)
|
|
})
|
|
|
|
await test.step('verify line chart renders', async () => {
|
|
await expect(insight.retention.chart).toBeVisible()
|
|
await expect(insight.retention.chart.locator('canvas[role="img"]')).toBeVisible()
|
|
})
|
|
})
|
|
|
|
test('Hover tooltips and persons modal', async ({ page }) => {
|
|
const insight = new InsightPage(page)
|
|
|
|
await test.step('create a retention insight with our event', async () => {
|
|
await insight.goToNewInsight(InsightType.RETENTION)
|
|
await insight.retention.waitForChart()
|
|
await insight.retention.selectTargetEvent(EVENT_NAME)
|
|
await insight.retention.selectReturningEvent(EVENT_NAME)
|
|
await expect(insight.retention.chart.locator('canvas[role="img"]')).toBeVisible()
|
|
})
|
|
|
|
await test.step('hover over chart points and verify tooltips', async () => {
|
|
for (const xFraction of [0.15, 0.5]) {
|
|
await insight.retention.hoverChartAt(xFraction, 0.5)
|
|
await expect(insight.retention.tooltip).toBeVisible()
|
|
const text = (await insight.retention.tooltip.textContent()) ?? ''
|
|
expect(isTooltipLike(text)).toBe(true)
|
|
expect(text).not.toContain('NaN')
|
|
expect(text).not.toContain('undefined')
|
|
}
|
|
})
|
|
|
|
await test.step('click cohort row and verify persons modal', async () => {
|
|
// Dismiss any lingering chart tooltip from the previous step so it
|
|
// doesn't overlay and block the cohort row click.
|
|
await insight.retention.hoverAway()
|
|
await expect(insight.retention.tooltip).not.toBeVisible({ timeout: 5000 })
|
|
|
|
await insight.retention.clickCohortRow(1)
|
|
const personLinks = insight.retention.personsModal.locator('[data-attr="retention-person-link"]')
|
|
await expect(personLinks).toHaveCount(10, { timeout: 30000 })
|
|
await expect(personLinks.first()).toBeVisible()
|
|
})
|
|
|
|
await test.step('close modal and verify table is intact', async () => {
|
|
await insight.retention.closePersonsModal()
|
|
await expect(insight.retention.table).toBeVisible()
|
|
expect(await insight.retention.tableRows.count()).toBe(10)
|
|
})
|
|
})
|
|
|
|
test('Custom retention brackets', async ({ page }) => {
|
|
const insight = new InsightPage(page)
|
|
|
|
await test.step('create a retention insight with our event', async () => {
|
|
await insight.goToNewInsight(InsightType.RETENTION)
|
|
await insight.retention.waitForChart()
|
|
await insight.retention.selectTargetEvent(EVENT_NAME)
|
|
await insight.retention.selectReturningEvent(EVENT_NAME)
|
|
})
|
|
|
|
await test.step('enable custom brackets [1, 3, 7, 14]', async () => {
|
|
await insight.retention.enableCustomBrackets()
|
|
await insight.retention.setCustomBracket(0, 1)
|
|
await insight.retention.setCustomBracket(1, 3)
|
|
await insight.retention.setCustomBracket(2, 7)
|
|
await insight.retention.addCustomBracket()
|
|
await insight.retention.setCustomBracket(3, 14)
|
|
})
|
|
|
|
await test.step('verify custom range headers appear', async () => {
|
|
// Wait for the final API response after all brackets are set (debounce settles)
|
|
// by waiting until we see 3 range headers (one per bracket boundary: 1-3, 3-7, 7-14)
|
|
await expect(async () => {
|
|
const headers = await insight.retention.getColumnHeaderTexts()
|
|
expect(headers.filter(isRangeHeader).length).toBe(3)
|
|
}).toPass({ timeout: 15000 })
|
|
|
|
const headerTexts = await insight.retention.getColumnHeaderTexts()
|
|
expect(headerTexts).toContain('Day 0')
|
|
expect(headerTexts.some(isDayHeader)).toBe(true)
|
|
expect(await insight.retention.tableRows.count()).toBe(10)
|
|
})
|
|
|
|
await test.step('disable custom brackets and verify default columns return', async () => {
|
|
await insight.retention.disableCustomBrackets()
|
|
expect(await insight.retention.tableHeaders.count()).toBe(10)
|
|
})
|
|
})
|
|
})
|