Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
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.
2026-08-01 15:44:40 -07:00

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)
})
})
})