From ef0baa6a3724dd111ca6f5e9afb936b8d670f036 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:26:12 +0000 Subject: [PATCH 1/7] feat(analytics): ask about the email provider after install An interactive install run whose scan found an email provider now asks one question after the outro: try sending email with PostHog Workflows? The answer goes out as `wizard: email provider offer answered` and is set on the person. Yes shows a link to Workflows in the project. Co-Authored-By: Claude Opus 5.5 --- e2e-harness/action-registry.ts | 8 + .../__tests__/email-provider-offer.test.ts | 99 ++++++++++ .../email-provider-offer.ts | 97 ++++++++++ src/programs/posthog-integration/index.ts | 9 + src/services/codebase-scan/email-providers.ts | 25 ++- src/services/codebase-scan/index.ts | 6 +- src/services/codebase-scan/scan.ts | 2 +- .../frames/email-provider-offer-120x40.txt | 39 ++++ .../frames/email-provider-offer-80x28.txt | 27 +++ src/tui/__tests__/frames.test.tsx | 26 +++ src/tui/__tests__/store-invariants.test.ts | 5 + src/tui/programs/__tests__/flows.test.ts | 1 + src/tui/programs/posthog-integration/flow.ts | 9 + .../programs/posthog-integration/index.tsx | 4 + .../posthog-integration/screen-ids.ts | 1 + .../screens/EmailProviderOfferScreen.tsx | 141 ++++++++++++++ .../EmailProviderOfferScreen.test.tsx | 181 ++++++++++++++++++ src/tui/store.ts | 8 + src/tui/tui-state.ts | 2 + 19 files changed, 683 insertions(+), 7 deletions(-) create mode 100644 src/programs/posthog-integration/__tests__/email-provider-offer.test.ts create mode 100644 src/programs/posthog-integration/email-provider-offer.ts create mode 100644 src/tui/__tests__/__snapshots__/frames/email-provider-offer-120x40.txt create mode 100644 src/tui/__tests__/__snapshots__/frames/email-provider-offer-80x28.txt create mode 100644 src/tui/programs/posthog-integration/screens/EmailProviderOfferScreen.tsx create mode 100644 src/tui/programs/posthog-integration/screens/__tests__/EmailProviderOfferScreen.test.tsx diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index ac990a215..09d1f4a5b 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -304,6 +304,14 @@ export const ACTION_REGISTRY: Partial> = { }, ], + [PostHogIntegrationScreenId.EmailProviderOffer]: [ + { + id: 'dismiss_email_provider_offer', + description: 'Leave the email provider question.', + apply: (store) => store.setEmailProviderOfferDone(), + }, + ], + // ── Slack ───────────────────────────────────────────────────────────── [SelfDrivingScreenId.Github]: [ { diff --git a/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts b/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts new file mode 100644 index 000000000..040ca943a --- /dev/null +++ b/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; +import { buildSession } from '@services/session'; +import { CODEBASE_SCAN_KEY, type CodebaseScan } from '@services/codebase-scan'; +import { HostResolution } from '@shared/host-resolution'; +import { OutroKind } from '@shared/outro'; +import { ScanConsent } from '@shared/run-state'; +import type { ProgramSession } from '@programs/program-contract'; +import { emailProviderOffer } from '@programs/posthog-integration'; + +function detected(kind: string, label: string) { + return { + kind, + label, + warehouseSourceType: null, + mode: null, + matchedSignal: `package ${kind.toLowerCase()}`, + signals: [], + }; +} + +function scanFinding(...tools: ReturnType[]): CodebaseScan { + return { + program: 'posthog-integration', + scannedAt: '2026-10-26T09:30:00.000Z', + scannedDir: '/tmp/app', + tools, + complete: true, + }; +} + +function finishedRun( + scan: CodebaseScan, + overrides: Partial = {}, +): ProgramSession { + const session = buildSession({ installDir: '/tmp/app' }); + session.scanConsent = ScanConsent.Granted; + session.frameworkContext[CODEBASE_SCAN_KEY] = scan; + session.outroData = { kind: OutroKind.Success, message: 'Done' }; + session.credentials = { + accessToken: 'token', + projectApiKey: 'phc_example', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 42, + }; + return Object.assign(session, overrides); +} + +const RESEND = detected('Resend', 'Resend'); + +describe('emailProviderOffer', () => { + it('offers Workflows in the project to a run that found a provider', () => { + const offer = emailProviderOffer( + finishedRun( + scanFinding( + detected('Stripe', 'Stripe'), + RESEND, + detected('AwsSes', 'Amazon SES'), + ), + ), + ); + + expect(offer).toEqual({ + providers: ['Resend', 'AwsSes'], + providerName: 'Resend', + workflowsUrl: + 'https://us.posthog.com/project/42/workflows?source=wizard_cli', + }); + }); + + it.each<[string, (scan: CodebaseScan) => ProgramSession]>([ + ['a CI run', (scan) => finishedRun(scan, { ci: true })], + ['telemetry is off', (scan) => finishedRun(scan, { noTelemetry: true })], + [ + 'sharing was declined', + (scan) => finishedRun(scan, { scanConsent: ScanConsent.Declined }), + ], + [ + 'the install failed', + (scan) => + finishedRun(scan, { + outroData: { kind: OutroKind.Error, message: 'failed' }, + }), + ], + [ + 'the user is not logged in', + (scan) => finishedRun(scan, { credentials: null }), + ], + ])('stays silent when %s', (_case, sessionFor) => { + expect(emailProviderOffer(sessionFor(scanFinding(RESEND)))).toBeUndefined(); + }); + + it('stays silent when the scan found no email provider', () => { + expect( + emailProviderOffer( + finishedRun(scanFinding(detected('Stripe', 'Stripe'))), + ), + ).toBeUndefined(); + }); +}); diff --git a/src/programs/posthog-integration/email-provider-offer.ts b/src/programs/posthog-integration/email-provider-offer.ts new file mode 100644 index 000000000..7b8ee27b3 --- /dev/null +++ b/src/programs/posthog-integration/email-provider-offer.ts @@ -0,0 +1,97 @@ +import { + detectedEmailProviders, + getCodebaseScan, + maySendScanResults, + type EmailProviderKind, +} from '@services/codebase-scan'; +import { OutroKind } from '@shared/outro'; +import { analytics } from '@utils/analytics'; +import type { ProgramSession } from '@programs/program-contract'; + +export enum EmailProviderOfferAnswer { + Yes = 'yes', + NotNow = 'not_now', + No = 'no', + Skipped = 'skipped', +} + +export type EmailProviderOffer = { + providers: EmailProviderKind[]; + providerName: string; + workflowsUrl: string; + migrateCommand?: string; +}; + +type OfferSession = Pick< + ProgramSession, + | 'ci' + | 'noTelemetry' + | 'scanConsent' + | 'outroData' + | 'credentials' + | 'frameworkContext' +>; + +const MIGRATE_COMMANDS: Readonly>> = + {}; + +function mayAsk(session: OfferSession): boolean { + return ( + !session.ci && + maySendScanResults(session) && + session.outroData?.kind === OutroKind.Success + ); +} + +export function emailProviderOffer( + session: OfferSession, +): EmailProviderOffer | undefined { + const scan = getCodebaseScan(session); + const credentials = session.credentials; + if (!scan || !credentials || !mayAsk(session)) return undefined; + const [first, ...others] = detectedEmailProviders(scan); + if (!first) return undefined; + return { + providers: [first, ...others].map((tool) => tool.kind), + providerName: first.label, + workflowsUrl: `${credentials.host.appHost}/project/${credentials.projectId}/workflows?source=wizard_cli`, + migrateCommand: MIGRATE_COMMANDS[first.kind], + }; +} + +export function emailProviderOfferQuestion(offer: EmailProviderOffer): string { + return `Your app sends email with ${offer.providerName}. PostHog can send it too, triggered by the events you already track. Want to try it?`; +} + +export function emailProviderOfferChoices( + offer: EmailProviderOffer, +): { label: string; value: EmailProviderOfferAnswer }[] { + return [ + { label: 'Yes, show me how', value: EmailProviderOfferAnswer.Yes }, + { label: 'Not now', value: EmailProviderOfferAnswer.NotNow }, + { + label: `No, I'm happy with ${offer.providerName}`, + value: EmailProviderOfferAnswer.No, + }, + ]; +} + +export function reportEmailProviderOfferShown(offer: EmailProviderOffer): void { + analytics.wizardCapture('email provider offer shown', { + providers: offer.providers, + }); +} + +export function reportEmailProviderOfferAnswered( + offer: EmailProviderOffer, + answer: EmailProviderOfferAnswer, +): void { + analytics.wizardCapture('email provider offer answered', { + providers: offer.providers, + answer, + $set: { + wizard_email_offer_answer: answer, + wizard_email_offer_answered_at: new Date().toISOString(), + }, + }); +} diff --git a/src/programs/posthog-integration/index.ts b/src/programs/posthog-integration/index.ts index 87f5dad88..4ec16295d 100644 --- a/src/programs/posthog-integration/index.ts +++ b/src/programs/posthog-integration/index.ts @@ -239,6 +239,15 @@ const warehouseSeedTasks: NonNullable = (sess) => { }; export { EVENT_PLAN_FILE } from './constants.js'; +export { + EmailProviderOfferAnswer, + emailProviderOffer, + emailProviderOfferChoices, + emailProviderOfferQuestion, + reportEmailProviderOfferAnswered, + reportEmailProviderOfferShown, + type EmailProviderOffer, +} from './email-provider-offer.js'; export const config: ProgramConfig = { description: 'Set up PostHog SDK integration', diff --git a/src/services/codebase-scan/email-providers.ts b/src/services/codebase-scan/email-providers.ts index 67a5efa39..adb910241 100644 --- a/src/services/codebase-scan/email-providers.ts +++ b/src/services/codebase-scan/email-providers.ts @@ -1,6 +1,7 @@ +import type { DetectedTool } from '../warehouse-sources/types'; import type { CodebaseScan } from './scan'; -const EMAIL_PROVIDER_KINDS: ReadonlySet = new Set([ +const EMAIL_PROVIDER_KINDS = [ 'AwsSes', 'Beehiiv', 'BigMailer', @@ -25,19 +26,33 @@ const EMAIL_PROVIDER_KINDS: ReadonlySet = new Set([ 'Smaily', 'SparkPost', 'Tinyemail', -]); +] as const; + +export type EmailProviderKind = (typeof EMAIL_PROVIDER_KINDS)[number]; + +export type EmailProvider = DetectedTool & { kind: EmailProviderKind }; + +const EMAIL_PROVIDER_KIND_SET: ReadonlySet = new Set( + EMAIL_PROVIDER_KINDS, +); + +function isEmailProvider(tool: DetectedTool): tool is EmailProvider { + return EMAIL_PROVIDER_KIND_SET.has(tool.kind); +} export type EmailProviderPersonProperties = { wizard_detected_email_providers: string[]; wizard_detected_email_provider_at: string; }; +export function detectedEmailProviders(scan: CodebaseScan): EmailProvider[] { + return scan.tools.filter(isEmailProvider); +} + export function emailProviderPersonProperties( scan: CodebaseScan, ): EmailProviderPersonProperties | undefined { - const providers = scan.tools - .map((tool) => tool.kind) - .filter((kind) => EMAIL_PROVIDER_KINDS.has(kind)); + const providers = detectedEmailProviders(scan).map((tool) => tool.kind); if (providers.length === 0) return undefined; return { wizard_detected_email_providers: providers, diff --git a/src/services/codebase-scan/index.ts b/src/services/codebase-scan/index.ts index 8b79cdd3c..f329966b0 100644 --- a/src/services/codebase-scan/index.ts +++ b/src/services/codebase-scan/index.ts @@ -6,4 +6,8 @@ export { type CodebaseScan, } from './scan'; export { reportCodebaseScan, type ScanReportStore } from './report'; -export { tagScanResults } from './tags'; +export { maySendScanResults, tagScanResults } from './tags'; +export { + detectedEmailProviders, + type EmailProviderKind, +} from './email-providers'; diff --git a/src/services/codebase-scan/scan.ts b/src/services/codebase-scan/scan.ts index a5977a6c4..35043140b 100644 --- a/src/services/codebase-scan/scan.ts +++ b/src/services/codebase-scan/scan.ts @@ -95,7 +95,7 @@ export async function scannedWarehouseSources( } export function getCodebaseScan( - session: ProgramSession, + session: Pick, ): CodebaseScan | undefined { return session.frameworkContext[CODEBASE_SCAN_KEY] as | CodebaseScan diff --git a/src/tui/__tests__/__snapshots__/frames/email-provider-offer-120x40.txt b/src/tui/__tests__/__snapshots__/frames/email-provider-offer-120x40.txt new file mode 100644 index 000000000..da5de9c82 --- /dev/null +++ b/src/tui/__tests__/__snapshots__/frames/email-provider-offer-120x40.txt @@ -0,0 +1,39 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + Your app sends email with Resend. PostHog can send it too, triggered by the events you already track. Want to try it? + + ▸ Yes, show me how + Not now + No, I'm happy with Resend + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ↑↓ navigate esc skip enter select diff --git a/src/tui/__tests__/__snapshots__/frames/email-provider-offer-80x28.txt b/src/tui/__tests__/__snapshots__/frames/email-provider-offer-80x28.txt new file mode 100644 index 000000000..7df686adc --- /dev/null +++ b/src/tui/__tests__/__snapshots__/frames/email-provider-offer-80x28.txt @@ -0,0 +1,27 @@ + PostHog Wizard v0.0.0-test Feedback: wizard@posthog.com + + Cost (running): $0.00 · no agent turns yet Ctrl+T to hide + + + Your app sends email with Resend. PostHog can send it too, triggered by the + events you already track. Want to try it? + + ▸ Yes, show me how + Not now + No, I'm happy with Resend + + + + + + + + + + + + + + + + ↑↓ navigate esc skip enter select diff --git a/src/tui/__tests__/frames.test.tsx b/src/tui/__tests__/frames.test.tsx index 753ef9469..9dcedce8d 100644 --- a/src/tui/__tests__/frames.test.tsx +++ b/src/tui/__tests__/frames.test.tsx @@ -155,6 +155,7 @@ import { SelfDrivingScreenId } from '@tui/programs/self-driving'; import { SourceMapsScreenId } from '@tui/programs/error-tracking-upload-source-maps'; import { WarehouseSourceScreenId } from '@tui/programs/warehouse-source'; import { Tool } from '@tools'; +import { CODEBASE_SCAN_KEY } from '@services/codebase-scan'; // 80x28 is the ScreenContainer minimum. Anything smaller renders the // viewport guard instead of the screen, which the last describe pins once. @@ -548,6 +549,31 @@ const FIXTURES: Record = { s.setDashboardUrl('https://us.posthog.com/project/42/dashboard/7'); }, }, + [PostHogIntegrationScreenId.EmailProviderOffer]: { + program: Program.PostHogIntegration, + arrange: (s) => { + s.grantSharing(); + authed(s); + s.setFrameworkContext(CODEBASE_SCAN_KEY, { + program: Program.PostHogIntegration, + scannedAt: '2026-10-26T09:30:00.000Z', + scannedDir: '/app', + complete: true, + tools: [ + { + kind: 'Resend', + label: 'Resend', + warehouseSourceType: null, + mode: null, + matchedSignal: 'package resend', + signals: [], + }, + ], + }); + ranSuccessfully(s); + s.setOutroDismissed(); + }, + }, [ScreenId.MintFailure]: { program: Program.PostHogIntegration, arrange: (s) => { diff --git a/src/tui/__tests__/store-invariants.test.ts b/src/tui/__tests__/store-invariants.test.ts index 25cdc1bb7..e1481eda6 100644 --- a/src/tui/__tests__/store-invariants.test.ts +++ b/src/tui/__tests__/store-invariants.test.ts @@ -404,6 +404,11 @@ const MUTATIONS: MutationCase[] = [ emits: 1, }, { name: 'setOutroDismissed', invoke: (s) => s.setOutroDismissed(), emits: 1 }, + { + name: 'setEmailProviderOfferDone', + invoke: (s) => s.setEmailProviderOfferDone(), + emits: 1, + }, { name: 'setOutroData', invoke: (s) => s.setOutroData({ kind: OutroKind.Success, message: 'done' }), diff --git a/src/tui/programs/__tests__/flows.test.ts b/src/tui/programs/__tests__/flows.test.ts index db2d5b48b..38a9eeaa9 100644 --- a/src/tui/programs/__tests__/flows.test.ts +++ b/src/tui/programs/__tests__/flows.test.ts @@ -39,6 +39,7 @@ describe('program flows', () => { 'auth', 'run', 'outro', + 'email-provider-offer', 'mcp', ]); }); diff --git a/src/tui/programs/posthog-integration/flow.ts b/src/tui/programs/posthog-integration/flow.ts index e7d265ea4..1d397413d 100644 --- a/src/tui/programs/posthog-integration/flow.ts +++ b/src/tui/programs/posthog-integration/flow.ts @@ -10,6 +10,8 @@ import type { FlowStep } from '@tui/flow'; import { RunPhase } from '@shared/run-state'; import { HEALTH_CHECK_STEP } from '@tui/programs/shared/health-check-step'; import type { WizardSession } from '@programs/types'; +import { emailProviderOffer } from '@programs/posthog-integration'; +import { PostHogIntegrationScreenId } from './screen-ids.js'; function needsSetup(session: WizardSession): boolean { const config = session.frameworkConfig; @@ -55,6 +57,13 @@ export const POSTHOG_INTEGRATION_FLOW: FlowStep[] = [ screenId: 'outro', isComplete: (tui) => tui.outroDismissed, }, + { + id: 'email-provider-offer', + label: 'Email', + screenId: PostHogIntegrationScreenId.EmailProviderOffer, + show: ({ session }) => emailProviderOffer(session) !== undefined, + isComplete: (tui) => tui.emailProviderOfferDone, + }, { id: 'mcp', label: 'MCP servers', diff --git a/src/tui/programs/posthog-integration/index.tsx b/src/tui/programs/posthog-integration/index.tsx index fad0e3588..bc4b78daa 100644 --- a/src/tui/programs/posthog-integration/index.tsx +++ b/src/tui/programs/posthog-integration/index.tsx @@ -4,6 +4,7 @@ import { POSTHOG_INTEGRATION_FLOW } from './flow.js'; import { getContentBlocks } from './deck/index.js'; import { PostHogIntegrationScreenId } from './screen-ids.js'; import { PostHogIntegrationIntroScreen } from './screens/PostHogIntegrationIntroScreen.js'; +import { EmailProviderOfferScreen } from './screens/EmailProviderOfferScreen.js'; export { PostHogIntegrationScreenId } from './screen-ids.js'; @@ -15,6 +16,9 @@ export const TUI_PROGRAMS: TuiPrograms = { [PostHogIntegrationScreenId.Intro]: (store) => ( ), + [PostHogIntegrationScreenId.EmailProviderOffer]: (store) => ( + + ), }, }, }; diff --git a/src/tui/programs/posthog-integration/screen-ids.ts b/src/tui/programs/posthog-integration/screen-ids.ts index c26e47f84..3a0395182 100644 --- a/src/tui/programs/posthog-integration/screen-ids.ts +++ b/src/tui/programs/posthog-integration/screen-ids.ts @@ -1,4 +1,5 @@ /** The screens the integration program owns. */ export enum PostHogIntegrationScreenId { Intro = 'intro', + EmailProviderOffer = 'email-provider-offer', } diff --git a/src/tui/programs/posthog-integration/screens/EmailProviderOfferScreen.tsx b/src/tui/programs/posthog-integration/screens/EmailProviderOfferScreen.tsx new file mode 100644 index 000000000..0ab24e75f --- /dev/null +++ b/src/tui/programs/posthog-integration/screens/EmailProviderOfferScreen.tsx @@ -0,0 +1,141 @@ +import { Box, Text } from 'ink'; +import { useEffect, useRef, useState } from 'react'; +import type { WizardStore } from '@tui/store'; +import { Colors } from '@tui/styles'; +import { PickerMenu } from '@tui/primitives/index'; +import { useKeyBindings, KeyMatch } from '@tui/hooks/useKeyBindings'; +import { useDismissOnAnyKey } from '@tui/hooks/useDismissOnAnyKey'; +import { + EmailProviderOfferAnswer, + emailProviderOffer, + emailProviderOfferChoices, + emailProviderOfferQuestion, + reportEmailProviderOfferAnswered, + reportEmailProviderOfferShown, + type EmailProviderOffer, +} from '@programs/posthog-integration'; + +interface EmailProviderOfferScreenProps { + store: WizardStore; +} + +export const EmailProviderOfferScreen = ({ + store, +}: EmailProviderOfferScreenProps) => { + const [offer] = useState(() => emailProviderOffer(store.session)); + + useEffect(() => { + if (!offer) store.setEmailProviderOfferDone(); + }, [offer, store]); + + if (!offer) return null; + return ; +}; + +function useAnswerOnce(offer: EmailProviderOffer) { + const answered = useRef(false); + + useEffect(() => { + reportEmailProviderOfferShown(offer); + return () => { + if (answered.current) return; + reportEmailProviderOfferAnswered(offer, EmailProviderOfferAnswer.Skipped); + }; + }, [offer]); + + return (answer: EmailProviderOfferAnswer): boolean => { + if (answered.current) return false; + answered.current = true; + reportEmailProviderOfferAnswered(offer, answer); + return true; + }; +} + +const OfferFlow = ({ + offer, + store, +}: { + offer: EmailProviderOffer; + store: WizardStore; +}) => { + const [showingWorkflows, setShowingWorkflows] = useState(false); + const answerOnce = useAnswerOnce(offer); + + const answer = (choice: EmailProviderOfferAnswer): void => { + if (!answerOnce(choice)) return; + if (choice === EmailProviderOfferAnswer.Yes) setShowingWorkflows(true); + else store.setEmailProviderOfferDone(); + }; + + if (showingWorkflows) { + return ( + store.setEmailProviderOfferDone()} + /> + ); + } + + return ; +}; + +const QuestionPicker = ({ + offer, + onAnswer, +}: { + offer: EmailProviderOffer; + onAnswer: (choice: EmailProviderOfferAnswer) => void; +}) => { + useKeyBindings('email-provider-offer', [ + { + match: KeyMatch.Escape, + label: 'esc', + action: 'skip', + handler: () => onAnswer(EmailProviderOfferAnswer.Skipped), + }, + ]); + + return ( + + {emailProviderOfferQuestion(offer)} + + + onAnswer(Array.isArray(value) ? value[0] : value) + } + /> + + + ); +}; + +const WorkflowsLink = ({ + offer, + onContinue, +}: { + offer: EmailProviderOffer; + onContinue: () => void; +}) => { + useDismissOnAnyKey(onContinue); + + return ( + + + Set up email in Workflows:{' '} + {offer.workflowsUrl} + + {offer.migrateCommand && ( + + + Move your {offer.providerName} emails with{' '} + {offer.migrateCommand} + + + )} + + Press any key to continue + + + ); +}; diff --git a/src/tui/programs/posthog-integration/screens/__tests__/EmailProviderOfferScreen.test.tsx b/src/tui/programs/posthog-integration/screens/__tests__/EmailProviderOfferScreen.test.tsx new file mode 100644 index 000000000..da868932e --- /dev/null +++ b/src/tui/programs/posthog-integration/screens/__tests__/EmailProviderOfferScreen.test.tsx @@ -0,0 +1,181 @@ +import { vi, it, expect, afterEach, beforeEach, describe } from 'vitest'; +import { render, cleanup } from 'ink-testing-library'; +import { WizardStore } from '@tui/store'; +import { ScreenId } from '@tui/screen-ids'; +import { KeyboardHintsProvider } from '@tui/hooks/useKeyboardHints'; +import { OutroKind } from '@shared/outro'; +import { HostResolution } from '@shared/host-resolution'; +import { RunPhase } from '@shared/run-state'; +import { WizardReadiness } from '@shared/health-checks/readiness'; +import { CODEBASE_SCAN_KEY } from '@services/codebase-scan'; +import { PostHogIntegrationScreenId } from '@tui/programs/posthog-integration/screen-ids'; +import { EmailProviderOfferScreen } from '@tui/programs/posthog-integration/screens/EmailProviderOfferScreen'; + +vi.mock('ink', () => vi.importActual('ink-actual')); +const { wizardCapture } = vi.hoisted(() => ({ wizardCapture: vi.fn() })); + +vi.mock('@utils/analytics', () => ({ + analytics: { + wizardCapture, + capture: vi.fn(), + setTag: vi.fn(), + } as never, + sessionProperties: vi.fn(() => ({})), +})); + +const ANSWERED_AT = '2026-10-26T11:00:00.000Z'; +const WORKFLOWS_URL = + 'https://us.posthog.com/project/42/workflows?source=wizard_cli'; +const DOWN = '\u001B[B'; +const ENTER = '\r'; +const ESC = '\u001B'; + +const delay = () => new Promise((resolve) => setTimeout(resolve, 30)); + +function storeAfterInstallWithResend(): WizardStore { + const store = new WizardStore(); + store.grantSharing(); + store.completeSetup(); + store.setReadinessResult({ + decision: WizardReadiness.Yes, + health: {} as never, + reasons: [], + }); + store.setCredentials({ + accessToken: 'token', + projectApiKey: 'phc_example', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 42, + }); + store.setFrameworkContext(CODEBASE_SCAN_KEY, { + program: 'posthog-integration', + scannedAt: '2026-10-26T09:30:00.000Z', + scannedDir: '/tmp/app', + complete: true, + tools: [ + { + kind: 'Resend', + label: 'Resend', + warehouseSourceType: null, + mode: null, + matchedSignal: 'package resend', + signals: [], + }, + ], + }); + store.setRunPhase(RunPhase.Running); + store.setRunPhase(RunPhase.Completed); + store.setOutroData({ kind: OutroKind.Success, message: 'Done' }); + store.setOutroDismissed(); + return store; +} + +function setup() { + const store = storeAfterInstallWithResend(); + const app = render( + + + , + ); + const press = async (...keys: string[]) => { + await delay(); + for (const key of keys) { + app.stdin.write(key); + await delay(); + } + }; + return { store, app, press }; +} + +function answeredWith(answer: string) { + return [ + 'email provider offer answered', + { + providers: ['Resend'], + answer, + $set: { + wizard_email_offer_answer: answer, + wizard_email_offer_answered_at: ANSWERED_AT, + }, + }, + ]; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(ANSWERED_AT)); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe('the email provider offer after install', () => { + it('asks about the detected provider once the outro is dismissed', async () => { + const { store, app } = setup(); + await delay(); + + expect(store.router.resolve(store)).toBe( + PostHogIntegrationScreenId.EmailProviderOffer, + ); + expect(app.lastFrame()?.replace(/\s+/g, ' ')).toContain( + 'Your app sends email with Resend. PostHog can send it too, triggered by the events you already track. Want to try it?', + ); + expect(app.lastFrame()).toContain("No, I'm happy with Resend"); + expect(wizardCapture).toHaveBeenCalledWith('email provider offer shown', { + providers: ['Resend'], + }); + }); + + it.each([ + ['Not now', [DOWN, ENTER], 'not_now'], + ['No', [DOWN, DOWN, ENTER], 'no'], + ['Esc', [ESC], 'skipped'], + ])('records %s and moves on', async (_choice, keys, answer) => { + const { store, press } = setup(); + + await press(...keys); + + expect(wizardCapture).toHaveBeenCalledWith(...answeredWith(answer)); + expect(store.router.resolve(store)).toBe(ScreenId.Mcp); + }); + + it('shows the Workflows link on yes and moves on at the next key', async () => { + const { store, app, press } = setup(); + + await press(ENTER); + + expect(wizardCapture).toHaveBeenCalledWith(...answeredWith('yes')); + expect(app.lastFrame()).toContain(WORKFLOWS_URL); + expect(store.router.resolve(store)).toBe( + PostHogIntegrationScreenId.EmailProviderOffer, + ); + + await press(ENTER); + + expect(store.router.resolve(store)).toBe(ScreenId.Mcp); + }); + + it('records one answer when the screen closes after an answer', async () => { + const { app, press } = setup(); + + await press(DOWN, ENTER); + app.unmount(); + + const answers = wizardCapture.mock.calls.filter( + ([event]) => event === 'email provider offer answered', + ); + expect(answers).toEqual([answeredWith('not_now')]); + }); + + it('counts an unanswered Ctrl-C as skipped', async () => { + const { app } = setup(); + await delay(); + + app.unmount(); + + expect(wizardCapture).toHaveBeenCalledWith(...answeredWith('skipped')); + }); +}); diff --git a/src/tui/store.ts b/src/tui/store.ts index a6f240878..007621ac1 100644 --- a/src/tui/store.ts +++ b/src/tui/store.ts @@ -383,6 +383,10 @@ export class WizardStore implements TuiView { return this.$tui.get().outroDismissed; } + get emailProviderOfferDone(): TuiState['emailProviderOfferDone'] { + return this.$tui.get().emailProviderOfferDone; + } + get integrate(): TuiState['integrate'] { return this.$tui.get().integrate; } @@ -915,6 +919,10 @@ export class WizardStore implements TuiView { this._write({ outroDismissed: dismissed }); } + setEmailProviderOfferDone(): void { + this._write({ emailProviderOfferDone: true }); + } + setOutroData(data: OutroData): void { this.sessions.setOutroData(data); } diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 6c08abf01..1d8387f48 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -42,6 +42,7 @@ export type TuiState = { /** Whether the project already has a Slack integration; `null` until detected. */ slackConnected: boolean | null; outroDismissed: boolean; + emailProviderOfferDone: boolean; /** * Self-driving only: whether to integrate PostHog as part of this run. @@ -96,6 +97,7 @@ export function initialTuiState( slackStepDismissed: false, slackConnected: null, outroDismissed: false, + emailProviderOfferDone: false, // `--integrate` forces integration (skip the question); otherwise the // integration-check screen resolves it from null. integrate: choices.integrate === true ? true : null, From 4687eebab48e10d055bdb307cfc9192a4fbe28e4 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:08:44 +0000 Subject: [PATCH 2/7] fix(analytics): deliver events captured after the run report on ctrl-c Co-Authored-By: Claude Opus 5.5 --- src/tui/__tests__/run.test.ts | 8 +++++++- src/tui/run.ts | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/tui/__tests__/run.test.ts b/src/tui/__tests__/run.test.ts index be03fcbdd..a152e99bb 100644 --- a/src/tui/__tests__/run.test.ts +++ b/src/tui/__tests__/run.test.ts @@ -139,18 +139,24 @@ it.each(['continue', 'exit'] as const)( }, ); -it('routes Ink cancellation through one cancelled shutdown and preserves exit 130', async () => { +it('routes Ink cancellation through one cancelled shutdown, delivers queued events, and preserves exit 130', async () => { const { store, unmount } = mountedStore(); vi.mocked(runProgram).mockImplementation(() => { store.setRunPhase(RunPhase.Running); return new Promise(() => undefined); }); + let delivered = false; + vi.mocked(analytics.flush).mockImplementation(async () => { + await flush(); + delivered = true; + }); const exited = runTui(posthogIntegration, launch('/tmp/cancellation-test')); await vi.waitFor(() => expect(runProgram).toHaveBeenCalled()); const interrupt = vi.mocked(startTUI).mock.calls[0][2]; interrupt?.(); interrupt?.(); await expect(exited).resolves.toBe(130); + expect(delivered).toBe(true); expect(streamShutdown).toHaveBeenCalledExactlyOnceWith(2000, 'cancelled'); expect(analytics.shutdown).toHaveBeenCalledWith('cancelled'); expect(unmount).toHaveBeenCalledOnce(); diff --git a/src/tui/run.ts b/src/tui/run.ts index 37f30ec74..cd94f8a60 100644 --- a/src/tui/run.ts +++ b/src/tui/run.ts @@ -69,6 +69,11 @@ export async function runTui( // A tool the intro handed off to ends the run once its analytics flush. let handedOff = false; + const reportCancellation = async (): Promise => { + await analytics.shutdown('cancelled'); + await flushAnalytics(); + }; + // Flush a terminal-phase push so the web app sees the run ended in error // rather than hanging on the last "running" snapshot. Registered before the // stream exists: Ctrl-C on the intro must still restore the terminal and run @@ -85,7 +90,7 @@ export async function runTui( } void Promise.all([ stream?.shutdown(2000, 'cancelled'), - analytics.shutdown('cancelled'), + reportCancellation(), ]) .catch(() => logToFile('[run-wizard] cancellation shutdown failed')) .finally(() => { From 771faf650ec694a211022c64212d191593bc881e Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:21:31 +0000 Subject: [PATCH 3/7] feat(analytics): gate the email provider offer on a flag Co-Authored-By: Claude Opus 5.5 --- .../__tests__/email-provider-offer.test.ts | 17 ++++++++++++++++- .../posthog-integration/email-provider-offer.ts | 9 +++++++-- src/shared/constants.ts | 3 +++ src/shared/utils/__tests__/analytics.test.ts | 8 ++++++++ src/tui/__tests__/flow-traces.test.ts | 1 + src/tui/__tests__/frames.test.tsx | 3 +++ src/tui/__tests__/store-invariants.test.ts | 3 +++ .../__tests__/EmailProviderOfferScreen.test.tsx | 3 +++ 8 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts b/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts index 040ca943a..9a2ac817e 100644 --- a/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts +++ b/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts @@ -46,6 +46,7 @@ function finishedRun( } const RESEND = detected('Resend', 'Resend'); +const OFFER_ON = { 'wizard-email-provider-offer': 'true' }; describe('emailProviderOffer', () => { it('offers Workflows in the project to a run that found a provider', () => { @@ -57,6 +58,7 @@ describe('emailProviderOffer', () => { detected('AwsSes', 'Amazon SES'), ), ), + OFFER_ON, ); expect(offer).toEqual({ @@ -86,14 +88,27 @@ describe('emailProviderOffer', () => { (scan) => finishedRun(scan, { credentials: null }), ], ])('stays silent when %s', (_case, sessionFor) => { - expect(emailProviderOffer(sessionFor(scanFinding(RESEND)))).toBeUndefined(); + expect( + emailProviderOffer(sessionFor(scanFinding(RESEND)), OFFER_ON), + ).toBeUndefined(); }); it('stays silent when the scan found no email provider', () => { expect( emailProviderOffer( finishedRun(scanFinding(detected('Stripe', 'Stripe'))), + OFFER_ON, ), ).toBeUndefined(); }); + + it.each<[string, Record | null]>([ + ['the flag is off', { 'wizard-email-provider-offer': 'false' }], + ['the flag evaluation left the flag out', {}], + ['no flag evaluation finished', null], + ])('stays silent when %s', (_case, flags) => { + expect( + emailProviderOffer(finishedRun(scanFinding(RESEND)), flags), + ).toBeUndefined(); + }); }); diff --git a/src/programs/posthog-integration/email-provider-offer.ts b/src/programs/posthog-integration/email-provider-offer.ts index 7b8ee27b3..c9019c6db 100644 --- a/src/programs/posthog-integration/email-provider-offer.ts +++ b/src/programs/posthog-integration/email-provider-offer.ts @@ -4,6 +4,7 @@ import { maySendScanResults, type EmailProviderKind, } from '@services/codebase-scan'; +import { WIZARD_EMAIL_PROVIDER_OFFER_FLAG_KEY } from '@shared/constants'; import { OutroKind } from '@shared/outro'; import { analytics } from '@utils/analytics'; import type { ProgramSession } from '@programs/program-contract'; @@ -22,6 +23,8 @@ export type EmailProviderOffer = { migrateCommand?: string; }; +type WizardFlags = Readonly> | null; + type OfferSession = Pick< ProgramSession, | 'ci' @@ -35,8 +38,9 @@ type OfferSession = Pick< const MIGRATE_COMMANDS: Readonly>> = {}; -function mayAsk(session: OfferSession): boolean { +function mayAsk(session: OfferSession, flags: WizardFlags): boolean { return ( + flags?.[WIZARD_EMAIL_PROVIDER_OFFER_FLAG_KEY] === 'true' && !session.ci && maySendScanResults(session) && session.outroData?.kind === OutroKind.Success @@ -45,10 +49,11 @@ function mayAsk(session: OfferSession): boolean { export function emailProviderOffer( session: OfferSession, + flags: WizardFlags = analytics.getCachedWizardFlags(), ): EmailProviderOffer | undefined { const scan = getCodebaseScan(session); const credentials = session.credentials; - if (!scan || !credentials || !mayAsk(session)) return undefined; + if (!scan || !credentials || !mayAsk(session, flags)) return undefined; const [first, ...others] = detectedEmailProviders(scan); if (!first) return undefined; return { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 9975c0daf..44ba09c7d 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -335,6 +335,8 @@ export const WIZARD_ORCHESTRATOR_SEEDED_TASKS_FLAG_KEY = export const WIZARD_DEFAULT_AIO_LOGS_FLAG_KEY = 'wizard-default-aio-logs'; /** Boolean flag: true runs the Decisions judgment service in shadow (ask and log, never act). */ export const WIZARD_DECISIONS_DETECTION_FLAG_KEY = 'wizard-decisions-detection'; +export const WIZARD_EMAIL_PROVIDER_OFFER_FLAG_KEY = + 'wizard-email-provider-offer'; // Reading a flag enters this run into that flag's experiment, so a closed set — not a // `wizard-` prefix anyone can name into — decides what a run evaluates. Test-pinned exhaustive. export const WIZARD_FLAG_KEYS = [ @@ -346,6 +348,7 @@ export const WIZARD_FLAG_KEYS = [ WIZARD_BASIC_INTEGRATION_AGENTIC_DETECTION_FLAG_KEY, WIZARD_DEFAULT_AIO_LOGS_FLAG_KEY, WIZARD_DECISIONS_DETECTION_FLAG_KEY, + WIZARD_EMAIL_PROVIDER_OFFER_FLAG_KEY, ] as const; /** User-Agent for wizard HTTP requests and MCP server identification. */ export const WIZARD_USER_AGENT = `posthog/wizard; version: ${VERSION}`; diff --git a/src/shared/utils/__tests__/analytics.test.ts b/src/shared/utils/__tests__/analytics.test.ts index 44be51216..c7d1fa93e 100644 --- a/src/shared/utils/__tests__/analytics.test.ts +++ b/src/shared/utils/__tests__/analytics.test.ts @@ -320,6 +320,14 @@ describe('Analytics', () => { expect((mockPostHogInstance as any).evaluateFlags).toHaveBeenCalledOnce(); }); + it('caches a snapshot with no flags when the evaluation fails', async () => { + (mockPostHogInstance as any).evaluateFlags = vi + .fn() + .mockRejectedValue(new Error('flags endpoint down')); + await expect(analytics.getAllFlagsForWizard()).resolves.toEqual({}); + expect(analytics.getCachedWizardFlags()).toEqual({}); + }); + it("skips another team's flag", async () => { await analytics.getAllFlagsForWizard(); expect(snapshot.getFlag).not.toHaveBeenCalledWith('unrelated-flag'); diff --git a/src/tui/__tests__/flow-traces.test.ts b/src/tui/__tests__/flow-traces.test.ts index 8b1d4b280..c76253f1a 100644 --- a/src/tui/__tests__/flow-traces.test.ts +++ b/src/tui/__tests__/flow-traces.test.ts @@ -45,6 +45,7 @@ vi.mock('@utils/analytics', () => ({ setTag: vi.fn(), captureException: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined), + getCachedWizardFlags: vi.fn(() => null), }, sessionProperties: vi.fn(() => ({})), })); diff --git a/src/tui/__tests__/frames.test.tsx b/src/tui/__tests__/frames.test.tsx index 9dcedce8d..1e2dc7a92 100644 --- a/src/tui/__tests__/frames.test.tsx +++ b/src/tui/__tests__/frames.test.tsx @@ -20,6 +20,9 @@ vi.mock('@utils/analytics', () => ({ captureException: vi.fn(), setTag: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined), + getCachedWizardFlags: vi.fn(() => ({ + 'wizard-email-provider-offer': 'true', + })), }, sessionProperties: vi.fn(() => ({})), })); diff --git a/src/tui/__tests__/store-invariants.test.ts b/src/tui/__tests__/store-invariants.test.ts index e1481eda6..064c90ab2 100644 --- a/src/tui/__tests__/store-invariants.test.ts +++ b/src/tui/__tests__/store-invariants.test.ts @@ -45,6 +45,9 @@ vi.mock('@utils/analytics.js', () => ({ captureException: vi.fn(), setTag: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined), + getCachedWizardFlags: vi.fn(() => ({ + 'wizard-email-provider-offer': 'true', + })), }, sessionProperties: vi.fn(() => ({})), })); diff --git a/src/tui/programs/posthog-integration/screens/__tests__/EmailProviderOfferScreen.test.tsx b/src/tui/programs/posthog-integration/screens/__tests__/EmailProviderOfferScreen.test.tsx index da868932e..cafd41fdd 100644 --- a/src/tui/programs/posthog-integration/screens/__tests__/EmailProviderOfferScreen.test.tsx +++ b/src/tui/programs/posthog-integration/screens/__tests__/EmailProviderOfferScreen.test.tsx @@ -19,6 +19,9 @@ vi.mock('@utils/analytics', () => ({ wizardCapture, capture: vi.fn(), setTag: vi.fn(), + getCachedWizardFlags: vi.fn(() => ({ + 'wizard-email-provider-offer': 'true', + })), } as never, sessionProperties: vi.fn(() => ({})), })); From df77f3c430114619c5a4ef55f3f5544a4d0e36aa Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:22:49 +0000 Subject: [PATCH 4/7] feat(analytics): name up to two email providers in the offer Co-Authored-By: Claude Opus 5.5 --- .../__tests__/email-provider-offer.test.ts | 52 ++++++++++++++++++- .../email-provider-offer.ts | 30 ++++++++--- .../screens/EmailProviderOfferScreen.tsx | 2 +- 3 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts b/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts index 9a2ac817e..4a6606f3d 100644 --- a/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts +++ b/src/programs/posthog-integration/__tests__/email-provider-offer.test.ts @@ -5,7 +5,11 @@ import { HostResolution } from '@shared/host-resolution'; import { OutroKind } from '@shared/outro'; import { ScanConsent } from '@shared/run-state'; import type { ProgramSession } from '@programs/program-contract'; -import { emailProviderOffer } from '@programs/posthog-integration'; +import { + emailProviderOffer, + emailProviderOfferChoices, + emailProviderOfferQuestion, +} from '@programs/posthog-integration'; function detected(kind: string, label: string) { return { @@ -63,7 +67,7 @@ describe('emailProviderOffer', () => { expect(offer).toEqual({ providers: ['Resend', 'AwsSes'], - providerName: 'Resend', + providerLabels: ['Resend', 'Amazon SES'], workflowsUrl: 'https://us.posthog.com/project/42/workflows?source=wizard_cli', }); @@ -111,4 +115,48 @@ describe('emailProviderOffer', () => { emailProviderOffer(finishedRun(scanFinding(RESEND)), flags), ).toBeUndefined(); }); + + it.each<[string, ReturnType[], string, string]>([ + [ + 'one provider', + [RESEND], + 'Your app sends email with Resend.', + "No, I'm happy with Resend", + ], + [ + 'two providers', + [RESEND, detected('SendGrid', 'SendGrid')], + 'Your app sends email with Resend and SendGrid.', + "No, I'm happy with my current setup", + ], + [ + 'three providers', + [ + RESEND, + detected('SendGrid', 'SendGrid'), + detected('Postmark', 'Postmark'), + ], + 'Your app sends email with Resend, SendGrid and others.', + "No, I'm happy with my current setup", + ], + ])( + 'names the providers for %s and keeps all of them in the offer', + (_case, tools, opening, decline) => { + const offer = emailProviderOffer( + finishedRun(scanFinding(...tools)), + OFFER_ON, + ); + if (!offer) throw new Error('expected an offer'); + + expect(offer.providers).toEqual(tools.map((tool) => tool.kind)); + expect(emailProviderOfferQuestion(offer)).toBe( + `${opening} PostHog can send it too, triggered by the events you already track. Want to try it?`, + ); + expect(emailProviderOfferChoices(offer).map((c) => c.label)).toEqual([ + 'Yes, show me how', + 'Not now', + decline, + ]); + }, + ); }); diff --git a/src/programs/posthog-integration/email-provider-offer.ts b/src/programs/posthog-integration/email-provider-offer.ts index c9019c6db..f6a42b511 100644 --- a/src/programs/posthog-integration/email-provider-offer.ts +++ b/src/programs/posthog-integration/email-provider-offer.ts @@ -18,7 +18,7 @@ export enum EmailProviderOfferAnswer { export type EmailProviderOffer = { providers: EmailProviderKind[]; - providerName: string; + providerLabels: [string, ...string[]]; workflowsUrl: string; migrateCommand?: string; }; @@ -58,14 +58,33 @@ export function emailProviderOffer( if (!first) return undefined; return { providers: [first, ...others].map((tool) => tool.kind), - providerName: first.label, + providerLabels: [first.label, ...others.map((tool) => tool.label)], workflowsUrl: `${credentials.host.appHost}/project/${credentials.projectId}/workflows?source=wizard_cli`, migrateCommand: MIGRATE_COMMANDS[first.kind], }; } +function providerList([ + first, + second, + ...rest +]: EmailProviderOffer['providerLabels']): string { + if (second === undefined) return first; + if (rest.length === 0) return `${first} and ${second}`; + return `${first}, ${second} and others`; +} + +function declineLabel(offer: EmailProviderOffer): string { + const [only, ...others] = offer.providerLabels; + return others.length === 0 + ? `No, I'm happy with ${only}` + : "No, I'm happy with my current setup"; +} + export function emailProviderOfferQuestion(offer: EmailProviderOffer): string { - return `Your app sends email with ${offer.providerName}. PostHog can send it too, triggered by the events you already track. Want to try it?`; + return `Your app sends email with ${providerList( + offer.providerLabels, + )}. PostHog can send it too, triggered by the events you already track. Want to try it?`; } export function emailProviderOfferChoices( @@ -74,10 +93,7 @@ export function emailProviderOfferChoices( return [ { label: 'Yes, show me how', value: EmailProviderOfferAnswer.Yes }, { label: 'Not now', value: EmailProviderOfferAnswer.NotNow }, - { - label: `No, I'm happy with ${offer.providerName}`, - value: EmailProviderOfferAnswer.No, - }, + { label: declineLabel(offer), value: EmailProviderOfferAnswer.No }, ]; } diff --git a/src/tui/programs/posthog-integration/screens/EmailProviderOfferScreen.tsx b/src/tui/programs/posthog-integration/screens/EmailProviderOfferScreen.tsx index 0ab24e75f..f01f11217 100644 --- a/src/tui/programs/posthog-integration/screens/EmailProviderOfferScreen.tsx +++ b/src/tui/programs/posthog-integration/screens/EmailProviderOfferScreen.tsx @@ -128,7 +128,7 @@ const WorkflowsLink = ({ {offer.migrateCommand && ( - Move your {offer.providerName} emails with{' '} + Move your {offer.providerLabels[0]} emails with{' '} {offer.migrateCommand} From 72dd09bff725d215f7ea6858d99274300483d70a Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:26:28 +0000 Subject: [PATCH 5/7] test(analytics): give the harness analytics mock the flag read Co-Authored-By: Claude Opus 5.5 --- e2e-harness/__tests__/keyboard-equivalence.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e-harness/__tests__/keyboard-equivalence.test.tsx b/e2e-harness/__tests__/keyboard-equivalence.test.tsx index 63ce2c599..62ead1ee7 100644 --- a/e2e-harness/__tests__/keyboard-equivalence.test.tsx +++ b/e2e-harness/__tests__/keyboard-equivalence.test.tsx @@ -44,6 +44,7 @@ vi.mock('@utils/analytics', () => ({ setTag: vi.fn(), captureException: vi.fn(), shutdown: vi.fn().mockResolvedValue(undefined), + getCachedWizardFlags: vi.fn(() => null), }, sessionProperties: vi.fn(() => ({})), })); From 7b8aed8df081f434212a151484f6afcb87d9d2d8 Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 11:40:57 +0000 Subject: [PATCH 6/7] fix(analytics): unmount before the cancellation report on a signal Co-Authored-By: Claude Opus 5.5 --- src/tui/__tests__/run.test.ts | 6 +++++- src/tui/run.ts | 10 +++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/tui/__tests__/run.test.ts b/src/tui/__tests__/run.test.ts index a152e99bb..3b91d48e0 100644 --- a/src/tui/__tests__/run.test.ts +++ b/src/tui/__tests__/run.test.ts @@ -167,9 +167,10 @@ it.each([ ['SIGTERM', 143], ['SIGHUP', 130], ] as const)( - 'a %s cancels the run and runs the cleanups before the stream flush, then resolves %i', + 'a %s cancels the run, runs the cleanups before the stream flush, delivers what the unmount captures, then resolves %i', async (name, code) => { const { store, unmount } = mountedStore(); + vi.mocked(analytics.flush).mockResolvedValue(undefined); const controller = new AbortController(); const cleanup = vi.fn(); registerCleanup(cleanup); @@ -200,6 +201,9 @@ it.each([ ); expect(analytics.shutdown).toHaveBeenCalledWith('cancelled'); expect(unmount).toHaveBeenCalledOnce(); + expect(unmount.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(analytics.flush).mock.invocationCallOrder.at(-1) ?? 0, + ); }, ); diff --git a/src/tui/run.ts b/src/tui/run.ts index cd94f8a60..3ce0d3c39 100644 --- a/src/tui/run.ts +++ b/src/tui/run.ts @@ -88,6 +88,11 @@ export async function runTui( if (tui?.store.session.runPhase === RunPhase.Running) { tui.store.setRunPhase(RunPhase.Error); } + try { + tui?.unmount(); + } catch { + // terminal may already be torn down + } void Promise.all([ stream?.shutdown(2000, 'cancelled'), reportCancellation(), @@ -95,11 +100,6 @@ export async function runTui( .catch(() => logToFile('[run-wizard] cancellation shutdown failed')) .finally(() => { unregisterShutdown?.(); - try { - tui?.unmount(); - } catch { - // terminal may already be torn down - } exit.end(signal === 'SIGTERM' ? 143 : 130); }); }; From 90cccfc941bc6846f4a31511129e443969afbbbb Mon Sep 17 00:00:00 2001 From: "Reichenbach, Michael" <755327+Silthus@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:02:20 +0000 Subject: [PATCH 7/7] test(tui): deliver a cancelled answer after a successful run Co-Authored-By: Claude Opus 5.5 --- src/tui/__tests__/run.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/tui/__tests__/run.test.ts b/src/tui/__tests__/run.test.ts index 3b91d48e0..bfdd62226 100644 --- a/src/tui/__tests__/run.test.ts +++ b/src/tui/__tests__/run.test.ts @@ -207,6 +207,34 @@ it.each([ }, ); +it('an Ink Ctrl-C after a successful run delivers what the unmount captures before it resolves 130', async () => { + const { unmount } = mountedStore(); + vi.mocked(runProgram).mockResolvedValue({ + outcome: RunOutcome.Success, + diagnostics: [], + } as never); + unmount.mockImplementation(() => + analytics.wizardCapture('email provider offer answered', { + answer: 'skipped', + }), + ); + const delivered: string[] = []; + vi.mocked(analytics.flush).mockImplementation(async () => { + const queued = vi + .mocked(analytics.wizardCapture) + .mock.calls.map(([event]) => event); + await flush(); + delivered.push(...queued); + }); + const exited = runTui(posthogIntegration, launch('/tmp/after-success-test')); + await vi.waitFor(() => + expect(analytics.shutdown).toHaveBeenCalledWith('success'), + ); + vi.mocked(startTUI).mock.calls[0][2]?.(); + await expect(exited).resolves.toBe(130); + expect(delivered).toContain('email provider offer answered'); +}); + it("resolves a screen's exit request with its code and starts no stream shutdown", async () => { const { store, unmount } = mountedStore(); vi.mocked(runProgram).mockResolvedValue({