From 4d50c61e10a277721bc1b23545a809c120b75b8b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 21 Sep 2026 18:19:16 +0200 Subject: [PATCH 1/5] fix(onboarding): separate channel creation from final setup --- .../dashboard/AppOnboardingFlow.vue | 197 +++++------ .../dashboard/ChannelCreateOnboarding.vue | 13 +- .../ChannelSetupOnboardingDialog.vue | 2 +- src/utils/onboardingChannelAnalytics.ts | 2 +- src/utils/onboardingProgressAnalytics.ts | 4 +- src/utils/userOnboardingProgress.ts | 15 +- ...20260921155716_onboarding_channel_step.sql | 52 +++ ...app-onboarding-apikey-loading.unit.test.ts | 4 +- ...app-onboarding-apikey-runtime.unit.test.ts | 10 +- ...boarding-progress-integration.unit.test.ts | 312 +++++++++++------- tests/app-onboarding-v3.unit.test.ts | 9 +- tests/channel-create-onboarding.unit.test.ts | 6 +- .../onboarding-channel-analytics.unit.test.ts | 44 ++- tests/user-onboarding-progress.unit.test.ts | 40 ++- tests/users-onboarding-size.test.ts | 18 + 15 files changed, 450 insertions(+), 278 deletions(-) create mode 100644 supabase/migrations/20260921155716_onboarding_channel_step.sql diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 9afb5c352c..1f3a0b738e 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -18,7 +18,7 @@ import type { import type { OnboardingPersistOptions, OnboardingPersistResult } from '~/utils/onboardingProgressPersistence' import type { UserOnboardingSetupStage, UserOnboardingStatus } from '~/utils/userOnboardingProgress' import mime from 'mime' -import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' @@ -69,7 +69,6 @@ import { isValidAppId } from '~/utils/appId' import { shouldSkipOnboardingResume } from '~/utils/appOnboardingProgress' import { useBeforeUnloadWarning } from '~/utils/beforeUnloadWarning' import { - hasNewChannelTreatment, hasWebNativeDevelopmentEnvironmentTreatment, parseOnboardingABTestAssignments, reconcileOnboardingABTestAssignments, @@ -150,7 +149,6 @@ const onboardingForABTests = computed(() => { const config = getLocalConfig() const webNativePublishIntentTreatment = computed(() => shouldShowWebNativePublishIntent(onboardingForABTests.value)) const webNativeDevelopmentEnvironmentTreatment = computed(() => hasWebNativeDevelopmentEnvironmentTreatment(onboardingForABTests.value)) -const newChannelTreatment = computed(() => hasNewChannelTreatment(onboardingForABTests.value)) const APPLE_LOOKUP_TIMEOUT_MS = 5_000 const STORE_ICON_FETCH_TIMEOUT_MS = 10_000 const ONBOARDING_AB_TEST_WAIT_TIMEOUT_MS = 3_000 @@ -161,10 +159,10 @@ const removeBeforeUnloadWarning = useBeforeUnloadWarning(Boolean(props.preOrg)) type AppRow = Omit & { onboarding?: unknown } -type StandardFlowStep = 'details' | 'choice' | 'install' | 'setup' -type PreOrgFlowStep = 'intent' | 'publish_app_question' | 'details' | 'organization' | 'setup' +type StandardFlowStep = 'details' | 'choice' | 'channel' | 'install' | 'setup' +type PreOrgFlowStep = 'intent' | 'publish_app_question' | 'details' | 'organization' | 'channel' | 'setup' type OnboardingFlowStep = StandardFlowStep | PreOrgFlowStep -type OnboardingProgressStepId = OnboardingFlowStep | 'channel' +type OnboardingProgressStepId = OnboardingFlowStep type AppDetailsStep = 'name' | 'app_id' | 'icon' type AppDetailsAnalyticsStep = 'app_name' | 'app_id' | 'app_icon' type SetupStage = UserOnboardingSetupStage @@ -211,20 +209,10 @@ const preOrgCreatedOrganizationId = ref(null) const preOrgShouldInvite = ref(false) const reportedSetupSource = ref<'manual' | 'cli' | 'mcp' | 'ai' | null>(null) const flowStep = ref('details') +const finalOnboardingStep = ref<'setup' | 'install'>(props.preOrg ? 'setup' : 'install') const appDetailsStep = ref('name') -const setupStage = ref('cli') -const showSetupBackButton = computed(() => newChannelTreatment.value && ( - ( - props.preOrg - && flowStep.value === 'setup' - && (setupStage.value === 'channel-create' || setupStage.value === 'cli') - ) - || ( - !props.preOrg - && flowStep.value === 'install' - && setupStage.value === 'channel-create' - ) -)) +const setupStage = ref('channel-routing') +const showSetupBackButton = computed(() => flowStep.value === 'channel' && setupStage.value !== 'channel-routing') const showLanguageSelector = computed(() => ( (props.preOrg && !createdApp.value) || (flowStep.value === 'setup' && Boolean(createdApp.value)) @@ -370,7 +358,6 @@ function refreshOnboardingABTests(options: { force?: boolean } = {}): Promise { }) const resumeStep = computed(() => { const value = route.query.step - return value === 'choice' || value === 'install' || value === 'setup' ? value : null + return value === 'choice' || value === 'channel' || value === 'install' || value === 'setup' ? value : null }) const canUseStoreImportPreview = computed(() => useImportedStoreIcon.value && !!storeIconPreview.value) const iconPreview = computed(() => localIconPreview.value || (canUseStoreImportPreview.value ? storeIconPreview.value : '') || '') @@ -548,33 +535,20 @@ const appOnboardingSteps = computed flowStep.value === 'publish_app_question' ? 'intent' : flowStep.value) -const onboardingProgressSteps = computed>(() => { - if (props.preOrg && newChannelTreatment.value) { - return [ - { id: 'intent', label: t('unified-onboarding-step-intent') }, - { id: 'details', label: t('app-onboarding-step-details') }, - { id: 'organization', label: t('unified-onboarding-step-organization') }, - { id: 'channel', label: t('unified-onboarding-step-channel') }, - { id: 'setup', label: t('unified-onboarding-step-setup') }, - ] - } - return appOnboardingSteps.value -}) -const currentProgressStepId = computed(() => { - if (props.preOrg && newChannelTreatment.value && flowStep.value === 'setup' && setupStage.value !== 'cli') - return 'channel' - return stepperStepId.value -}) +const onboardingProgressSteps = computed>(() => appOnboardingSteps.value) +const currentProgressStepId = computed(() => stepperStepId.value) const currentStepIndex = computed(() => Math.max(0, onboardingProgressSteps.value.findIndex(entry => entry.id === currentProgressStepId.value))) const stepProgress = computed(() => `${((currentStepIndex.value + 1) / onboardingProgressSteps.value.length) * 100}%`) const userCountStops = computed(() => { @@ -636,7 +610,12 @@ function trackOrganizationEvent( } function trackChannelEvent(name: OnboardingChannelEvent, details: OnboardingChannelEventProperties) { - progressTracker?.trackStepEvent(name, analyticsStepFor(flowStep.value), withOnboardingChannelOrigin(details)) + progressTracker?.trackStepEvent(name, 'channel', { + ...withOnboardingChannelOrigin(details), + app_id: createdApp.value?.app_id, + existing_app: existingApp.value ?? undefined, + intent: selectedIntent.value ?? undefined, + }) } const detailsFieldTracker = createOnboardingDetailsFieldDebouncer((name, step, details) => { @@ -680,8 +659,6 @@ function initializeProgressTracking(resumed: boolean) { }) if (initialStep === 'welcome') trackedAnalyticsSteps.unshift('welcome') - if (!props.preOrg && resumed && flowStep.value === 'setup') - trackedAnalyticsSteps.push('setup') ensurePublishAppQuestionStepTracked() progressTracker = createOnboardingProgressTracker({ @@ -693,7 +670,10 @@ function initializeProgressTracking(resumed: boolean) { onboardingAttemptId: onboardingTelemetry.attemptId, onboardingRunId: onboardingTelemetry.runId, }) - progressTracker.viewStep(initialStep) + if (initialStep === 'setup' || initialStep === 'install') + void viewFinalStepWhenRendered(initialStep) + else + progressTracker.viewStep(initialStep) for (const visibilityChange of pendingVisibilityChanges) progressTracker.trackVisibilityChange(visibilityChange.state, visibilityChange.occurredAt) pendingVisibilityChanges = [] @@ -713,10 +693,19 @@ function completeAndViewStep(nextStep: OnboardingFlowStep, completionProperties: nextStep: nextAnalyticsStep, }) flowStep.value = nextStep - progressTracker?.viewStep(nextAnalyticsStep, previousAnalyticsStep) + if (nextStep === 'setup' || nextStep === 'install') + void viewFinalStepWhenRendered(nextStep, previousAnalyticsStep) + else + progressTracker?.viewStep(nextAnalyticsStep, previousAnalyticsStep) void persistOnboardingProgress() } +async function viewFinalStepWhenRendered(step: 'setup' | 'install', previousStep?: OnboardingAnalyticsStep) { + await nextTick() + if (!isLoading.value && createdApp.value && flowStep.value === step) + progressTracker?.viewStep(step, previousStep) +} + function viewPreviousStep(nextStep: OnboardingFlowStep) { const previousStep = flowStep.value if (previousStep === nextStep) @@ -751,9 +740,10 @@ function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress' publishAppQuestion: flowStep.value === 'publish_app_question', intent: selectedIntent.value, detailsStep: appDetailsStep.value, - setupStage: flowStep.value === 'setup' || flowStep.value === 'install' ? setupStage.value : undefined, + finalStep: finalOnboardingStep.value, + setupStage: flowStep.value === 'channel' ? setupStage.value : flowStep.value === 'setup' || flowStep.value === 'install' ? 'cli' : undefined, appName: appName.value, - appId: selectedAppIdSource.value === 'generated' ? '' : generatedAppId.value, + appId: createdApp.value?.app_id ?? (selectedAppIdSource.value === 'generated' ? '' : generatedAppId.value), existingApp: existingApp.value, existingAppSetup: existingAppSetup.value, storeUrl: storeUrl.value, @@ -889,8 +879,9 @@ async function writeOnboardingProgress( function resetOnboardingForm() { flowStep.value = props.preOrg ? 'intent' : 'details' + finalOnboardingStep.value = props.preOrg ? 'setup' : 'install' appDetailsStep.value = 'name' - setupStage.value = 'cli' + setupStage.value = 'channel-routing' selectedDevelopmentEnvironment.value = null skippedPublishAppQuestion.value = false selectedIntent.value = null @@ -937,6 +928,7 @@ function applyOnboardingProgress(progress: ReturnType> = { +const previousSetupStage: Partial> = { 'channel-self-assign': 'channel-routing', 'channel-console-assign': 'channel-self-assign', 'channel-create': 'channel-console-assign', - 'cli': 'channel-create', } function goBackFromSetupStage() { @@ -2145,11 +2136,6 @@ function goBackFromSetupStage() { } } -watch(newChannelTreatment, () => { - if (!onboardingABTestsPending.value) - reconcileSetupStageWithChannelAssignment() -}) - function onTechnicalInviteOpened() { progressTracker?.trackStepEvent('onboarding_technical_invite_opened', 'setup') } @@ -2287,7 +2273,7 @@ async function createAppRecord(options?: { nextStep?: StandardFlowStep | PreOrgF if (flowStep.value === 'details') completionProperties.storeImportUsed = hasImportedStoreMetadata.value const nextStep = options?.nextStep ?? 'choice' - if (nextStep === 'setup' || nextStep === 'install') + if (nextStep === 'channel') setupStage.value = resolveSetupStage() completeAndViewStep(nextStep, completionProperties) } @@ -2456,7 +2442,7 @@ function goToInstallStep() { isCliCommandVisible.value = false setupStage.value = resolveSetupStage() startApiKeyLoading() - completeAndViewStep('install', { + completeAndViewStep('channel', { appId: createdApp.value.app_id, }) } @@ -2706,7 +2692,7 @@ defineExpose({ 'onboarding-flow-details-icon': flowStep === 'details' && appDetailsStep === 'icon', }" > -
+
@@ -3504,35 +3490,37 @@ defineExpose({ @channel-analytics="trackChannelEvent" /> -
+
+
-
+
+

{{ t('unified-onboarding-step-setup') }} @@ -3714,34 +3702,7 @@ defineExpose({

- - - - - - - - -
+

diff --git a/src/components/dashboard/ChannelCreateOnboarding.vue b/src/components/dashboard/ChannelCreateOnboarding.vue index 2491fb4ac0..e8cdb029a0 100644 --- a/src/components/dashboard/ChannelCreateOnboarding.vue +++ b/src/components/dashboard/ChannelCreateOnboarding.vue @@ -47,6 +47,7 @@ const showNameError = ref(false) const submitError = ref('') const completedChannel = ref(null) const createdInOnboarding = ref(false) +const hasContinued = ref(false) const channelNameSource = ref<'manual' | 'suggestion'>('manual') const lastTrackedChannelName = ref('') const currentOrganization = computed(() => organizationStore.currentOrganization) @@ -63,6 +64,7 @@ const canSubmit = computed(() => ( hasRequiredPermissions.value && !isInitializing.value && !isSubmitting.value + && !completedChannel.value && !channelNameError.value )) @@ -202,6 +204,8 @@ function getCreateBlockReason(): NonNullable & { + app_id?: string development_environment?: OnboardingDevelopmentEnvironment + existing_app?: boolean invitation_count?: number intent?: OnboardingIntent starting_out?: boolean diff --git a/src/utils/userOnboardingProgress.ts b/src/utils/userOnboardingProgress.ts index ed0629fb8d..e7bcedc28a 100644 --- a/src/utils/userOnboardingProgress.ts +++ b/src/utils/userOnboardingProgress.ts @@ -5,7 +5,7 @@ import type { } from '~/utils/onboardingProgressAnalytics' export const USER_ONBOARDING_STATUSES = ['in_progress', 'completed', 'abandoned'] as const -export const USER_ONBOARDING_STEPS = ['intent', 'publish_app_question', 'details', 'organization', 'choice', 'install', 'setup'] as const +export const USER_ONBOARDING_STEPS = ['intent', 'publish_app_question', 'details', 'organization', 'choice', 'channel', 'install', 'setup'] as const export const USER_ONBOARDING_FLOWS = ['pre_org', 'existing_org'] as const export const USER_ONBOARDING_DEVELOPMENT_ENVIRONMENTS = ['hosted_builder', 'ai_assistant', 'hand_coded', 'other', 'local_project', 'exploring', 'skipped'] as const satisfies readonly OnboardingDevelopmentEnvironment[] export const USER_ONBOARDING_INTENTS = ['ota', 'builder', 'both', 'exploring', 'publish'] as const @@ -25,6 +25,7 @@ export interface UserOnboardingProgress { intent?: OnboardingIntent publish_app_question?: true details_step?: UserOnboardingDetailsStep + final_step?: 'setup' | 'install' setup_stage?: UserOnboardingSetupStage app_name?: string app_id?: string @@ -45,6 +46,7 @@ export const USER_ONBOARDING_PROGRESS_FIELDS = { app_name: true, completed_at: true, details_step: true, + final_step: true, development_environment: true, estimated_users_index: true, existing_app: true, @@ -71,6 +73,7 @@ export interface UserOnboardingProgressInput { intent?: OnboardingIntent | null publishAppQuestion?: boolean detailsStep?: UserOnboardingDetailsStep + finalStep?: 'setup' | 'install' setupStage?: UserOnboardingSetupStage appName?: string appId?: string @@ -164,6 +167,9 @@ function applyOptionalUserOnboardingFields( if (isOneOf(raw.details_step, USER_ONBOARDING_DETAILS_STEPS)) progress.details_step = raw.details_step + if (raw.final_step === 'setup' || raw.final_step === 'install') + progress.final_step = raw.final_step + if (isOneOf(raw.setup_stage, USER_ONBOARDING_SETUP_STAGES)) progress.setup_stage = raw.setup_stage @@ -257,6 +263,9 @@ export function buildUserOnboardingProgress(input: UserOnboardingProgressInput): if (input.detailsStep) progress.details_step = input.detailsStep + if (input.finalStep) + progress.final_step = input.finalStep + if (input.setupStage) progress.setup_stage = input.setupStage @@ -305,7 +314,7 @@ export function clampResumableOnboardingStep( step: UserOnboardingStep, flow: OnboardingAnalyticsFlow, ): UserOnboardingStep { - if (flow === 'pre_org' && (step === 'choice' || step === 'install' || step === 'setup')) + if (flow === 'pre_org' && (step === 'choice' || step === 'install')) return 'organization' return step } @@ -315,6 +324,8 @@ export function resumableOnboardingFlowStep( flow: OnboardingAnalyticsFlow, ): UserOnboardingStep { const step = clampResumableOnboardingStep(progress.step, flow) + if ((step === 'setup' || step === 'install') && progress.setup_stage !== 'cli') + return 'channel' if (progress.publish_app_question === true && (step === 'intent' || step === 'publish_app_question')) return 'publish_app_question' return step diff --git a/supabase/migrations/20260921155716_onboarding_channel_step.sql b/supabase/migrations/20260921155716_onboarding_channel_step.sql new file mode 100644 index 0000000000..e90e7ea1fd --- /dev/null +++ b/supabase/migrations/20260921155716_onboarding_channel_step.sql @@ -0,0 +1,52 @@ +ALTER TABLE "public"."users" +DROP CONSTRAINT IF EXISTS "users_onboarding_valid"; + +ALTER TABLE "public"."users" +ADD CONSTRAINT "users_onboarding_valid" CHECK ( + ("jsonb_typeof"("onboarding") = 'object'::"text") + AND ("octet_length"(("onboarding")::"text") <= 65536) + AND ( + (NOT ("onboarding" ? 'status'::"text")) + OR ( + ("jsonb_typeof"(("onboarding" -> 'status'::"text")) = 'string'::"text") + AND (("onboarding" ->> 'status'::"text") = ANY (ARRAY['in_progress'::"text", 'completed'::"text", 'abandoned'::"text"])) + ) + ) + AND ( + (NOT ("onboarding" ? 'step'::"text")) + OR ( + ("jsonb_typeof"(("onboarding" -> 'step'::"text")) = 'string'::"text") + AND (("onboarding" ->> 'step'::"text") = ANY (ARRAY['intent'::"text", 'publish_app_question'::"text", 'details'::"text", 'organization'::"text", 'choice'::"text", 'channel'::"text", 'install'::"text", 'setup'::"text"])) + ) + ) + AND ( + (NOT ("onboarding" ? 'flow'::"text")) + OR ( + ("jsonb_typeof"(("onboarding" -> 'flow'::"text")) = 'string'::"text") + AND (("onboarding" ->> 'flow'::"text") = ANY (ARRAY['pre_org'::"text", 'existing_org'::"text"])) + ) + ) + AND ( + (NOT ("onboarding" ? 'development_environment'::"text")) + OR ( + ("jsonb_typeof"(("onboarding" -> 'development_environment'::"text")) = 'string'::"text") + AND (("onboarding" ->> 'development_environment'::"text") = ANY (ARRAY['hosted_builder'::"text", 'ai_assistant'::"text", 'hand_coded'::"text", 'other'::"text", 'local_project'::"text", 'exploring'::"text", 'skipped'::"text"])) + ) + ) + AND ( + (NOT ("onboarding" ? 'intent'::"text")) + OR ( + ("jsonb_typeof"(("onboarding" -> 'intent'::"text")) = 'string'::"text") + AND (("onboarding" ->> 'intent'::"text") = ANY (ARRAY['ota'::"text", 'builder'::"text", 'both'::"text", 'exploring'::"text", 'publish'::"text"])) + ) + ) + AND ( + (NOT ("onboarding" ? 'setup_stage'::"text")) + OR ( + ("jsonb_typeof"(("onboarding" -> 'setup_stage'::"text")) = 'string'::"text") + AND (("onboarding" ->> 'setup_stage'::"text") = ANY (ARRAY['channel-routing'::"text", 'channel-self-assign'::"text", 'channel-console-assign'::"text", 'channel-create'::"text", 'cli'::"text"])) + ) + ) +) NOT VALID; + +COMMENT ON COLUMN "public"."users"."onboarding" IS 'Persisted create-app onboarding wizard progress for resume and admin drop-off. Keys: status, step, flow, final_step, development_environment, intent, details_step, setup_stage, app_name, app_id, existing_app, existing_app_setup, store_url, imported_store_app_id, org_name, estimated_users_index, onboarding_attempt_id, last_run_id, abtests, updated_at, completed_at.'; diff --git a/tests/app-onboarding-apikey-loading.unit.test.ts b/tests/app-onboarding-apikey-loading.unit.test.ts index 90ccdb3802..d25afe19a1 100644 --- a/tests/app-onboarding-apikey-loading.unit.test.ts +++ b/tests/app-onboarding-apikey-loading.unit.test.ts @@ -22,7 +22,7 @@ describe('app onboarding API key loading state', () => { it.concurrent('renders a resumed app without waiting for API key provisioning', () => { const resumeLoader = onboardingSource.slice( - onboardingSource.indexOf('async function loadResumeApp()'), + onboardingSource.indexOf('async function loadResumeApp('), onboardingSource.indexOf('async function importStoreMetadata('), ) const mountedFlow = onboardingSource.slice(onboardingSource.indexOf('onMounted(async () => {')) @@ -39,7 +39,7 @@ describe('app onboarding API key loading state', () => { it.concurrent('targets the created app when a stale resume falls back to replacement creation', () => { const keyLoader = onboardingSource.slice( onboardingSource.indexOf('async function ensureApiKey('), - onboardingSource.indexOf('async function loadResumeApp()'), + onboardingSource.indexOf('async function loadResumeApp('), ) expect(keyLoader).toContain('const userId = main.user?.id ?? main.auth?.id') diff --git a/tests/app-onboarding-apikey-runtime.unit.test.ts b/tests/app-onboarding-apikey-runtime.unit.test.ts index 85e789b357..bf7dc0a8de 100644 --- a/tests/app-onboarding-apikey-runtime.unit.test.ts +++ b/tests/app-onboarding-apikey-runtime.unit.test.ts @@ -170,7 +170,13 @@ beforeEach(() => { runtimeMocks.organizationStore.updateAppOnboarding.mockClear() Object.defineProperty(window, 'matchMedia', { configurable: true, - value: vi.fn(() => ({ matches: false })), + value: vi.fn(() => ({ + addEventListener: vi.fn(), + addListener: vi.fn(), + matches: false, + removeEventListener: vi.fn(), + removeListener: vi.fn(), + })), }) }) @@ -232,7 +238,7 @@ describe('app onboarding API key runtime loading', () => { } }) - it('retries a settled failed load when entering the install step', async () => { + it('retries a settled failed load when entering the channel step', async () => { const loadError = new Error('transient API-key failure') const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) runtimeMocks.findUsablePlainApiKey diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 57e2397734..9db83b3031 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs' import { URL as NodeUrl } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { createApp } from 'vue' +import { sendOnboardingEvent } from '../src/services/onboardingTracking' import AppOnboardingFlow from '../src/components/dashboard/AppOnboardingFlow.vue' -const messages = JSON.parse(readFileSync(new NodeUrl('../messages/en.json', import.meta.url), 'utf8')) as Record const writerMocks = vi.hoisted(() => ({ abTestAssignments: {} as Record, @@ -119,6 +119,148 @@ function expectSourceOrder(source: string, markers: string[]) { } describe('app onboarding progress analytics integration', () => { + it('records a final setup view only after the saved setup screen renders', async () => { + const previousUser = writerMocks.main.user + const previousRouteQuery = writerMocks.route.query + const previousOrganization = writerMocks.organization.currentOrganization + const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia') + writerMocks.route.query = { resume: 'com.example.final', step: 'setup' } + writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' } + writerMocks.main.user = { + id: 'user-bento-retry', + image_url: 'avatar.png', + onboarding: { + app_id: 'com.example.final', + final_step: 'setup', + flow: 'pre_org', + setup_stage: 'cli', + status: 'in_progress', + step: 'setup', + updated_at: '2026-09-21T00:00:00.000Z', + }, + } + writerMocks.loadApp.mockResolvedValue({ + data: { + android_store_url: null, + app_id: 'com.example.final', + existing_app: true, + icon_url: null, + ios_store_url: null, + name: 'Test App', + onboarding: { setup: { todo_list_version: 3, steps: {} } }, + owner_org: 'test-org', + }, + error: null, + }) + writerMocks.replaceUserOnboardingIfUnchanged.mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({ + data: { ...writerMocks.main.user, onboarding }, + error: null, + })) + Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false })) }) + vi.mocked(sendOnboardingEvent).mockClear() + const container = document.createElement('div') + const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg: true }) + app.config.warnHandler = () => undefined + try { + app.mount(container) + await vi.waitFor(() => expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'setup')).toBe(true)) + expect(container.querySelector('[data-test="onboarding-setup-cli"]')).not.toBeNull() + expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel')).toBe(false) + } + finally { + app.unmount() + writerMocks.main.user = previousUser + writerMocks.route.query = previousRouteQuery + writerMocks.organization.currentOrganization = previousOrganization + if (matchMediaDescriptor) + Object.defineProperty(window, 'matchMedia', matchMediaDescriptor) + else + Reflect.deleteProperty(window, 'matchMedia') + } + }) + + it('resumes channel before setup for either todo-list version, flow, and assignment', async () => { + const previousUser = writerMocks.main.user + const previousAuthGeneration = writerMocks.main.authGeneration + const previousRouteQuery = writerMocks.route.query + const previousOrganization = writerMocks.organization.currentOrganization + const previousAssignments = writerMocks.abTestAssignments + const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia') + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: vi.fn(() => ({ matches: false })), + }) + + try { + for (const preOrg of [true, false]) { + for (const todoListVersion of [2, 3, 4]) { + const appId = `com.example.channel.${preOrg ? 'pre' : 'existing'}.${todoListVersion}` + writerMocks.route.query = { resume: appId, step: preOrg ? 'setup' : 'install' } + writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' } + writerMocks.abTestAssignments = { new_channel: { assigned_at: '2026-09-21T00:00:00.000Z', branch: todoListVersion === 2 ? 'B' : 'A' } } + writerMocks.main.user = { + id: 'user-bento-retry', + image_url: 'avatar.png', + onboarding: { + app_id: appId, + final_step: preOrg ? 'setup' : 'install', + flow: preOrg ? 'pre_org' : 'existing_org', + setup_stage: 'channel-routing', + status: 'in_progress', + step: 'channel', + updated_at: '2026-09-21T00:00:00.000Z', + }, + } + writerMocks.loadApp.mockResolvedValue({ + data: { + android_store_url: null, + app_id: appId, + existing_app: true, + icon_url: null, + ios_store_url: null, + name: 'Test App', + onboarding: { setup: { todo_list_version: todoListVersion, ota_todo_list_version: todoListVersion === 4 ? '1' : undefined, steps: {} } }, + owner_org: 'test-org', + }, + error: null, + }) + writerMocks.replaceUserOnboardingIfUnchanged.mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({ + data: { ...writerMocks.main.user, onboarding }, + error: null, + })) + vi.mocked(sendOnboardingEvent).mockClear() + const container = document.createElement('div') + const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg }) + app.config.warnHandler = () => undefined + try { + app.mount(container) + await vi.waitFor(() => expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel')).toBe(true)) + const viewed = vi.mocked(sendOnboardingEvent).mock.calls.filter(call => call[0] === 'onboarding_step_viewed') + expect(viewed.some(call => call[1]?.step === 'setup' || call[1]?.step === 'install')).toBe(false) + expect(container.querySelector('[data-test="onboarding-setup-cli"]')).toBeNull() + expect(container.querySelector('[data-test="onboarding-install-cli"]')).toBeNull() + } + finally { + app.unmount() + } + + writerMocks.main.authGeneration += 1 + } + } + } + finally { + writerMocks.main.user = previousUser + writerMocks.main.authGeneration = previousAuthGeneration + writerMocks.route.query = previousRouteQuery + writerMocks.organization.currentOrganization = previousOrganization + writerMocks.abTestAssignments = previousAssignments + if (matchMediaDescriptor) + Object.defineProperty(window, 'matchMedia', matchMediaDescriptor) + else + Reflect.deleteProperty(window, 'matchMedia') + } + }) + it.concurrent('forwards document visibility changes and removes the listener on teardown', () => { const visibilityHandler = sourceBetween('function trackOnboardingVisibilityChange()', 'function initializeProgressTracking(') expect(visibilityHandler).toContain('const visibilityChange = { state: document.visibilityState, occurredAt: Date.now() }') @@ -338,8 +480,8 @@ describe('app onboarding progress analytics integration', () => { expect(initializer).toContain('trackedAnalyticsSteps = appOnboardingSteps.value.flatMap') expect(initializer).toContain('return Object.values(APP_DETAILS_ANALYTICS_STEPS)') expect(initializer).toContain(`trackedAnalyticsSteps.unshift('welcome')`) - expect(initializer).toContain(`if (!props.preOrg && resumed && flowStep.value === 'setup')`) - expect(initializer).toContain(`trackedAnalyticsSteps.push('setup')`) + expect(initializer).toContain(`if (initialStep === 'setup' || initialStep === 'install')`) + expect(initializer).toContain('void viewFinalStepWhenRendered(initialStep)') expect(initializer).toContain('ensurePublishAppQuestionStepTracked()') expect(initializer).toContain('steps: trackedAnalyticsSteps') expect(initializer).toContain('resumed,') @@ -389,10 +531,11 @@ describe('app onboarding progress analytics integration', () => { ]) expect(resumeDialog.match(/return null/g)).toHaveLength(2) - const resumeLoader = sourceBetween('async function loadResumeApp()', 'async function importStoreMetadata()') + const resumeLoader = sourceBetween('async function loadResumeApp(', 'async function importStoreMetadata()') expect(resumeLoader).not.toContain('initializeProgressTracking') expect(resumeLoader).not.toContain('viewStep') - expect(resumeLoader).toContain('if (props.preOrg || resumeStep.value === \'setup\')') + expect(resumeLoader).toContain("finalOnboardingStep.value = props.preOrg || resumeStep.value === 'setup'") + expect(resumeLoader).toContain("if (finalOnboardingStep.value === 'setup')") expectSourceOrder(resumeLoader, [ 'const savedProgress = parseUserOnboardingProgress(main.user?.onboarding)', 'applyOnboardingProgress(savedProgress)', @@ -561,127 +704,50 @@ describe('app onboarding progress analytics integration', () => { expect(onboardingSource).toContain(`sendOnboardingEvent('onboarding_intent_selected', {`) }) - it.concurrent('keeps Maker+ invitations inside the organization progress step before setup', () => { - expect(onboardingSource).toContain(`createAppRecord({ nextStep: shouldInvite ? 'organization' : 'setup' })`) + it.concurrent('keeps Maker+ invitations inside the organization progress step before channel', () => { + expect(onboardingSource).toContain(`createAppRecord({ nextStep: shouldInvite ? 'organization' : 'channel' })`) expect(onboardingSource).toContain(`trackOrganizationEvent('onboarding_organization_invite_viewed')`) - expect(onboardingSource).toContain(`completeAndViewStep('setup', { appId: createdApp.value.app_id })`) + expect(onboardingSource).toContain(`completeAndViewStep('channel', { appId: createdApp.value.app_id })`) }) - it.concurrent('shows channel education before CLI for every fresh or resumed setup path', () => { - expect(onboardingSource).toContain(`type PreOrgFlowStep = 'intent' | 'publish_app_question' | 'details' | 'organization' | 'setup'`) - expect(onboardingSource).toContain(`type SetupStage = UserOnboardingSetupStage`) - expect(onboardingSource).toContain(`const setupStage = ref('cli')`) - expect(onboardingSource).toContain('const newChannelTreatment = computed(() => hasNewChannelTreatment(onboardingForABTests.value))') - expect(onboardingSource).toContain('const onboardingABTestsPending = ref(false)') - - const assignmentRefresh = sourceBetween('function refreshOnboardingABTests(', 'async function waitForOnboardingABTests(') - expect(assignmentRefresh).toContain('onboardingABTestsPending.value = true') - expect(assignmentRefresh).toContain('onboardingABTestsPending.value = false') - expect(assignmentRefresh).toContain('reconcileSetupStageWithChannelAssignment()') - - const assignmentStageReconciliation = sourceBetween('function resolveSetupStage(', 'function continueFromChannelDefaultRouting()') - expect(assignmentStageReconciliation).toContain('!newChannelTreatment.value && !onboardingABTestsPending.value') - expect(assignmentStageReconciliation).toContain('function reconcileSetupStageWithChannelAssignment()') - expect(assignmentStageReconciliation).toContain(`flowStep.value !== 'setup' && flowStep.value !== 'install'`) - expect(assignmentStageReconciliation).toContain('onboardingABTestsPending.value') - expect(assignmentStageReconciliation).toContain('newChannelTreatment.value') - expect(assignmentStageReconciliation).toContain(`setSetupStage('cli')`) - - const stepList = sourceBetween('const appOnboardingSteps = computed', 'const onboardingProgressSteps = computed') - expect(stepList).not.toContain(`{ id: 'channels'`) - - const progressStepList = sourceBetween('const onboardingProgressSteps = computed', 'const currentProgressStepId = computed') - expectSourceOrder(progressStepList, [ - `{ id: 'intent', label: t('unified-onboarding-step-intent') }`, - `{ id: 'details', label: t('app-onboarding-step-details') }`, - `{ id: 'organization', label: t('unified-onboarding-step-organization') }`, - `{ id: 'channel', label: t('unified-onboarding-step-channel') }`, - `{ id: 'setup', label: t('unified-onboarding-step-setup') }`, + it.concurrent('routes both onboarding flows through channel before final setup', () => { + const preOrgSteps = sourceBetween('const appOnboardingSteps = computed', 'const stepperStepId = computed') + expectSourceOrder(preOrgSteps, [ + "{ id: 'organization'", + "{ id: 'channel'", + "{ id: 'setup'", + ]) + expectSourceOrder(preOrgSteps, [ + "{ id: 'choice'", + "{ id: 'channel'", + "{ id: finalOnboardingStep.value", + ]) + expect(onboardingSource).toContain("createAppRecord({ nextStep: shouldInvite ? 'organization' : 'channel' })") + expect(onboardingSource).toContain("completeAndViewStep('channel', { appId: createdApp.value.app_id })") + expect(onboardingSource).toContain("completeAndViewStep('channel', {\n appId: createdApp.value.app_id,") + + const channel = sourceBetween('function continueFromChannelDefaultRouting()', 'function onTechnicalInviteOpened()') + expectSourceOrder(channel, [ + "setSetupStage('channel-self-assign')", + "setSetupStage('channel-console-assign')", + "setSetupStage('channel-create')", + "completeAndViewStep(finalOnboardingStep.value", + ]) + expect(channel).toContain("if (flowStep.value !== 'channel' || setupStage.value !== 'channel-create' || !createdApp.value)") + expect(channel).toContain("trackChannelStageTransition(finalOnboardingStep.value, 'forward')") + + const renderedChannel = sourceBetween('flowStep === \'channel\' && createdApp', 'flowStep === \'setup\' && createdApp') + expectSourceOrder(renderedChannel, [ + ' (flowStep.value === \'setup\' || flowStep.value === \'install\') && usesOtaTodoList.value)') + expect(onboardingSource).toContain("progressTracker?.trackStepEvent(name, 'channel', {") + expect(onboardingSource).toContain('void viewFinalStepWhenRendered(nextStep, previousAnalyticsStep)') + expect(onboardingSource).toContain('if (!isLoading.value && createdApp.value && flowStep.value === step)') }) it.concurrent('keeps the unload warning scoped to unfinished pre-org onboarding', () => { @@ -715,7 +781,7 @@ describe('app onboarding progress analytics integration', () => { expect(appCreation).toContain('completeAndViewStep(nextStep, completionProperties)') const realSetupChoice = sourceBetween('function goToInstallStep()', 'function openDashboard()') - expect(realSetupChoice).toContain(`completeAndViewStep('install', {`) + expect(realSetupChoice).toContain(`completeAndViewStep('channel', {`) expect(realSetupChoice).toContain('appId: createdApp.value.app_id') }) diff --git a/tests/app-onboarding-v3.unit.test.ts b/tests/app-onboarding-v3.unit.test.ts index d7f0af40dc..f2ce3a3210 100644 --- a/tests/app-onboarding-v3.unit.test.ts +++ b/tests/app-onboarding-v3.unit.test.ts @@ -128,7 +128,7 @@ describe('pre-organization onboarding v3', () => { }) it.concurrent('does not restore a skipped generated App ID as a manual choice', () => { - expect(onboardingSource).toContain('appId: selectedAppIdSource.value === \'generated\' ? \'\' : generatedAppId.value') + expect(onboardingSource).toContain("appId: createdApp.value?.app_id ?? (selectedAppIdSource.value === 'generated' ? '' : generatedAppId.value)") }) it.concurrent('tracks every app-details page as a standard onboarding step', () => { @@ -248,7 +248,7 @@ describe('pre-organization onboarding v3', () => { expect(onboardingSource).toContain('website: websitePreview.value?.website') expect(onboardingSource).toContain('selectedStop.planName !== \'Solo\'') expect(onboardingSource).toContain(' { @@ -302,7 +302,7 @@ describe('pre-organization onboarding v3', () => { const organizationCreation = sliceBetween(onboardingSource, 'async function createOrganizationAndApp()', 'async function createAppRecord(') expect(organizationCreation).toContain('preOrgCreatedOrganizationId.value = data.id') expect(organizationCreation).toContain('await completePreOrgAppCreation(data.id, shouldInvite)') - expect(organizationCreation).toContain('await createAppRecord({ nextStep: shouldInvite ? \'organization\' : \'setup\' })') + expect(organizationCreation).toContain('await createAppRecord({ nextStep: shouldInvite ? \'organization\' : \'channel\' })') const appCreation = sliceBetween(onboardingSource, 'async function createAppRecord(', 'async function seedDemoData()') expect(appCreation).toContain('returnToAppIdAfterConflict()') @@ -317,8 +317,7 @@ describe('pre-organization onboarding v3', () => { expect(setup).toContain(':show-manual-setup-link="false"') expect(setup).toContain(':tracking-version="3"') expect(setup).toContain('t(\'onboarding-manual-setup-prefix\')') - expect(setup).toContain(' { `setSetupStage('channel-create')`, `setSetupStage('cli')`, ]) - expect(onboardingSource.match(/ { @@ -84,7 +84,7 @@ describe('channel creation onboarding', () => { it.concurrent('tracks privacy-safe creation milestones through the onboarding event pipeline', () => { expect(onboardingSource).toContain('function trackChannelEvent(name: OnboardingChannelEvent') - expect(onboardingSource.match(/@analytics="trackChannelEvent"/g)).toHaveLength(8) + expect(onboardingSource.match(/@analytics="trackChannelEvent"/g)).toHaveLength(4) expect(onboardingSource).toContain(`'onboarding_channel_stage_continued'`) expect(onboardingSource).toContain(`'onboarding_channel_stage_backed'`) diff --git a/tests/onboarding-channel-analytics.unit.test.ts b/tests/onboarding-channel-analytics.unit.test.ts index 84e7f83fcb..e63bc22ad5 100644 --- a/tests/onboarding-channel-analytics.unit.test.ts +++ b/tests/onboarding-channel-analytics.unit.test.ts @@ -40,28 +40,28 @@ describe('onboarding channel animation analytics', () => { const progress = createOnboardingProgressTracker({ capture, flow: 'pre_org', - steps: ['setup'], + steps: ['channel', 'setup'], resumed: true, onboardingAttemptId: '00000000-0000-4000-8000-000000000001', onboardingRunId: 'ir_00000000-0000-4000-8000-000000000002', onboardingVersion: () => NEW_CHANNEL_ANALYTICS_VERSION, supaHost: 'https://supabase.capgo.test', }) - progress.viewStep('setup') + progress.viewStep('channel') const regular = createOnboardingChannelAnimationTracker({ stage: 'channel-routing', - emit: (event, properties) => progress.trackStepEvent(event, 'setup', withOnboardingChannelOrigin(properties)), + emit: (event, properties) => progress.trackStepEvent(event, 'channel', withOnboardingChannelOrigin(properties)), }) regular.start('automatic', { durationMs: () => 8000, progress: () => 0.25 }) const todo = createOnboardingChannelAnimationTracker({ stage: 'channel-routing', - emit: (event, properties) => progress.trackStepEvent(event, 'setup', withOnboardingChannelOrigin(withOnboardingChannelOrigin(properties, 'todo_list'))), + emit: (event, properties) => progress.trackStepEvent(event, 'channel', withOnboardingChannelOrigin(withOnboardingChannelOrigin(properties, 'todo_list'))), }) todo.start('automatic', { durationMs: () => 8000, progress: () => 0.25 }) todo.replayRequested() todo.start('replay', { durationMs: () => 8000, progress: () => 0.25 }) todo.dispose() - progress.trackStepEvent('onboarding_channel_create_succeeded', 'setup', withOnboardingChannelOrigin({ + progress.trackStepEvent('onboarding_channel_create_succeeded', 'channel', withOnboardingChannelOrigin({ channel_stage: 'channel-create', created_in_onboarding: true, channel_name_length: 10, @@ -81,11 +81,43 @@ describe('onboarding channel animation analytics', () => { 'onboarding_channel_create_succeeded', ]) for (const [, , properties] of channelCaptures) { - expect(properties).toMatchObject({ onboarding_version: NEW_CHANNEL_ANALYTICS_VERSION, resumed: true, step: 'setup' }) + expect(properties).toMatchObject({ onboarding_version: NEW_CHANNEL_ANALYTICS_VERSION, resumed: true, step: 'channel' }) expect(properties).not.toHaveProperty('channel_name') } }) + it.concurrent.each([ + ['pre_org', 'setup'], + ['existing_org', 'install'], + ] as const)('reports %s channel substeps before viewing %s', (flow, finalStep) => { + const capture = vi.fn() + const progress = createOnboardingProgressTracker({ + capture, + flow, + steps: ['details', 'channel', finalStep], + resumed: false, + onboardingAttemptId: '00000000-0000-4000-8000-000000000001', + onboardingRunId: 'ir_00000000-0000-4000-8000-000000000002', + supaHost: 'https://supabase.capgo.test', + }) + progress.viewStep('details') + progress.completeStep('details', { nextStep: 'channel' }) + progress.viewStep('channel', 'details') + for (const channelStage of ['channel-routing', 'channel-self-assign', 'channel-console-assign', 'channel-create'] as const) + progress.trackStepEvent('onboarding_channel_stage_viewed', 'channel', { channel_stage: channelStage, channel_flow_origin: 'onboarding' }) + + expect(capture.mock.calls.filter(call => call[0] === 'onboarding_step_viewed' && call[2].step === finalStep)).toHaveLength(0) + progress.completeStep('channel', { nextStep: finalStep }) + progress.completeStep('channel', { nextStep: finalStep }) + progress.viewStep(finalStep, 'channel') + + const channelViews = capture.mock.calls.filter(call => call[0] === 'onboarding_channel_stage_viewed') + expect(channelViews.map(call => call[2].channel_stage)).toEqual(['channel-routing', 'channel-self-assign', 'channel-console-assign', 'channel-create']) + expect(channelViews.every(call => call[2].step === 'channel' && call[2].flow === flow && call[2].onboarding_attempt_id && call[2].onboarding_run_id)).toBe(true) + expect(capture.mock.calls.filter(call => call[0] === 'onboarding_step_completed' && call[2].step === 'channel')).toHaveLength(1) + expect(capture.mock.calls.filter(call => call[0] === 'onboarding_step_viewed' && call[2].step === finalStep)).toHaveLength(1) + }) + it.concurrent('tracks the stage, animation start, completion, and a completed continuation', () => { const context = createTracker() context.tracker.start('automatic', context.source) diff --git a/tests/user-onboarding-progress.unit.test.ts b/tests/user-onboarding-progress.unit.test.ts index ca2e51bb22..8d090fc81c 100644 --- a/tests/user-onboarding-progress.unit.test.ts +++ b/tests/user-onboarding-progress.unit.test.ts @@ -204,8 +204,9 @@ describe('user onboarding progress', () => { }), 'pre_org')).toBe(false) }) - it.concurrent('clamps post-org steps back to organization for the pre-org wizard', () => { - expect(clampResumableOnboardingStep('setup', 'pre_org')).toBe('organization') + it.concurrent('keeps pre-org channel and setup resumable', () => { + expect(clampResumableOnboardingStep('setup', 'pre_org')).toBe('setup') + expect(clampResumableOnboardingStep('channel', 'pre_org')).toBe('channel') expect(clampResumableOnboardingStep('install', 'existing_org')).toBe('install') expect(clampResumableOnboardingStep('details', 'pre_org')).toBe('details') }) @@ -248,13 +249,36 @@ describe('user onboarding progress', () => { expect(isUsersOnboardingCheckConstraintError({ code: '23505' })).toBe(false) }) - it.concurrent('keeps channel education out of persisted top-level onboarding steps', () => { - expect(parseUserOnboardingProgress({ + it.concurrent.each(['pre_org', 'existing_org'] as const)('resumes %s at its saved channel substep', (flow) => { + const progress = buildUserOnboardingProgress({ status: 'in_progress', - step: 'channels', - flow: 'pre_org', - updated_at: '2026-08-15T00:00:00.000Z', - })).toBeNull() + step: 'channel', + flow, + setupStage: 'channel-console-assign', + finalStep: flow === 'pre_org' ? 'setup' : 'install', + appId: 'com.example.onboarding', + }) + expect(parseUserOnboardingProgress(progress)).toMatchObject({ + step: 'channel', + setup_stage: 'channel-console-assign', + final_step: flow === 'pre_org' ? 'setup' : 'install', + }) + expect(resumableOnboardingFlowStep(progress, flow)).toBe('channel') + }) + + it.concurrent.each([ + ['pre_org', 'setup'], + ['existing_org', 'install'], + ] as const)('routes legacy %s final steps through channel until completion', (flow, finalStep) => { + const legacy = parseUserOnboardingProgress({ + status: 'in_progress', + step: finalStep, + flow, + updated_at: '2026-09-10T00:00:00.000Z', + })! + expect(resumableOnboardingFlowStep(legacy, flow)).toBe('channel') + expect(resumableOnboardingFlowStep({ ...legacy, setup_stage: 'channel-create' }, flow)).toBe('channel') + expect(resumableOnboardingFlowStep({ ...legacy, setup_stage: 'cli' }, flow)).toBe(finalStep) }) it.concurrent('round-trips every channel setup stage and ignores invalid stages', () => { diff --git a/tests/users-onboarding-size.test.ts b/tests/users-onboarding-size.test.ts index 49a499925a..09febbb0aa 100644 --- a/tests/users-onboarding-size.test.ts +++ b/tests/users-onboarding-size.test.ts @@ -57,4 +57,22 @@ describe('users.onboarding size constraint', () => { [userId, 'b'.repeat(65_522)], )).rejects.toMatchObject({ code: '23514' }) }) + + it('accepts the channel step and rejects unknown onboarding steps', async () => { + const accepted = await executeSQL<{ step: string }>( + `UPDATE public.users + SET onboarding = jsonb_build_object('status', 'in_progress', 'flow', 'pre_org', 'step', 'channel', 'setup_stage', 'channel-create') + WHERE id = $1 + RETURNING onboarding ->> 'step' AS step`, + [userId], + ) + expect(accepted[0]?.step).toBe('channel') + + await expect(executeSQL( + `UPDATE public.users + SET onboarding = jsonb_build_object('status', 'in_progress', 'flow', 'pre_org', 'step', 'channels') + WHERE id = $1`, + [userId], + )).rejects.toMatchObject({ code: '23514' }) + }) }) From 05e26c1cb4b2dcf13b8400636d439ecc5de124fc Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 21 Sep 2026 18:31:37 +0200 Subject: [PATCH 2/5] fix(onboarding): route legacy resumes through channel creation --- .../dashboard/AppOnboardingFlow.vue | 4 +- src/utils/userOnboardingProgress.ts | 2 +- ...20260921155716_onboarding_channel_step.sql | 7 + ...boarding-progress-integration.unit.test.ts | 141 +++++++++--------- tests/user-onboarding-progress.unit.test.ts | 3 +- tests/users-onboarding-size.test.ts | 13 +- 6 files changed, 94 insertions(+), 76 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 1f3a0b738e..f1c9477e32 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -1235,14 +1235,14 @@ async function loadResumeApp(appId = resumeAppId.value) { void loadResumeIconPreview(data.icon_url, data.app_id, iconLoadRun) finalOnboardingStep.value = props.preOrg || resumeStep.value === 'setup' || (resumeStep.value !== 'choice' && savedProgress?.app_id === data.app_id && (savedProgress?.final_step === 'setup' || savedProgress?.step === 'setup')) ? 'setup' : 'install' if (finalOnboardingStep.value === 'setup') { - flowStep.value = savedProgress && savedProgress.app_id === data.app_id && savedProgress.setup_stage === 'cli' + flowStep.value = savedProgress && savedProgress.app_id === data.app_id && savedProgress.final_step && savedProgress.setup_stage === 'cli' ? 'setup' : 'channel' if (!savedProgress?.intent) hydrateIntentFromCurrentOrg() } else { - flowStep.value = resumeStep.value === 'choice' ? 'choice' : savedProgress && savedProgress.app_id === data.app_id && savedProgress.setup_stage === 'cli' ? 'install' : 'channel' + flowStep.value = resumeStep.value === 'choice' ? 'choice' : savedProgress && savedProgress.app_id === data.app_id && savedProgress.final_step && savedProgress.setup_stage === 'cli' ? 'install' : 'channel' } return true } diff --git a/src/utils/userOnboardingProgress.ts b/src/utils/userOnboardingProgress.ts index e7bcedc28a..6abbd5cd2a 100644 --- a/src/utils/userOnboardingProgress.ts +++ b/src/utils/userOnboardingProgress.ts @@ -324,7 +324,7 @@ export function resumableOnboardingFlowStep( flow: OnboardingAnalyticsFlow, ): UserOnboardingStep { const step = clampResumableOnboardingStep(progress.step, flow) - if ((step === 'setup' || step === 'install') && progress.setup_stage !== 'cli') + if ((step === 'setup' || step === 'install') && (progress.setup_stage !== 'cli' || !progress.final_step)) return 'channel' if (progress.publish_app_question === true && (step === 'intent' || step === 'publish_app_question')) return 'publish_app_question' diff --git a/supabase/migrations/20260921155716_onboarding_channel_step.sql b/supabase/migrations/20260921155716_onboarding_channel_step.sql index e90e7ea1fd..2a518fda8b 100644 --- a/supabase/migrations/20260921155716_onboarding_channel_step.sql +++ b/supabase/migrations/20260921155716_onboarding_channel_step.sql @@ -19,6 +19,13 @@ ADD CONSTRAINT "users_onboarding_valid" CHECK ( AND (("onboarding" ->> 'step'::"text") = ANY (ARRAY['intent'::"text", 'publish_app_question'::"text", 'details'::"text", 'organization'::"text", 'choice'::"text", 'channel'::"text", 'install'::"text", 'setup'::"text"])) ) ) + AND ( + (NOT ("onboarding" ? 'final_step'::"text")) + OR ( + ("jsonb_typeof"(("onboarding" -> 'final_step'::"text")) = 'string'::"text") + AND (("onboarding" ->> 'final_step'::"text") = ANY (ARRAY['setup'::"text", 'install'::"text"])) + ) + ) AND ( (NOT ("onboarding" ? 'flow'::"text")) OR ( diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 9db83b3031..0ae1d6b2f4 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -4,9 +4,8 @@ import { readFileSync } from 'node:fs' import { URL as NodeUrl } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { createApp } from 'vue' -import { sendOnboardingEvent } from '../src/services/onboardingTracking' import AppOnboardingFlow from '../src/components/dashboard/AppOnboardingFlow.vue' - +import { sendOnboardingEvent } from '../src/services/onboardingTracking' const writerMocks = vi.hoisted(() => ({ abTestAssignments: {} as Record, @@ -194,57 +193,59 @@ describe('app onboarding progress analytics integration', () => { try { for (const preOrg of [true, false]) { for (const todoListVersion of [2, 3, 4]) { - const appId = `com.example.channel.${preOrg ? 'pre' : 'existing'}.${todoListVersion}` - writerMocks.route.query = { resume: appId, step: preOrg ? 'setup' : 'install' } - writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' } - writerMocks.abTestAssignments = { new_channel: { assigned_at: '2026-09-21T00:00:00.000Z', branch: todoListVersion === 2 ? 'B' : 'A' } } - writerMocks.main.user = { - id: 'user-bento-retry', - image_url: 'avatar.png', - onboarding: { - app_id: appId, - final_step: preOrg ? 'setup' : 'install', - flow: preOrg ? 'pre_org' : 'existing_org', - setup_stage: 'channel-routing', - status: 'in_progress', - step: 'channel', - updated_at: '2026-09-21T00:00:00.000Z', - }, + for (const resumeKind of ['channel', 'legacy-final'] as const) { + const appId = `com.example.channel.${preOrg ? 'pre' : 'existing'}.${todoListVersion}.${resumeKind}` + writerMocks.route.query = { resume: appId, step: preOrg ? 'setup' : 'install' } + writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' } + writerMocks.abTestAssignments = { new_channel: { assigned_at: '2026-09-21T00:00:00.000Z', branch: todoListVersion === 2 ? 'B' : 'A' } } + writerMocks.main.user = { + id: 'user-bento-retry', + image_url: 'avatar.png', + onboarding: { + app_id: appId, + final_step: resumeKind === 'channel' ? (preOrg ? 'setup' : 'install') : undefined, + flow: preOrg ? 'pre_org' : 'existing_org', + setup_stage: resumeKind === 'channel' ? 'channel-routing' : 'cli', + status: 'in_progress', + step: resumeKind === 'channel' ? 'channel' : preOrg ? 'setup' : 'install', + updated_at: '2026-09-21T00:00:00.000Z', + }, + } + writerMocks.loadApp.mockResolvedValue({ + data: { + android_store_url: null, + app_id: appId, + existing_app: true, + icon_url: null, + ios_store_url: null, + name: 'Test App', + onboarding: { setup: { todo_list_version: todoListVersion, ota_todo_list_version: todoListVersion === 4 ? '1' : undefined, steps: {} } }, + owner_org: 'test-org', + }, + error: null, + }) + writerMocks.replaceUserOnboardingIfUnchanged.mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({ + data: { ...writerMocks.main.user, onboarding }, + error: null, + })) + vi.mocked(sendOnboardingEvent).mockClear() + const container = document.createElement('div') + const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg }) + app.config.warnHandler = () => undefined + try { + app.mount(container) + await vi.waitFor(() => expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel')).toBe(true)) + const viewed = vi.mocked(sendOnboardingEvent).mock.calls.filter(call => call[0] === 'onboarding_step_viewed') + expect(viewed.some(call => call[1]?.step === 'setup' || call[1]?.step === 'install')).toBe(false) + expect(container.querySelector('[data-test="onboarding-setup-cli"]')).toBeNull() + expect(container.querySelector('[data-test="onboarding-install-cli"]')).toBeNull() + } + finally { + app.unmount() + } + + writerMocks.main.authGeneration += 1 } - writerMocks.loadApp.mockResolvedValue({ - data: { - android_store_url: null, - app_id: appId, - existing_app: true, - icon_url: null, - ios_store_url: null, - name: 'Test App', - onboarding: { setup: { todo_list_version: todoListVersion, ota_todo_list_version: todoListVersion === 4 ? '1' : undefined, steps: {} } }, - owner_org: 'test-org', - }, - error: null, - }) - writerMocks.replaceUserOnboardingIfUnchanged.mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({ - data: { ...writerMocks.main.user, onboarding }, - error: null, - })) - vi.mocked(sendOnboardingEvent).mockClear() - const container = document.createElement('div') - const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg }) - app.config.warnHandler = () => undefined - try { - app.mount(container) - await vi.waitFor(() => expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel')).toBe(true)) - const viewed = vi.mocked(sendOnboardingEvent).mock.calls.filter(call => call[0] === 'onboarding_step_viewed') - expect(viewed.some(call => call[1]?.step === 'setup' || call[1]?.step === 'install')).toBe(false) - expect(container.querySelector('[data-test="onboarding-setup-cli"]')).toBeNull() - expect(container.querySelector('[data-test="onboarding-install-cli"]')).toBeNull() - } - finally { - app.unmount() - } - - writerMocks.main.authGeneration += 1 } } } @@ -534,8 +535,8 @@ describe('app onboarding progress analytics integration', () => { const resumeLoader = sourceBetween('async function loadResumeApp(', 'async function importStoreMetadata()') expect(resumeLoader).not.toContain('initializeProgressTracking') expect(resumeLoader).not.toContain('viewStep') - expect(resumeLoader).toContain("finalOnboardingStep.value = props.preOrg || resumeStep.value === 'setup'") - expect(resumeLoader).toContain("if (finalOnboardingStep.value === 'setup')") + expect(resumeLoader).toContain('finalOnboardingStep.value = props.preOrg || resumeStep.value === \'setup\'') + expect(resumeLoader).toContain('if (finalOnboardingStep.value === \'setup\')') expectSourceOrder(resumeLoader, [ 'const savedProgress = parseUserOnboardingProgress(main.user?.onboarding)', 'applyOnboardingProgress(savedProgress)', @@ -713,28 +714,28 @@ describe('app onboarding progress analytics integration', () => { it.concurrent('routes both onboarding flows through channel before final setup', () => { const preOrgSteps = sourceBetween('const appOnboardingSteps = computed', 'const stepperStepId = computed') expectSourceOrder(preOrgSteps, [ - "{ id: 'organization'", - "{ id: 'channel'", - "{ id: 'setup'", + '{ id: \'organization\'', + '{ id: \'channel\'', + '{ id: \'setup\'', ]) expectSourceOrder(preOrgSteps, [ - "{ id: 'choice'", - "{ id: 'channel'", - "{ id: finalOnboardingStep.value", + '{ id: \'choice\'', + '{ id: \'channel\'', + '{ id: finalOnboardingStep.value', ]) - expect(onboardingSource).toContain("createAppRecord({ nextStep: shouldInvite ? 'organization' : 'channel' })") - expect(onboardingSource).toContain("completeAndViewStep('channel', { appId: createdApp.value.app_id })") - expect(onboardingSource).toContain("completeAndViewStep('channel', {\n appId: createdApp.value.app_id,") + expect(onboardingSource).toContain('createAppRecord({ nextStep: shouldInvite ? \'organization\' : \'channel\' })') + expect(onboardingSource).toContain('completeAndViewStep(\'channel\', { appId: createdApp.value.app_id })') + expect(onboardingSource).toContain('completeAndViewStep(\'channel\', {\n appId: createdApp.value.app_id,') const channel = sourceBetween('function continueFromChannelDefaultRouting()', 'function onTechnicalInviteOpened()') expectSourceOrder(channel, [ - "setSetupStage('channel-self-assign')", - "setSetupStage('channel-console-assign')", - "setSetupStage('channel-create')", - "completeAndViewStep(finalOnboardingStep.value", + 'setSetupStage(\'channel-self-assign\')', + 'setSetupStage(\'channel-console-assign\')', + 'setSetupStage(\'channel-create\')', + 'completeAndViewStep(finalOnboardingStep.value', ]) - expect(channel).toContain("if (flowStep.value !== 'channel' || setupStage.value !== 'channel-create' || !createdApp.value)") - expect(channel).toContain("trackChannelStageTransition(finalOnboardingStep.value, 'forward')") + expect(channel).toContain('if (flowStep.value !== \'channel\' || setupStage.value !== \'channel-create\' || !createdApp.value)') + expect(channel).toContain('trackChannelStageTransition(finalOnboardingStep.value, \'forward\')') const renderedChannel = sourceBetween('flowStep === \'channel\' && createdApp', 'flowStep === \'setup\' && createdApp') expectSourceOrder(renderedChannel, [ @@ -745,7 +746,7 @@ describe('app onboarding progress analytics integration', () => { ]) expect(renderedChannel).not.toContain('newChannelTreatment') expect(onboardingSource).toContain('const showSetupChecklist = computed(() => (flowStep.value === \'setup\' || flowStep.value === \'install\') && usesOtaTodoList.value)') - expect(onboardingSource).toContain("progressTracker?.trackStepEvent(name, 'channel', {") + expect(onboardingSource).toContain('progressTracker?.trackStepEvent(name, \'channel\', {') expect(onboardingSource).toContain('void viewFinalStepWhenRendered(nextStep, previousAnalyticsStep)') expect(onboardingSource).toContain('if (!isLoading.value && createdApp.value && flowStep.value === step)') }) diff --git a/tests/user-onboarding-progress.unit.test.ts b/tests/user-onboarding-progress.unit.test.ts index 8d090fc81c..d1df41ae5e 100644 --- a/tests/user-onboarding-progress.unit.test.ts +++ b/tests/user-onboarding-progress.unit.test.ts @@ -278,7 +278,8 @@ describe('user onboarding progress', () => { })! expect(resumableOnboardingFlowStep(legacy, flow)).toBe('channel') expect(resumableOnboardingFlowStep({ ...legacy, setup_stage: 'channel-create' }, flow)).toBe('channel') - expect(resumableOnboardingFlowStep({ ...legacy, setup_stage: 'cli' }, flow)).toBe(finalStep) + expect(resumableOnboardingFlowStep({ ...legacy, setup_stage: 'cli' }, flow)).toBe('channel') + expect(resumableOnboardingFlowStep({ ...legacy, final_step: finalStep, setup_stage: 'cli' }, flow)).toBe(finalStep) }) it.concurrent('round-trips every channel setup stage and ignores invalid stages', () => { diff --git a/tests/users-onboarding-size.test.ts b/tests/users-onboarding-size.test.ts index 09febbb0aa..e46c649d9c 100644 --- a/tests/users-onboarding-size.test.ts +++ b/tests/users-onboarding-size.test.ts @@ -58,10 +58,10 @@ describe('users.onboarding size constraint', () => { )).rejects.toMatchObject({ code: '23514' }) }) - it('accepts the channel step and rejects unknown onboarding steps', async () => { + it('accepts the channel step and final destination, and rejects unknown steps', async () => { const accepted = await executeSQL<{ step: string }>( `UPDATE public.users - SET onboarding = jsonb_build_object('status', 'in_progress', 'flow', 'pre_org', 'step', 'channel', 'setup_stage', 'channel-create') + SET onboarding = jsonb_build_object('status', 'in_progress', 'flow', 'pre_org', 'step', 'channel', 'setup_stage', 'channel-create', 'final_step', 'setup') WHERE id = $1 RETURNING onboarding ->> 'step' AS step`, [userId], @@ -74,5 +74,14 @@ describe('users.onboarding size constraint', () => { WHERE id = $1`, [userId], )).rejects.toMatchObject({ code: '23514' }) + + for (const invalidFinalStep of ['channel', 123]) { + await expect(executeSQL( + `UPDATE public.users + SET onboarding = jsonb_build_object('status', 'in_progress', 'flow', 'pre_org', 'step', 'channel', 'final_step', $2::jsonb) + WHERE id = $1`, + [userId, JSON.stringify(invalidFinalStep)], + )).rejects.toMatchObject({ code: '23514' }) + } }) }) From 58a8c582bd0b57033e82d98d77bbf0cb281cb811 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Mon, 21 Sep 2026 18:52:03 +0200 Subject: [PATCH 3/5] test(onboarding): model completed channel flow in setup preview --- playwright/e2e/onboarding-setup.spec.ts | 6 ++++++ playwright/fixtures/onboarding-setup.ts | 13 +++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/playwright/e2e/onboarding-setup.spec.ts b/playwright/e2e/onboarding-setup.spec.ts index 9b40282507..75c8e319ec 100644 --- a/playwright/e2e/onboarding-setup.spec.ts +++ b/playwright/e2e/onboarding-setup.spec.ts @@ -614,6 +614,12 @@ test.describe('Dashboard exploration and returning to v3 setup', () => { const appId = 'com.example.onboarding-preview' const setup = `${fixture}?view=navigation&resume=${appId}&step=setup` + test('routes legacy saved setup through channel creation first', async ({ page }) => { + await page.goto(`${setup}&legacyChannel=1`) + await expect(page.locator('[data-test="channel-default-routing-animation"]')).toBeVisible() + await expect(page.locator('[data-test="onboarding-setup-cli"]')).toHaveCount(0) + }) + test('lands on the app dashboard, prompts on refresh, and returns to fullscreen setup', async ({ page }) => { await page.goto(setup) await expect(page.locator('[data-test="onboarding-setup-cli"]')).toBeVisible() diff --git a/playwright/fixtures/onboarding-setup.ts b/playwright/fixtures/onboarding-setup.ts index 7ff65861fe..a44b7ce1ef 100644 --- a/playwright/fixtures/onboarding-setup.ts +++ b/playwright/fixtures/onboarding-setup.ts @@ -53,6 +53,15 @@ const state = { const events: string[] = [] const channelEvents: Array<{ event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties }> = [] const preview = { state, events, channelEvents, appId: ref(previewAppId), command: ref('npx @capgo/cli@latest i [API_KEY]'), hiding: ref(false), selectedOrgId: ref('') } +const previewUserOnboarding = { + intent: assignment ? 'builder' : 'ota', + status: 'in_progress', + step: 'setup', + flow: 'existing_org', + setup_stage: 'cli', + app_id: previewAppId, + ...(params.get('legacyChannel') === '1' ? {} : { final_step: 'setup' }), +} Object.assign(window, { onboardingSetupPreview: preview }) function previewOnboarding() { @@ -140,7 +149,7 @@ window.fetch = async (input, init) => { } if (params.get('view') === 'flow' || navigationView) { const app = { id: '00000000-0000-4000-8000-000000000003', app_id: previewAppId, name: 'My Capacitor app', icon_url: '', owner_org: '00000000-0000-4000-8000-000000000002', need_onboarding: true, onboarding: previewOnboarding() } - const user = { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: { intent: assignment ? 'builder' : 'ota', status: 'in_progress', step: 'setup', flow: 'app', setup_stage: 'cli', app_id: previewAppId } } + const user = { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: previewUserOnboarding } const rows = url.pathname.endsWith('/apps') ? (!url.searchParams.get('owner_org') || url.searchParams.get('owner_org') === `eq.${app.owner_org}` ? [app] : []) : url.pathname.endsWith('/users') ? [user] : url.pathname.endsWith('/apikeys') ? [{ key: '00000000-0000-4000-8000-000000000004', rbac_id: '00000000-0000-4000-8000-000000000005', expires_at: null }] : url.pathname.endsWith('/role_bindings') ? [{ principal_id: '00000000-0000-4000-8000-000000000005', scope_type: 'org', roles: { name: 'org_super_admin' } }] : [] const single = new Headers(init?.headers).get('Accept')?.includes('object') return new Response(JSON.stringify(single ? rows[0] ?? {} : rows), { headers: { 'Content-Type': 'application/json' } }) @@ -210,7 +219,7 @@ const app = createApp(defineComponent({ const pinia = createPinia() app.use(pinia) // Supply identity to the real channel form without starting dashboard store watchers. -Object.defineProperty(useMainStore(pinia), 'user', { value: { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: { intent: assignment ? 'builder' : 'ota', status: 'in_progress', step: 'setup', flow: 'app', setup_stage: 'cli', app_id: previewAppId } } }) +Object.defineProperty(useMainStore(pinia), 'user', { value: { id: '00000000-0000-4000-8000-000000000001', email: 'preview@example.com', onboarding: previewUserOnboarding } }) Object.defineProperty(useMainStore(pinia), 'auth', { value: { id: '00000000-0000-4000-8000-000000000001' } }) useMainStore(pinia).awaitInitialLoad = async () => true const organization = useOrganizationStore(pinia) From 1938f3b240af231220eaf7915a6cf0e3870fcf76 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 22 Sep 2026 15:56:53 +0200 Subject: [PATCH 4/5] fix(onboarding): resume saved channel stage automatically --- .../dashboard/AppOnboardingFlow.vue | 31 ++++- src/utils/onboardingProgressAnalytics.ts | 9 +- ...boarding-progress-integration.unit.test.ts | 117 +++++++++++++++++- ...onboarding-progress-analytics.unit.test.ts | 44 +++++++ 4 files changed, 191 insertions(+), 10 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index f1c9477e32..50e83a548d 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -16,7 +16,7 @@ import type { OnboardingStepCompletionProperties, } from '~/utils/onboardingProgressAnalytics' import type { OnboardingPersistOptions, OnboardingPersistResult } from '~/utils/onboardingProgressPersistence' -import type { UserOnboardingSetupStage, UserOnboardingStatus } from '~/utils/userOnboardingProgress' +import type { UserOnboardingProgress, UserOnboardingSetupStage, UserOnboardingStatus } from '~/utils/userOnboardingProgress' import mime from 'mime' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' @@ -973,6 +973,29 @@ function applyDefaultPreOrgDetails() { flowStep.value = 'intent' } +function recordSkippedChannelResumeDialog(saved: UserOnboardingProgress | null) { + const flow = props.preOrg ? 'pre_org' : 'existing_org' + if ( + saved?.status !== 'in_progress' + || saved.flow !== flow + || resumableOnboardingFlowStep(saved, flow) !== 'channel' + || saved.app_id !== createdApp.value?.app_id + || flowStep.value !== 'channel' + || setupStage.value === 'cli' + ) { + return false + } + + onboardingTelemetry.prepareResumeCandidate({ + onboardingAttemptId: saved.onboarding_attempt_id, + lastRunId: saved.last_run_id, + savedStep: 'channel', + steps: appOnboardingSteps.value.map(step => step.id), + }) + onboardingTelemetry.recordResumeDialogSkipped(setupStage.value) + return true +} + async function maybeResumeSavedOnboarding() { const flow = props.preOrg ? 'pre_org' : 'existing_org' const saved = parseUserOnboardingProgress(main.user?.onboarding) @@ -989,6 +1012,9 @@ async function maybeResumeSavedOnboarding() { } const resumableStep = resumableOnboardingFlowStep(saved, flow) + if (resumableStep === 'channel' && saved.app_id && await loadResumeApp(saved.app_id) && recordSkippedChannelResumeDialog(saved)) + return true + onboardingTelemetry.prepareResumeCandidate({ onboardingAttemptId: saved.onboarding_attempt_id, lastRunId: saved.last_run_id, @@ -2546,6 +2572,7 @@ onMounted(async () => { onboardingProgressPersistence.abort() return } + recordSkippedChannelResumeDialog(parseUserOnboardingProgress(main.user?.onboarding)) startApiKeyLoading() return } @@ -2574,6 +2601,8 @@ onMounted(async () => { onboardingProgressPersistence.abort() return } + if (resumed) + recordSkippedChannelResumeDialog(parseUserOnboardingProgress(main.user?.onboarding)) startApiKeyLoading() } diff --git a/src/utils/onboardingProgressAnalytics.ts b/src/utils/onboardingProgressAnalytics.ts index f616204ad2..84bacea575 100644 --- a/src/utils/onboardingProgressAnalytics.ts +++ b/src/utils/onboardingProgressAnalytics.ts @@ -1,4 +1,4 @@ -import type { OnboardingChannelEvent, OnboardingChannelEventProperties } from '~/utils/onboardingChannelAnalytics' +import type { OnboardingChannelEvent, OnboardingChannelEventProperties, OnboardingChannelStage } from '~/utils/onboardingChannelAnalytics' import { sendOnboardingEvent } from '~/services/onboardingTracking' export const ONBOARDING_ANALYTICS_VERSION = 4 @@ -222,13 +222,13 @@ export function createOnboardingTelemetryIdentity(options: CreateOnboardingTelem } function recordResumeDialogViewed() { - if (recorded.dialog || !candidate) + if (recorded.dialog || recorded.decision || !candidate) return recorded.dialog = true safelyCapture('onboarding_resume_dialog_viewed', resumeProperties(candidate)) } - function recordDecision(name: string, continueSavedAttempt: boolean) { + function recordDecision(name: string, continueSavedAttempt: boolean, details: AnalyticsProperties = {}) { if (recorded.decision || !candidate) return recorded.decision = true @@ -236,7 +236,7 @@ export function createOnboardingTelemetryIdentity(options: CreateOnboardingTelem if (continueSavedAttempt && candidate.onboardingAttemptId) activeAttemptId = candidate.onboardingAttemptId - const properties = resumeProperties(candidate) + const properties = { ...resumeProperties(candidate), ...details } if (activeAttemptId !== previousAttemptId) properties.initial_onboarding_attempt_id = initialAttemptId safelyCapture(name, properties) @@ -250,6 +250,7 @@ export function createOnboardingTelemetryIdentity(options: CreateOnboardingTelem candidate = next }, recordResumeContinued: () => recordDecision('onboarding_resume_continued', true), + recordResumeDialogSkipped: (channelStage: OnboardingChannelStage) => recordDecision('onboarding_resume_dialog_skipped', true, { channel_stage: channelStage }), recordResumeDialogViewed, recordResumeRestarted: () => recordDecision('onboarding_resume_restarted', false), } diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 0ae1d6b2f4..0909ce7d22 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -9,6 +9,11 @@ import { sendOnboardingEvent } from '../src/services/onboardingTracking' const writerMocks = vi.hoisted(() => ({ abTestAssignments: {} as Record, + dialog: { + lastButtonRole: null, + onDialogDismiss: vi.fn(async () => undefined), + openDialog: vi.fn(), + }, loadApp: vi.fn(), main: { auth: { id: 'user-bento-retry' }, @@ -44,6 +49,15 @@ vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) vi.mock('../src/components/dashboard/ChannelDefaultRoutingOnboarding.vue', () => ({ default: { template: '

' }, })) +vi.mock('../src/components/dashboard/ChannelSelfAssignOnboarding.vue', () => ({ + default: { template: '
' }, +})) +vi.mock('../src/components/dashboard/ChannelConsoleAssignOnboarding.vue', () => ({ + default: { template: '
' }, +})) +vi.mock('../src/components/dashboard/ChannelCreateOnboarding.vue', () => ({ + default: { template: '
' }, +})) vi.mock('~/services/apikeys', () => ({ createDefaultApiKey: vi.fn(), findUsablePlainApiKey: vi.fn(async () => 'test-api-key'), @@ -84,11 +98,7 @@ vi.mock('~/services/userOnboardingWriteQueue', async (importOriginal) => { }) vi.mock('~/stores/dashboardApps', () => ({ useDashboardAppsStore: () => ({ upsertApp: vi.fn() }) })) vi.mock('~/stores/dialogv2', () => ({ - useDialogV2Store: () => ({ - lastButtonRole: null, - onDialogDismiss: vi.fn(async () => undefined), - openDialog: vi.fn(), - }), + useDialogV2Store: () => writerMocks.dialog, })) vi.mock('~/stores/main', () => ({ useMainStore: () => writerMocks.main })) vi.mock('~/stores/organization', () => ({ useOrganizationStore: () => writerMocks.organization })) @@ -118,6 +128,103 @@ function expectSourceOrder(source: string, markers: string[]) { } describe('app onboarding progress analytics integration', () => { + it('automatically resumes the saved channel screen without opening a dialog in both flows', async () => { + const previousUser = writerMocks.main.user + const previousAuthGeneration = writerMocks.main.authGeneration + const previousRouteQuery = writerMocks.route.query + const previousOrganization = writerMocks.organization.currentOrganization + const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia') + const attemptId = '7e64f484-4171-47b6-86f7-0ef5d49e0ef8' + const previousRunId = 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276' + Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false })) }) + + try { + for (const [preOrg, useDirectLink, stage] of [ + [true, false, 'channel-create'], + [true, true, 'channel-self-assign'], + [false, true, 'channel-console-assign'], + ] as const) { + const appId = `com.example.resume.${stage}` + writerMocks.route.query = useDirectLink ? { resume: appId, step: preOrg ? 'setup' : 'install' } : {} + writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' } + writerMocks.main.user = { + id: 'user-bento-retry', + image_url: 'avatar.png', + onboarding: { + app_id: appId, + final_step: preOrg ? 'setup' : 'install', + flow: preOrg ? 'pre_org' : 'existing_org', + last_run_id: previousRunId, + onboarding_attempt_id: attemptId, + setup_stage: stage, + status: 'in_progress', + step: 'channel', + updated_at: '2026-09-21T00:00:00.000Z', + }, + } + writerMocks.loadApp.mockResolvedValue({ + data: { + android_store_url: null, + app_id: appId, + existing_app: true, + icon_url: null, + ios_store_url: null, + name: 'Test App', + onboarding: { setup: { todo_list_version: 3, steps: {} } }, + owner_org: 'test-org', + }, + error: null, + }) + writerMocks.replaceUserOnboardingIfUnchanged.mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({ + data: { ...writerMocks.main.user, onboarding }, + error: null, + })) + writerMocks.dialog.openDialog.mockClear() + vi.mocked(sendOnboardingEvent).mockClear() + const container = document.createElement('div') + const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg }) + app.config.warnHandler = () => undefined + try { + app.mount(container) + await vi.waitFor(() => expect(container.querySelector(`[data-test="resumed-${stage}"]`), `Expected ${stage} in ${preOrg ? 'pre_org' : 'existing_org'} resume`).not.toBeNull()) + await vi.waitFor(() => expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel')).toBe(true)) + expect(writerMocks.dialog.openDialog).not.toHaveBeenCalled() + const skipped = vi.mocked(sendOnboardingEvent).mock.calls.filter(call => call[0] === 'onboarding_resume_dialog_skipped') + expect(skipped).toHaveLength(1) + expect(skipped[0]?.[1]).toMatchObject({ + channel_stage: stage, + flow: preOrg ? 'pre_org' : 'existing_org', + onboarding_attempt_id: attemptId, + resumed_from_run_id: previousRunId, + saved_step: 'channel', + }) + const channelView = vi.mocked(sendOnboardingEvent).mock.calls.find(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel') + expect(channelView?.[1]).toMatchObject({ + onboarding_attempt_id: attemptId, + onboarding_run_id: skipped[0]?.[1]?.onboarding_run_id, + resumed: true, + }) + expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_resume_dialog_viewed')).toBe(false) + } + finally { + app.unmount() + await new Promise(resolve => setTimeout(resolve, 0)) + } + writerMocks.main.authGeneration += 1 + } + } + finally { + writerMocks.main.user = previousUser + writerMocks.main.authGeneration = previousAuthGeneration + writerMocks.route.query = previousRouteQuery + writerMocks.organization.currentOrganization = previousOrganization + if (matchMediaDescriptor) + Object.defineProperty(window, 'matchMedia', matchMediaDescriptor) + else + Reflect.deleteProperty(window, 'matchMedia') + } + }) + it('records a final setup view only after the saved setup screen renders', async () => { const previousUser = writerMocks.main.user const previousRouteQuery = writerMocks.route.query diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts index c12da125e6..427dccb27f 100644 --- a/tests/onboarding-progress-analytics.unit.test.ts +++ b/tests/onboarding-progress-analytics.unit.test.ts @@ -98,6 +98,50 @@ describe('onboarding progress analytics', () => { }) }) + it.concurrent.each(['pre_org', 'existing_org'] as const)('records an automatic %s channel resume without a dialog or duplicate decision', (flow) => { + const capture = vi.fn() + const ids = [ATTEMPT_A2, RUN_R2_UUID] + const identity = createOnboardingTelemetryIdentity({ + capture, + flow, + idFactory: () => ids.shift()!, + supaHost: 'https://supabase.capgo.test', + }) + identity.prepareResumeCandidate({ + lastRunId: RUN_R1, + onboardingAttemptId: ATTEMPT_A1, + savedStep: 'channel', + steps: ['app_name', 'channel', flow === 'pre_org' ? 'setup' : 'install'], + }) + + identity.recordResumeDialogSkipped('channel-create') + identity.recordResumeDialogSkipped('channel-create') + identity.recordResumeDialogViewed() + identity.recordResumeContinued() + + expect(capture.mock.calls).toEqual([[ + 'onboarding_resume_dialog_skipped', + 'https://supabase.capgo.test', + { + channel_stage: 'channel-create', + flow, + initial_onboarding_attempt_id: ATTEMPT_A2, + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R2, + onboarding_version: ONBOARDING_ANALYTICS_VERSION, + resume_onboarding_attempt_id: ATTEMPT_A1, + resumed_from_run_id: RUN_R1, + saved_step: 'channel', + step_index: 1, + total_steps: 3, + }, + ]]) + expect(identity.getProgressMetadata()).toEqual({ + lastRunId: RUN_R2, + onboardingAttemptId: ATTEMPT_A1, + }) + }) + it.concurrent('keeps the fresh attempt when restarting saved progress', () => { const capture = vi.fn() const ids = [ATTEMPT_A2, RUN_R2_UUID] From a43d5c6683e6f9c243e44826cd8fda11e5d66526 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Fri, 25 Sep 2026 17:52:35 +0200 Subject: [PATCH 5/5] fix(onboarding): preserve todo list channel exclusion --- playwright/e2e/onboarding-setup.spec.ts | 10 +- playwright/fixtures/onboarding-setup.ts | 4 + .../dashboard/AppOnboardingFlow.vue | 72 +++++-- src/utils/onboardingABTests.ts | 6 +- ...0260925160725_onboarding_channel_step.sql} | 0 ...boarding-progress-integration.unit.test.ts | 181 ++++++++++-------- tests/app-onboarding-v3.unit.test.ts | 4 +- tests/onboarding-ab-tests.unit.test.ts | 50 ++++- 8 files changed, 219 insertions(+), 108 deletions(-) rename supabase/migrations/{20260921155716_onboarding_channel_step.sql => 20260925160725_onboarding_channel_step.sql} (100%) diff --git a/playwright/e2e/onboarding-setup.spec.ts b/playwright/e2e/onboarding-setup.spec.ts index 75c8e319ec..ea4e966ab9 100644 --- a/playwright/e2e/onboarding-setup.spec.ts +++ b/playwright/e2e/onboarding-setup.spec.ts @@ -614,12 +614,18 @@ test.describe('Dashboard exploration and returning to v3 setup', () => { const appId = 'com.example.onboarding-preview' const setup = `${fixture}?view=navigation&resume=${appId}&step=setup` - test('routes legacy saved setup through channel creation first', async ({ page }) => { - await page.goto(`${setup}&legacyChannel=1`) + test('routes legacy saved setup through channel creation when the channel treatment is enabled', async ({ page }) => { + await page.goto(`${setup}&legacyChannel=1&channelTreatment=1`) await expect(page.locator('[data-test="channel-default-routing-animation"]')).toBeVisible() await expect(page.locator('[data-test="onboarding-setup-cli"]')).toHaveCount(0) }) + test('lets the new todo list override the channel treatment for legacy saved setup', async ({ page }) => { + await page.goto(`${setup}&legacyChannel=1&channelTreatment=1&todoListTreatment=1`) + await expect(page.locator('[data-test="onboarding-setup-cli"]')).toBeVisible() + await expect(page.locator('[data-test="channel-default-routing-animation"]')).toHaveCount(0) + }) + test('lands on the app dashboard, prompts on refresh, and returns to fullscreen setup', async ({ page }) => { await page.goto(setup) await expect(page.locator('[data-test="onboarding-setup-cli"]')).toBeVisible() diff --git a/playwright/fixtures/onboarding-setup.ts b/playwright/fixtures/onboarding-setup.ts index a44b7ce1ef..8bc66e3588 100644 --- a/playwright/fixtures/onboarding-setup.ts +++ b/playwright/fixtures/onboarding-setup.ts @@ -54,6 +54,10 @@ const events: string[] = [] const channelEvents: Array<{ event: OnboardingChannelEvent, properties: OnboardingChannelEventProperties }> = [] const preview = { state, events, channelEvents, appId: ref(previewAppId), command: ref('npx @capgo/cli@latest i [API_KEY]'), hiding: ref(false), selectedOrgId: ref('') } const previewUserOnboarding = { + abtests: { + ...(params.get('channelTreatment') === '1' ? { new_channel: { assigned_at: '2026-09-25T00:00:00.000Z', branch: 'A' } } : {}), + ...(params.get('todoListTreatment') === '1' ? { ota_todo_list_v3: { assigned_at: '2026-09-25T00:00:00.000Z', branch: 'A' } } : {}), + }, intent: assignment ? 'builder' : 'ota', status: 'in_progress', step: 'setup', diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 50e83a548d..168cb314b1 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -69,6 +69,7 @@ import { isValidAppId } from '~/utils/appId' import { shouldSkipOnboardingResume } from '~/utils/appOnboardingProgress' import { useBeforeUnloadWarning } from '~/utils/beforeUnloadWarning' import { + hasNewChannelTreatment, hasWebNativeDevelopmentEnvironmentTreatment, parseOnboardingABTestAssignments, reconcileOnboardingABTestAssignments, @@ -149,6 +150,7 @@ const onboardingForABTests = computed(() => { const config = getLocalConfig() const webNativePublishIntentTreatment = computed(() => shouldShowWebNativePublishIntent(onboardingForABTests.value)) const webNativeDevelopmentEnvironmentTreatment = computed(() => hasWebNativeDevelopmentEnvironmentTreatment(onboardingForABTests.value)) +const newChannelTreatment = computed(() => hasNewChannelTreatment(onboardingForABTests.value)) const APPLE_LOOKUP_TIMEOUT_MS = 5_000 const STORE_ICON_FETCH_TIMEOUT_MS = 10_000 const ONBOARDING_AB_TEST_WAIT_TIMEOUT_MS = 3_000 @@ -530,19 +532,22 @@ function createAiHelpPrompt() { }, promptIntent) } const appOnboardingSteps = computed>(() => { + const channelStep: Array<{ id: OnboardingFlowStep, label: string }> = newChannelTreatment.value + ? [{ id: 'channel', label: t('unified-onboarding-step-channel') }] + : [] if (props.preOrg) { return [ { id: 'intent', label: t('unified-onboarding-step-intent') }, { id: 'details', label: t('app-onboarding-step-details') }, { id: 'organization', label: t('unified-onboarding-step-organization') }, - { id: 'channel', label: t('unified-onboarding-step-channel') }, + ...channelStep, { id: 'setup', label: t('unified-onboarding-step-setup') }, ] } return [ { id: 'details', label: t('app-onboarding-step-details') }, { id: 'choice', label: t('app-onboarding-step-choice') }, - { id: 'channel', label: t('unified-onboarding-step-channel') }, + ...channelStep, { id: finalOnboardingStep.value, label: t(finalOnboardingStep.value === 'setup' ? 'unified-onboarding-step-setup' : 'app-onboarding-step-install') }, ] }) @@ -927,8 +932,11 @@ function applyOnboardingProgress(progress: ReturnType step.id) + if (savedStep !== 'channel' || steps.includes('channel')) + return steps + + // A todo-list treatment can disable channel after it was already persisted. + // Keep the saved channel in resume telemetry so its step index stays valid. + const finalStepIndex = steps.findIndex(step => step === 'setup' || step === 'install') + steps.splice(finalStepIndex < 0 ? steps.length : finalStepIndex, 0, 'channel') + return steps +} + function recordSkippedChannelResumeDialog(saved: UserOnboardingProgress | null) { const flow = props.preOrg ? 'pre_org' : 'existing_org' if ( @@ -980,8 +1004,8 @@ function recordSkippedChannelResumeDialog(saved: UserOnboardingProgress | null) || saved.flow !== flow || resumableOnboardingFlowStep(saved, flow) !== 'channel' || saved.app_id !== createdApp.value?.app_id - || flowStep.value !== 'channel' - || setupStage.value === 'cli' + || !saved.setup_stage + || saved.setup_stage === 'cli' ) { return false } @@ -990,9 +1014,9 @@ function recordSkippedChannelResumeDialog(saved: UserOnboardingProgress | null) onboardingAttemptId: saved.onboarding_attempt_id, lastRunId: saved.last_run_id, savedStep: 'channel', - steps: appOnboardingSteps.value.map(step => step.id), + steps: resumeCandidateSteps('channel'), }) - onboardingTelemetry.recordResumeDialogSkipped(setupStage.value) + onboardingTelemetry.recordResumeDialogSkipped(saved.setup_stage) return true } @@ -1012,14 +1036,23 @@ async function maybeResumeSavedOnboarding() { } const resumableStep = resumableOnboardingFlowStep(saved, flow) - if (resumableStep === 'channel' && saved.app_id && await loadResumeApp(saved.app_id) && recordSkippedChannelResumeDialog(saved)) - return true + if (resumableStep === 'channel' && saved.app_id) { + if (await loadResumeApp(saved.app_id)) { + recordSkippedChannelResumeDialog(saved) + return true + } + resetOnboardingForm() + if (props.preOrg) + applyDefaultPreOrgDetails() + showWelcomeOnDesktop() + return false + } onboardingTelemetry.prepareResumeCandidate({ onboardingAttemptId: saved.onboarding_attempt_id, lastRunId: saved.last_run_id, savedStep: resumableStep, - steps: appOnboardingSteps.value.map(step => step.id), + steps: resumeCandidateSteps(resumableStep), }) dialogStore.openDialog({ title: t('onboarding-resume-title'), @@ -1260,15 +1293,15 @@ async function loadResumeApp(appId = resumeAppId.value) { localIconPreview.value = getImmediateImageUrl(data.icon_url) || '' void loadResumeIconPreview(data.icon_url, data.app_id, iconLoadRun) finalOnboardingStep.value = props.preOrg || resumeStep.value === 'setup' || (resumeStep.value !== 'choice' && savedProgress?.app_id === data.app_id && (savedProgress?.final_step === 'setup' || savedProgress?.step === 'setup')) ? 'setup' : 'install' + const resumeFinalStep = !newChannelTreatment.value + || Boolean(savedProgress && savedProgress.app_id === data.app_id && savedProgress.final_step && savedProgress.setup_stage === 'cli') if (finalOnboardingStep.value === 'setup') { - flowStep.value = savedProgress && savedProgress.app_id === data.app_id && savedProgress.final_step && savedProgress.setup_stage === 'cli' - ? 'setup' - : 'channel' + flowStep.value = resumeFinalStep ? 'setup' : 'channel' if (!savedProgress?.intent) hydrateIntentFromCurrentOrg() } else { - flowStep.value = resumeStep.value === 'choice' ? 'choice' : savedProgress && savedProgress.app_id === data.app_id && savedProgress.final_step && savedProgress.setup_stage === 'cli' ? 'install' : 'channel' + flowStep.value = resumeStep.value === 'choice' ? 'choice' : resumeFinalStep ? 'install' : 'channel' } return true } @@ -2054,7 +2087,7 @@ async function createOrganizationAndApp() { } async function completePreOrgAppCreation(organizationId: string, shouldInvite: boolean) { - await createAppRecord({ nextStep: shouldInvite ? 'organization' : 'channel' }) + await createAppRecord({ nextStep: shouldInvite ? 'organization' : nextStepAfterChannelEligibility() }) if (!createdApp.value) return @@ -2093,12 +2126,14 @@ function continueFromOrganizationInvite(invitationCount: number) { }) showOrganizationInvite.value = false setupStage.value = resolveSetupStage() - completeAndViewStep('channel', { appId: createdApp.value.app_id }) + completeAndViewStep(nextStepAfterChannelEligibility(), { appId: createdApp.value.app_id }) } function resolveSetupStage( progress = parseUserOnboardingProgress(main.user?.onboarding), ): SetupStage { + if (!newChannelTreatment.value) + return 'cli' if (!progress || progress.app_id !== createdApp.value?.app_id || !progress.setup_stage || progress.setup_stage === 'cli') return 'channel-routing' return progress.setup_stage @@ -2468,7 +2503,7 @@ function goToInstallStep() { isCliCommandVisible.value = false setupStage.value = resolveSetupStage() startApiKeyLoading() - completeAndViewStep('channel', { + completeAndViewStep(nextStepAfterChannelEligibility(), { appId: createdApp.value.app_id, }) } @@ -2565,6 +2600,7 @@ onMounted(async () => { void refreshOnboardingABTests() if (resumeAppId.value) { await organizationStore.awaitInitialLoad() + await waitForOnboardingABTests() const resumed = await loadResumeApp() if (resumed) { resumedFlow = true @@ -3519,7 +3555,7 @@ defineExpose({ @channel-analytics="trackChannelEvent" /> -
+
({ })) vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } })) vi.mock('../src/components/dashboard/ChannelDefaultRoutingOnboarding.vue', () => ({ - default: { template: '
' }, + default: { template: '
' }, })) vi.mock('../src/components/dashboard/ChannelSelfAssignOnboarding.vue', () => ({ default: { template: '
' }, @@ -133,6 +133,7 @@ describe('app onboarding progress analytics integration', () => { const previousAuthGeneration = writerMocks.main.authGeneration const previousRouteQuery = writerMocks.route.query const previousOrganization = writerMocks.organization.currentOrganization + const previousAssignments = writerMocks.abTestAssignments const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia') const attemptId = '7e64f484-4171-47b6-86f7-0ef5d49e0ef8' const previousRunId = 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276' @@ -145,12 +146,15 @@ describe('app onboarding progress analytics integration', () => { [false, true, 'channel-console-assign'], ] as const) { const appId = `com.example.resume.${stage}` + const abtests = { new_channel: { assigned_at: '2026-09-21T00:00:00.000Z', branch: 'A' } } + writerMocks.abTestAssignments = abtests writerMocks.route.query = useDirectLink ? { resume: appId, step: preOrg ? 'setup' : 'install' } : {} writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' } writerMocks.main.user = { id: 'user-bento-retry', image_url: 'avatar.png', onboarding: { + abtests, app_id: appId, final_step: preOrg ? 'setup' : 'install', flow: preOrg ? 'pre_org' : 'existing_org', @@ -218,6 +222,7 @@ describe('app onboarding progress analytics integration', () => { writerMocks.main.authGeneration = previousAuthGeneration writerMocks.route.query = previousRouteQuery writerMocks.organization.currentOrganization = previousOrganization + writerMocks.abTestAssignments = previousAssignments if (matchMediaDescriptor) Object.defineProperty(window, 'matchMedia', matchMediaDescriptor) else @@ -285,7 +290,7 @@ describe('app onboarding progress analytics integration', () => { } }) - it('resumes channel before setup for either todo-list version, flow, and assignment', async () => { + it('uses the explicit todo-list override when resuming either onboarding flow', async () => { const previousUser = writerMocks.main.user const previousAuthGeneration = writerMocks.main.authGeneration const previousRouteQuery = writerMocks.route.query @@ -299,60 +304,70 @@ describe('app onboarding progress analytics integration', () => { try { for (const preOrg of [true, false]) { - for (const todoListVersion of [2, 3, 4]) { - for (const resumeKind of ['channel', 'legacy-final'] as const) { - const appId = `com.example.channel.${preOrg ? 'pre' : 'existing'}.${todoListVersion}.${resumeKind}` - writerMocks.route.query = { resume: appId, step: preOrg ? 'setup' : 'install' } - writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' } - writerMocks.abTestAssignments = { new_channel: { assigned_at: '2026-09-21T00:00:00.000Z', branch: todoListVersion === 2 ? 'B' : 'A' } } - writerMocks.main.user = { - id: 'user-bento-retry', - image_url: 'avatar.png', - onboarding: { - app_id: appId, - final_step: resumeKind === 'channel' ? (preOrg ? 'setup' : 'install') : undefined, - flow: preOrg ? 'pre_org' : 'existing_org', - setup_stage: resumeKind === 'channel' ? 'channel-routing' : 'cli', - status: 'in_progress', - step: resumeKind === 'channel' ? 'channel' : preOrg ? 'setup' : 'install', - updated_at: '2026-09-21T00:00:00.000Z', - }, - } - writerMocks.loadApp.mockResolvedValue({ - data: { - android_store_url: null, - app_id: appId, - existing_app: true, - icon_url: null, - ios_store_url: null, - name: 'Test App', - onboarding: { setup: { todo_list_version: todoListVersion, ota_todo_list_version: todoListVersion === 4 ? '1' : undefined, steps: {} } }, - owner_org: 'test-org', - }, - error: null, - }) - writerMocks.replaceUserOnboardingIfUnchanged.mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({ - data: { ...writerMocks.main.user, onboarding }, - error: null, - })) - vi.mocked(sendOnboardingEvent).mockClear() - const container = document.createElement('div') - const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg }) - app.config.warnHandler = () => undefined - try { - app.mount(container) - await vi.waitFor(() => expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel')).toBe(true)) - const viewed = vi.mocked(sendOnboardingEvent).mock.calls.filter(call => call[0] === 'onboarding_step_viewed') - expect(viewed.some(call => call[1]?.step === 'setup' || call[1]?.step === 'install')).toBe(false) - expect(container.querySelector('[data-test="onboarding-setup-cli"]')).toBeNull() - expect(container.querySelector('[data-test="onboarding-install-cli"]')).toBeNull() - } - finally { - app.unmount() - } - - writerMocks.main.authGeneration += 1 + for (const [channelBranch, todoBranch, expectsChannel] of [ + ['A', undefined, true], + ['A', 'A', false], + ['A', 'B', true], + ['B', 'A', false], + ] as const) { + const appId = `com.example.channel.${preOrg ? 'pre' : 'existing'}.${channelBranch}.${todoBranch ?? 'none'}` + const abtests = { + new_channel: { assigned_at: '2026-09-21T00:00:00.000Z', branch: channelBranch }, + ...(todoBranch ? { ota_todo_list_v3: { assigned_at: '2026-09-21T00:00:00.000Z', branch: todoBranch } } : {}), } + writerMocks.route.query = { resume: appId, step: preOrg ? 'setup' : 'install' } + writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' } + writerMocks.abTestAssignments = abtests + writerMocks.main.user = { + id: 'user-bento-retry', + image_url: 'avatar.png', + onboarding: { + abtests, + app_id: appId, + final_step: preOrg ? 'setup' : 'install', + flow: preOrg ? 'pre_org' : 'existing_org', + setup_stage: 'channel-routing', + status: 'in_progress', + step: 'channel', + updated_at: '2026-09-21T00:00:00.000Z', + }, + } + writerMocks.loadApp.mockResolvedValue({ + data: { + android_store_url: null, + app_id: appId, + existing_app: true, + icon_url: null, + ios_store_url: null, + name: 'Test App', + onboarding: { setup: { todo_list_version: todoBranch === 'A' ? 4 : 2, ota_todo_list_version: todoBranch === 'A' ? '1' : undefined, steps: {} } }, + owner_org: 'test-org', + }, + error: null, + }) + writerMocks.replaceUserOnboardingIfUnchanged.mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({ + data: { ...writerMocks.main.user, onboarding }, + error: null, + })) + vi.mocked(sendOnboardingEvent).mockClear() + const container = document.createElement('div') + const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg }) + app.config.warnHandler = () => undefined + try { + app.mount(container) + await vi.waitFor(() => expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === (expectsChannel ? 'channel' : preOrg ? 'setup' : 'install'))).toBe(true)) + const viewed = vi.mocked(sendOnboardingEvent).mock.calls.filter(call => call[0] === 'onboarding_step_viewed') + expect(viewed.some(call => call[1]?.step === 'channel')).toBe(expectsChannel) + expect(viewed.some(call => call[1]?.step === 'setup' || call[1]?.step === 'install')).toBe(!expectsChannel) + expect(container.querySelector('[data-test="resumed-channel-routing"]') !== null).toBe(expectsChannel) + expect(vi.mocked(sendOnboardingEvent).mock.calls.filter(call => call[0] === 'onboarding_resume_dialog_skipped')).toHaveLength(1) + } + finally { + app.unmount() + await new Promise(resolve => setTimeout(resolve, 0)) + } + + writerMocks.main.authGeneration += 1 } } } @@ -600,10 +615,21 @@ describe('app onboarding progress analytics integration', () => { const resumeDialog = sourceBetween('async function maybeResumeSavedOnboarding()', 'function whiteCardToggleButtonClass(') expect(resumeDialog).toContain('onboardingTelemetry.prepareResumeCandidate({') + expect(resumeDialog).toContain('steps: resumeCandidateSteps(resumableStep)') expect(resumeDialog).toContain('onboardingTelemetry.recordResumeDialogViewed()') expect(resumeDialog).toContain('onboardingTelemetry.recordResumeContinued()') expect(resumeDialog).toContain('onboardingTelemetry.recordResumeRestarted()') expect(resumeDialog).not.toContain('.viewStep(') + expectSourceOrder(resumeDialog, [ + 'if (resumableStep === \'channel\' && saved.app_id)', + 'if (await loadResumeApp(saved.app_id))', + 'recordSkippedChannelResumeDialog(saved)', + 'return true', + 'resetOnboardingForm()', + 'showWelcomeOnDesktop()', + 'return false', + 'onboardingTelemetry.prepareResumeCandidate({', + ]) expectSourceOrder(resumeDialog, [ 'onboardingTelemetry.prepareResumeCandidate({', 'dialogStore.openDialog({', @@ -658,6 +684,7 @@ describe('app onboarding progress analytics integration', () => { 'if (props.preOrg)', 'if (resumeAppId.value)', 'await organizationStore.awaitInitialLoad()', + 'await waitForOnboardingABTests()', 'const resumed = await loadResumeApp()', 'resumedFlow = true', 'startApiKeyLoading()', @@ -812,27 +839,20 @@ describe('app onboarding progress analytics integration', () => { expect(onboardingSource).toContain(`sendOnboardingEvent('onboarding_intent_selected', {`) }) - it.concurrent('keeps Maker+ invitations inside the organization progress step before channel', () => { - expect(onboardingSource).toContain(`createAppRecord({ nextStep: shouldInvite ? 'organization' : 'channel' })`) + it.concurrent('keeps Maker+ invitations inside the organization progress step before the eligible next step', () => { + expect(onboardingSource).toContain(`createAppRecord({ nextStep: shouldInvite ? 'organization' : nextStepAfterChannelEligibility() })`) expect(onboardingSource).toContain(`trackOrganizationEvent('onboarding_organization_invite_viewed')`) - expect(onboardingSource).toContain(`completeAndViewStep('channel', { appId: createdApp.value.app_id })`) + expect(onboardingSource).toContain(`completeAndViewStep(nextStepAfterChannelEligibility(), { appId: createdApp.value.app_id })`) }) - it.concurrent('routes both onboarding flows through channel before final setup', () => { + it.concurrent('routes both flows through channel only when its effective treatment is enabled', () => { const preOrgSteps = sourceBetween('const appOnboardingSteps = computed', 'const stepperStepId = computed') - expectSourceOrder(preOrgSteps, [ - '{ id: \'organization\'', - '{ id: \'channel\'', - '{ id: \'setup\'', - ]) - expectSourceOrder(preOrgSteps, [ - '{ id: \'choice\'', - '{ id: \'channel\'', - '{ id: finalOnboardingStep.value', - ]) - expect(onboardingSource).toContain('createAppRecord({ nextStep: shouldInvite ? \'organization\' : \'channel\' })') - expect(onboardingSource).toContain('completeAndViewStep(\'channel\', { appId: createdApp.value.app_id })') - expect(onboardingSource).toContain('completeAndViewStep(\'channel\', {\n appId: createdApp.value.app_id,') + expect(preOrgSteps).toContain('const channelStep: Array<{ id: OnboardingFlowStep, label: string }> = newChannelTreatment.value') + expect(preOrgSteps.match(/\.\.\.channelStep/g)).toHaveLength(2) + expect(onboardingSource).toContain('return newChannelTreatment.value ? \'channel\' : finalOnboardingStep.value') + expect(onboardingSource).toContain('createAppRecord({ nextStep: shouldInvite ? \'organization\' : nextStepAfterChannelEligibility() })') + expect(onboardingSource).toContain('completeAndViewStep(nextStepAfterChannelEligibility(), { appId: createdApp.value.app_id })') + expect(onboardingSource).toContain('completeAndViewStep(nextStepAfterChannelEligibility(), {\n appId: createdApp.value.app_id,') const channel = sourceBetween('function continueFromChannelDefaultRouting()', 'function onTechnicalInviteOpened()') expectSourceOrder(channel, [ @@ -844,20 +864,29 @@ describe('app onboarding progress analytics integration', () => { expect(channel).toContain('if (flowStep.value !== \'channel\' || setupStage.value !== \'channel-create\' || !createdApp.value)') expect(channel).toContain('trackChannelStageTransition(finalOnboardingStep.value, \'forward\')') - const renderedChannel = sourceBetween('flowStep === \'channel\' && createdApp', 'flowStep === \'setup\' && createdApp') + const renderedChannel = sourceBetween('flowStep === \'channel\' && newChannelTreatment && createdApp', 'flowStep === \'setup\' && createdApp') expectSourceOrder(renderedChannel, [ ' (flowStep.value === \'setup\' || flowStep.value === \'install\') && usesOtaTodoList.value)') + expect(renderedChannel).toContain('newChannelTreatment') + expect(onboardingSource).toContain('const showSetupChecklist = computed(() => (flowStep.value === \'setup\' || flowStep.value === \'install\') && usesOtaTodoList.value && !showBuilderChecklist.value)') expect(onboardingSource).toContain('progressTracker?.trackStepEvent(name, \'channel\', {') expect(onboardingSource).toContain('void viewFinalStepWhenRendered(nextStep, previousAnalyticsStep)') expect(onboardingSource).toContain('if (!isLoading.value && createdApp.value && flowStep.value === step)') }) + it.concurrent('keeps a persisted channel position in resume telemetry after the todo list disables channel', () => { + const resumeSteps = sourceBetween('function resumeCandidateSteps(', 'function recordSkippedChannelResumeDialog(') + const skippedResume = sourceBetween('function recordSkippedChannelResumeDialog(', 'async function maybeResumeSavedOnboarding()') + expect(resumeSteps).toContain('if (savedStep !== \'channel\' || steps.includes(\'channel\'))') + expect(resumeSteps).toContain('steps.findIndex(step => step === \'setup\' || step === \'install\')') + expect(resumeSteps).toContain('steps.splice(finalStepIndex < 0 ? steps.length : finalStepIndex, 0, \'channel\')') + expect(skippedResume).toContain('steps: resumeCandidateSteps(\'channel\')') + }) + it.concurrent('keeps the unload warning scoped to unfinished pre-org onboarding', () => { expect(onboardingSource).toContain('useBeforeUnloadWarning(Boolean(props.preOrg))') const creation = sourceBetween('async function createOrganizationAndApp()', 'async function createAppRecord(') @@ -889,7 +918,7 @@ describe('app onboarding progress analytics integration', () => { expect(appCreation).toContain('completeAndViewStep(nextStep, completionProperties)') const realSetupChoice = sourceBetween('function goToInstallStep()', 'function openDashboard()') - expect(realSetupChoice).toContain(`completeAndViewStep('channel', {`) + expect(realSetupChoice).toContain(`completeAndViewStep(nextStepAfterChannelEligibility(), {`) expect(realSetupChoice).toContain('appId: createdApp.value.app_id') }) diff --git a/tests/app-onboarding-v3.unit.test.ts b/tests/app-onboarding-v3.unit.test.ts index f2ce3a3210..689459eb32 100644 --- a/tests/app-onboarding-v3.unit.test.ts +++ b/tests/app-onboarding-v3.unit.test.ts @@ -248,7 +248,7 @@ describe('pre-organization onboarding v3', () => { expect(onboardingSource).toContain('website: websitePreview.value?.website') expect(onboardingSource).toContain('selectedStop.planName !== \'Solo\'') expect(onboardingSource).toContain(' { @@ -302,7 +302,7 @@ describe('pre-organization onboarding v3', () => { const organizationCreation = sliceBetween(onboardingSource, 'async function createOrganizationAndApp()', 'async function createAppRecord(') expect(organizationCreation).toContain('preOrgCreatedOrganizationId.value = data.id') expect(organizationCreation).toContain('await completePreOrgAppCreation(data.id, shouldInvite)') - expect(organizationCreation).toContain('await createAppRecord({ nextStep: shouldInvite ? \'organization\' : \'channel\' })') + expect(organizationCreation).toContain('await createAppRecord({ nextStep: shouldInvite ? \'organization\' : nextStepAfterChannelEligibility() })') const appCreation = sliceBetween(onboardingSource, 'async function createAppRecord(', 'async function seedDemoData()') expect(appCreation).toContain('returnToAppIdAfterConflict()') diff --git a/tests/onboarding-ab-tests.unit.test.ts b/tests/onboarding-ab-tests.unit.test.ts index 34a12624d4..1f8e09544e 100644 --- a/tests/onboarding-ab-tests.unit.test.ts +++ b/tests/onboarding-ab-tests.unit.test.ts @@ -5,6 +5,7 @@ import { hasWebNativeDevelopmentEnvironmentTreatment, hasWebNativePublishIntentTreatment, NEW_CHANNEL_AB_TEST, + OTA_TODO_LIST_V3_AB_TEST, parseOnboardingABTestAssignments, reconcileOnboardingABTestAssignments, resolveOnboardingAnalyticsVersion, @@ -155,16 +156,47 @@ describe('webNativeApp onboarding A/B tests', () => { }) }) -describe('independent checklist version experiment', () => { - it.concurrent('does not change PostHog wizard version when the todo-list flag is added', () => { - for (const intent of ['ota', 'both', 'builder'] as const) { - for (const channelBranch of ['A', 'B'] as const) { - const existing = { abtests: { [NEW_CHANNEL_AB_TEST]: { branch: channelBranch, assigned_at: '2026-09-16T00:00:00Z' } } } - const treatment = { abtests: { ...existing.abtests, ota_todo_list_v3: { branch: 'A', assigned_at: '2026-09-16T00:00:00Z' } } } - if (channelBranch === 'A' && intent === 'ota') - expect(resolveOnboardingAnalyticsVersion(existing, intent)).toBe('5.E') - expect(resolveOnboardingAnalyticsVersion(treatment, intent)).toBe(resolveOnboardingAnalyticsVersion(existing, intent)) +describe('channel and checklist experiment interaction', () => { + it.concurrent('explicitly disables the channel treatment when the new todo list is assigned', () => { + const channelTreatment = onboardingWithNewChannel('B', 'D', 'A') + const combinedTreatment = { + ...channelTreatment, + abtests: { + ...channelTreatment.abtests, + [OTA_TODO_LIST_V3_AB_TEST]: { branch: 'A', assigned_at: '2026-09-16T00:00:00Z' }, + }, + } + const todoControl = { + ...channelTreatment, + abtests: { + ...channelTreatment.abtests, + [OTA_TODO_LIST_V3_AB_TEST]: { branch: 'B', assigned_at: '2026-09-16T00:00:00Z' }, + }, + } + + expect(hasNewChannelTreatment(channelTreatment)).toBe(true) + expect(hasNewChannelTreatment(combinedTreatment)).toBe(false) + expect(hasNewChannelTreatment(todoControl)).toBe(true) + expect(resolveOnboardingAnalyticsVersion(channelTreatment, 'ota')).toBe('5.E') + expect(resolveOnboardingAnalyticsVersion(combinedTreatment, 'ota')).toBe(4) + expect(resolveOnboardingAnalyticsVersion(todoControl, 'ota')).toBe('5.E') + }) + + it.concurrent('falls back to the remaining active experiment analytics version', () => { + for (const [publishBranch, environmentBranch, expected] of [ + ['A', 'D', '5.A'], + ['B', 'C', '5.C'], + ] as const) { + const channelTreatment = onboardingWithNewChannel(publishBranch, environmentBranch, 'A') + const combinedTreatment = { + ...channelTreatment, + abtests: { + ...channelTreatment.abtests, + [OTA_TODO_LIST_V3_AB_TEST]: { branch: 'A', assigned_at: '2026-09-16T00:00:00Z' }, + }, } + + expect(resolveOnboardingAnalyticsVersion(combinedTreatment, 'ota')).toBe(expected) } }) })