diff --git a/playwright/e2e/onboarding-setup.spec.ts b/playwright/e2e/onboarding-setup.spec.ts index 9b40282507..ea4e966ab9 100644 --- a/playwright/e2e/onboarding-setup.spec.ts +++ b/playwright/e2e/onboarding-setup.spec.ts @@ -614,6 +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 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 7ff65861fe..8bc66e3588 100644 --- a/playwright/fixtures/onboarding-setup.ts +++ b/playwright/fixtures/onboarding-setup.ts @@ -53,6 +53,19 @@ 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 = { + 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', + 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 +153,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 +223,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) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 9afb5c352c..168cb314b1 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -16,9 +16,9 @@ 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, 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' @@ -161,10 +161,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 +211,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 +360,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 : '') || '') @@ -543,38 +532,28 @@ 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') }, + ...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: 'install', label: t('app-onboarding-step-install') }, + ...channelStep, + { id: finalOnboardingStep.value, label: t(finalOnboardingStep.value === 'setup' ? 'unified-onboarding-step-setup' : 'app-onboarding-step-install') }, ] }) const stepperStepId = 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 +615,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 +664,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 +675,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 +698,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 +745,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 +884,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 @@ -936,7 +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 ( + saved?.status !== 'in_progress' + || saved.flow !== flow + || resumableOnboardingFlowStep(saved, flow) !== 'channel' + || saved.app_id !== createdApp.value?.app_id + || !saved.setup_stage + || saved.setup_stage === 'cli' + ) { + return false + } + + onboardingTelemetry.prepareResumeCandidate({ + onboardingAttemptId: saved.onboarding_attempt_id, + lastRunId: saved.last_run_id, + savedStep: 'channel', + steps: resumeCandidateSteps('channel'), + }) + onboardingTelemetry.recordResumeDialogSkipped(saved.setup_stage) + return true +} + async function maybeResumeSavedOnboarding() { const flow = props.preOrg ? 'pre_org' : 'existing_org' const saved = parseUserOnboardingProgress(main.user?.onboarding) @@ -997,11 +1036,23 @@ async function maybeResumeSavedOnboarding() { } const resumableStep = resumableOnboardingFlowStep(saved, flow) + 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'), @@ -1032,6 +1083,12 @@ async function maybeResumeSavedOnboarding() { onboardingTelemetry.recordResumeContinued() applyOnboardingProgress(saved) + if (flowStep.value === 'channel' || flowStep.value === 'setup' || flowStep.value === 'install') { + if (!saved.app_id || !await loadResumeApp(saved.app_id)) { + resetOnboardingForm() + return false + } + } return true } @@ -1203,10 +1260,11 @@ function startApiKeyLoading() { }) } -async function loadResumeApp() { - if (!resumeAppId.value) +async function loadResumeApp(appId = resumeAppId.value) { + if (!appId) return false - const appOrganization = organizationStore.getOrgByAppId(resumeAppId.value) + await organizationStore.awaitInitialLoad() + const appOrganization = organizationStore.getOrgByAppId(appId) if (appOrganization && currentOrg.value?.gid !== appOrganization.gid) organizationStore.setCurrentOrganization(appOrganization.gid) if (!currentOrg.value?.gid) @@ -1216,7 +1274,7 @@ async function loadResumeApp() { .from('apps') .select() .eq('owner_org', currentOrg.value.gid) - .eq('app_id', resumeAppId.value) + .eq('app_id', appId) .single() if (error || !data) { @@ -1234,13 +1292,16 @@ async function loadResumeApp() { const iconLoadRun = ++resumeIconLoadRun localIconPreview.value = getImmediateImageUrl(data.icon_url) || '' void loadResumeIconPreview(data.icon_url, data.app_id, iconLoadRun) - if (props.preOrg || resumeStep.value === 'setup') { - flowStep.value = 'setup' + 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 = resumeFinalStep ? 'setup' : 'channel' if (!savedProgress?.intent) hydrateIntentFromCurrentOrg() } else { - flowStep.value = resumeStep.value === 'choice' ? 'choice' : 'install' + flowStep.value = resumeStep.value === 'choice' ? 'choice' : resumeFinalStep ? 'install' : 'channel' } return true } @@ -2026,7 +2087,7 @@ async function createOrganizationAndApp() { } async function completePreOrgAppCreation(organizationId: string, shouldInvite: boolean) { - await createAppRecord({ nextStep: shouldInvite ? 'organization' : 'setup' }) + await createAppRecord({ nextStep: shouldInvite ? 'organization' : nextStepAfterChannelEligibility() }) if (!createdApp.value) return @@ -2065,28 +2126,17 @@ function continueFromOrganizationInvite(invitationCount: number) { }) showOrganizationInvite.value = false setupStage.value = resolveSetupStage() - completeAndViewStep('setup', { appId: createdApp.value.app_id }) + completeAndViewStep(nextStepAfterChannelEligibility(), { appId: createdApp.value.app_id }) } function resolveSetupStage( progress = parseUserOnboardingProgress(main.user?.onboarding), ): SetupStage { - if (usesOtaTodoList.value || (!newChannelTreatment.value && !onboardingABTestsPending.value)) + if (!newChannelTreatment.value) return 'cli' - return progress?.setup_stage ?? 'channel-routing' -} - -function reconcileSetupStageWithChannelAssignment() { - if ( - !createdApp.value - || (flowStep.value !== 'setup' && flowStep.value !== 'install') - || onboardingABTestsPending.value - || newChannelTreatment.value - ) { - return - } - - setSetupStage('cli') + if (!progress || progress.app_id !== createdApp.value?.app_id || !progress.setup_stage || progress.setup_stage === 'cli') + return 'channel-routing' + return progress.setup_stage } function setSetupStage(nextStage: SetupStage) { @@ -2112,11 +2162,14 @@ function continueFromChannelConsoleAssign() { } function continueFromChannelCreate() { - trackChannelStageTransition('cli', 'forward') + if (flowStep.value !== 'channel' || setupStage.value !== 'channel-create' || !createdApp.value) + return + trackChannelStageTransition(finalOnboardingStep.value, 'forward') setSetupStage('cli') + completeAndViewStep(finalOnboardingStep.value, { appId: createdApp.value.app_id }) } -function trackChannelStageTransition(nextStage: OnboardingChannelStage | 'cli', direction: 'backward' | 'forward') { +function trackChannelStageTransition(nextStage: OnboardingChannelStage | 'setup' | 'install', direction: 'backward' | 'forward') { const currentStage = setupStage.value if (currentStage === 'cli') return @@ -2130,11 +2183,10 @@ function trackChannelStageTransition(nextStage: OnboardingChannelStage | 'cli', ) } -const previousSetupStage: Partial> = { +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 +2197,6 @@ function goBackFromSetupStage() { } } -watch(newChannelTreatment, () => { - if (!onboardingABTestsPending.value) - reconcileSetupStageWithChannelAssignment() -}) - function onTechnicalInviteOpened() { progressTracker?.trackStepEvent('onboarding_technical_invite_opened', 'setup') } @@ -2287,7 +2334,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 +2503,7 @@ function goToInstallStep() { isCliCommandVisible.value = false setupStage.value = resolveSetupStage() startApiKeyLoading() - completeAndViewStep('install', { + completeAndViewStep(nextStepAfterChannelEligibility(), { appId: createdApp.value.app_id, }) } @@ -2553,6 +2600,7 @@ onMounted(async () => { void refreshOnboardingABTests() if (resumeAppId.value) { await organizationStore.awaitInitialLoad() + await waitForOnboardingABTests() const resumed = await loadResumeApp() if (resumed) { resumedFlow = true @@ -2560,6 +2608,7 @@ onMounted(async () => { onboardingProgressPersistence.abort() return } + recordSkippedChannelResumeDialog(parseUserOnboardingProgress(main.user?.onboarding)) startApiKeyLoading() return } @@ -2588,6 +2637,8 @@ onMounted(async () => { onboardingProgressPersistence.abort() return } + if (resumed) + recordSkippedChannelResumeDialog(parseUserOnboardingProgress(main.user?.onboarding)) startApiKeyLoading() } @@ -2706,7 +2757,7 @@ defineExpose({ 'onboarding-flow-details-icon': flowStep === 'details' && appDetailsStep === 'icon', }" > -
+
@@ -3504,35 +3555,37 @@ defineExpose({ @channel-analytics="trackChannelEvent" /> -
+
+
-
+
+

{{ t('unified-onboarding-step-setup') }} @@ -3714,34 +3767,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 @@ -220,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 @@ -234,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) @@ -248,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/src/utils/userOnboardingProgress.ts b/src/utils/userOnboardingProgress.ts index ed0629fb8d..6abbd5cd2a 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' || !progress.final_step)) + 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/20260925160725_onboarding_channel_step.sql b/supabase/migrations/20260925160725_onboarding_channel_step.sql new file mode 100644 index 0000000000..2a518fda8b --- /dev/null +++ b/supabase/migrations/20260925160725_onboarding_channel_step.sql @@ -0,0 +1,59 @@ +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" ? '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 ( + ("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..d644e0dc37 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -5,11 +5,15 @@ import { URL as NodeUrl } from 'node:url' import { describe, expect, it, vi } from 'vitest' import { createApp } from 'vue' import AppOnboardingFlow from '../src/components/dashboard/AppOnboardingFlow.vue' - -const messages = JSON.parse(readFileSync(new NodeUrl('../messages/en.json', import.meta.url), 'utf8')) as Record +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' }, @@ -43,7 +47,16 @@ vi.mock('vue-router', () => ({ })) 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: '
' }, +})) +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(), @@ -85,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 })) @@ -119,6 +128,262 @@ 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 previousAssignments = writerMocks.abTestAssignments + 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}` + 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', + 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 + writerMocks.abTestAssignments = previousAssignments + 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 + 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('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 + 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 [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 + } + } + } + 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 +603,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,') @@ -350,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({', @@ -389,10 +665,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)', @@ -407,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()', @@ -561,127 +839,52 @@ 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 the eligible next step', () => { + expect(onboardingSource).toContain(`createAppRecord({ nextStep: shouldInvite ? 'organization' : nextStepAfterChannelEligibility() })`) expect(onboardingSource).toContain(`trackOrganizationEvent('onboarding_organization_invite_viewed')`) - expect(onboardingSource).toContain(`completeAndViewStep('setup', { appId: createdApp.value.app_id })`) + expect(onboardingSource).toContain(`completeAndViewStep(nextStepAfterChannelEligibility(), { 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 flows through channel only when its effective treatment is enabled', () => { + const preOrgSteps = sourceBetween('const appOnboardingSteps = computed', 'const stepperStepId = computed') + 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, [ + '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\' && newChannelTreatment && createdApp', 'flowStep === \'setup\' && createdApp') + expectSourceOrder(renderedChannel, [ + ' (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', () => { @@ -715,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('install', {`) + 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 d7f0af40dc..689459eb32 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\' : nextStepAfterChannelEligibility() })') 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-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) } }) }) 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/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] diff --git a/tests/user-onboarding-progress.unit.test.ts b/tests/user-onboarding-progress.unit.test.ts index ca2e51bb22..d1df41ae5e 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,37 @@ 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('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 49a499925a..e46c649d9c 100644 --- a/tests/users-onboarding-size.test.ts +++ b/tests/users-onboarding-size.test.ts @@ -57,4 +57,31 @@ describe('users.onboarding size constraint', () => { [userId, 'b'.repeat(65_522)], )).rejects.toMatchObject({ code: '23514' }) }) + + 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', 'final_step', 'setup') + 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' }) + + 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' }) + } + }) })