Ctrl K
ComunidadGuías…

tests/app-onboarding-progress-integration.unit.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// @vitest-environment happy-dom

import { describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import AppOnboardingFlow from '../src/components/dashboard/AppOnboardingFlow.vue'
import { sendOnboardingEvent } from '../src/services/onboardingTracking'

const writerMocks = vi.hoisted(() => ({
  abTestAssignments: {} as Record<string, unknown>,
  dialog: {
    lastButtonRole: null as string | null,
    onDialogDismiss: vi.fn(async () => undefined),
    openDialog: vi.fn(),
  },
  loadApp: vi.fn(),
  main: {
    auth: { id: 'user-bento-retry' },
    authGeneration: 1,
    awaitInitialLoad: vi.fn(async () => undefined),
    isAdmin: false,
    plans: [],
    user: {
      id: 'user-bento-retry',
      image_url: 'avatar.png',
      first_name: 'Example',
      last_name: 'User',
      onboarding: {},
    },
  },
  organization: {
    getOrgByAppId: vi.fn(),
    setCurrentOrganization: vi.fn(),
    awaitInitialLoad: vi.fn(async () => undefined),
    currentOrganization: null as { gid: string, name: string, onboarding?: unknown } | null,
    organizations: [],
    updateAppOnboarding: vi.fn(),
    upsertOrganizationApp: vi.fn(),
  },
  refreshUser: vi.fn(),
  replaceUserOnboardingIfUnchanged: vi.fn(),
  route: { query: {} as Record<string, string> },
  router: {
    push: vi.fn(),
    replace: vi.fn(async () => undefined),
  },
  sendOnboardingEvent: vi.fn(),
}))

vi.mock('vue-i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) }))
vi.mock('vue-router', () => ({
  useRoute: () => writerMocks.route,
  useRouter: () => writerMocks.router,
}))
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), success: vi.fn() } }))
vi.mock('../src/components/dashboard/ChannelDefaultRoutingOnboarding.vue', () => ({
  default: { template: '<div data-test="resumed-channel-routing" />' },
}))
vi.mock('../src/components/dashboard/ChannelSelfAssignOnboarding.vue', () => ({
  default: { template: '<div data-test="resumed-channel-self-assign" />' },
}))
vi.mock('../src/components/dashboard/ChannelConsoleAssignOnboarding.vue', () => ({
  default: { template: '<div data-test="resumed-channel-console-assign" />' },
}))
vi.mock('../src/components/dashboard/ChannelCreateOnboarding.vue', () => ({
  default: {
    emits: ['continue'],
    template: '<button type="button" data-test="resumed-channel-create" @click="$emit(\'continue\')" />',
  },
}))
vi.mock('~/services/apikeys', () => ({
  createDefaultApiKey: vi.fn(),
  findUsablePlainApiKey: vi.fn(async () => 'test-api-key'),
  shareInFlightApiKeyLoad: vi.fn(async (_key: unknown, load: () => Promise<unknown>) => load()),
}))
vi.mock('~/services/onboardingTracking', () => ({
  APP_ONBOARDING_READY_EVENT: 'app:onboarding_ready',
  sendOnboardingEvent: writerMocks.sendOnboardingEvent,
}))
vi.mock('~/services/capgoApi', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../src/services/capgoApi')>()
  return {
    ...actual,
    invokeCapgoApi: vi.fn(async () => ({ data: { assignments: writerMocks.abTestAssignments }, error: null })),
  }
})
vi.mock('~/services/console', () => ({
  getLocalConfig: () => ({ apiHost: 'https://api.capgo.app' }),
  isLocal: () => false,
  useConsole: () => {
    return {
      from: (table: string) => {
        const query = {
          update: (payload: { onboarding: unknown }) => ({
            eq: (userId: string) => ({
              filter: (_column: string, _operator: string, expected: string) => ({
                select: () => ({ maybeSingle: () => writerMocks.replaceUserOnboardingIfUnchanged(userId, JSON.parse(expected), payload.onboarding) }),
              }),
            }),
          }),
          eq: () => query,
          maybeSingle: writerMocks.refreshUser,
          select: () => query,
          single: table === 'apps' ? writerMocks.loadApp : vi.fn(),
        }
        return query
      },
      rpc: vi.fn(async () => ({ data: null, error: null })),
    }
  },
}))
vi.mock('~/services/userOnboardingWriteQueue', async (importOriginal) => {
  const actual = await importOriginal<typeof import('../src/services/userOnboardingWriteQueue')>()
  return {
    ...actual,
    replaceUserOnboardingIfUnchanged: writerMocks.replaceUserOnboardingIfUnchanged,
  }
})
vi.mock('~/stores/dashboardApps', () => ({ useDashboardAppsStore: () => ({ upsertApp: vi.fn() }) }))
vi.mock('~/stores/dialogv2', () => ({
  useDialogV2Store: () => writerMocks.dialog,
}))
vi.mock('~/stores/main', () => ({ useMainStore: () => writerMocks.main }))
vi.mock('~/stores/organization', () => ({ useOrganizationStore: () => writerMocks.organization }))

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, openedFromGettingStarted, 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 = {}
        writerMocks.router.replace.mockClear()
        writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' }
        writerMocks.main.user = {
          id: 'user-bento-retry',
          image_url: 'avatar.png',
          first_name: 'Example',
          last_name: 'User',
          onboarding: {
            profile_completed_at: '2026-10-20T10:00:00.000Z',
            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()
        if (!openedFromGettingStarted) {
          // The pre-org creation page forwards a saved setup resume to Getting started.
          vi.mocked(sendOnboardingEvent).mockClear()
          const creationContainer = document.createElement('div')
          const creationApp = createApp(AppOnboardingFlow, { onboarding: true, preOrg })
          creationApp.config.warnHandler = () => undefined
          try {
            creationApp.mount(creationContainer)
            await vi.waitFor(() => expect(writerMocks.router.replace).toHaveBeenCalledWith(`/app/${appId}/getting-started`))
            expect(creationContainer.querySelector(`[data-test="resumed-${stage}"]`)).toBeNull()
            expect(writerMocks.dialog.openDialog).not.toHaveBeenCalled()
            expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_resume_dialog_viewed')).toBe(false)
            expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed')).toBe(false)
          }
          finally {
            creationApp.unmount()
            await new Promise(resolve => setTimeout(resolve, 0))
          }
          writerMocks.main.authGeneration += 1
        }
        vi.mocked(sendOnboardingEvent).mockClear()
        const container = document.createElement('div')
        const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg, setupAppId: appId })
        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 = {}
    writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' }
    writerMocks.main.user = {
      id: 'user-bento-retry',
      image_url: 'avatar.png',
      first_name: 'Example',
      last_name: 'User',
      onboarding: {
        profile_completed_at: '2026-10-20T10:00:00.000Z',
        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, setupAppId: 'com.example.final' })
    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.each([true, false])('leaves Getting started for the app page when its app cannot load (preOrg=%s)', async (preOrg) => {
    const previousUser = writerMocks.main.user
    const previousOrganization = writerMocks.organization.currentOrganization
    const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia')
    writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' }
    // Saved setup for another app must not hijack Getting started for this one.
    writerMocks.main.user = {
      id: 'user-missing-app',
      image_url: 'avatar.png',
      first_name: 'Example',
      last_name: 'User',
      onboarding: {
        profile_completed_at: '2026-10-20T10:00:00.000Z',
        app_id: 'com.example.other',
        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: null, error: { message: 'not found' } })
    writerMocks.router.replace.mockClear()
    Object.defineProperty(window, 'matchMedia', { configurable: true, value: vi.fn(() => ({ matches: false })) })
    const container = document.createElement('div')
    const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg, setupAppId: 'com.example.missing' })
    app.config.warnHandler = () => undefined
    try {
      app.mount(container)
      await vi.waitFor(() => expect(writerMocks.router.replace).toHaveBeenCalledWith('/app/com.example.missing'))
      expect(writerMocks.router.replace).not.toHaveBeenCalledWith('/app/com.example.other/getting-started')
      expect(container.querySelector('[data-test="app-onboarding-name"]')).toBeNull()
    }
    finally {
      app.unmount()
      writerMocks.main.user = previousUser
      writerMocks.organization.currentOrganization = previousOrganization
      if (matchMediaDescriptor)
        Object.defineProperty(window, 'matchMedia', matchMediaDescriptor)
      else
        Reflect.deleteProperty(window, 'matchMedia')
    }
  })

  it('continues the creation attempt when Getting started receives the setup handoff', async () => {
    const previousUser = writerMocks.main.user
    const previousOrganization = writerMocks.organization.currentOrganization
    const previousAssignments = writerMocks.abTestAssignments
    const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia')
    const previousHistoryState: unknown = window.history.state
    const appId = 'com.example.handoff'
    const attemptId = '0c8f5d4e-7a1b-4c3d-9e2f-1a2b3c4d5e6f'
    const runId = 'ir_5f1e2d3c-4b5a-4968-8776-655443322110'
    const abtests = { new_channel: { assigned_at: '2026-09-21T00:00:00.000Z', branch: 'A' } }
    writerMocks.abTestAssignments = abtests
    writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' }
    writerMocks.main.user = {
      id: 'user-bento-retry',
      image_url: 'avatar.png',
      first_name: 'Example',
      last_name: 'User',
      onboarding: {
        profile_completed_at: '2026-10-20T10:00:00.000Z',
        abtests,
        app_id: appId,
        flow: 'existing_org',
        last_run_id: runId,
        onboarding_attempt_id: attemptId,
        setup_stage: 'channel-routing',
        status: 'in_progress',
        step: 'channel',
        updated_at: '2026-09-21T00:00:00.000Z',
      },
    }
    writerMocks.loadApp.mockReset()
    writerMocks.loadApp.mockResolvedValue({
      data: {
        android_store_url: null,
        app_id: appId,
        existing_app: true,
        icon_url: null,
        ios_store_url: null,
        name: 'Handoff App',
        onboarding: { setup: { todo_list_version: 3, steps: {} } },
        owner_org: 'test-org',
      },
      error: null,
    })
    writerMocks.replaceUserOnboardingIfUnchanged.mockReset()
    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 })) })
    window.history.replaceState({
      capgoOnboardingSetupHandoff: { appId, attemptId, flow: 'existing_org', previousStep: 'app_icon', runId },
      unrelated: 'kept',
    }, '')
    vi.mocked(sendOnboardingEvent).mockClear()
    writerMocks.dialog.openDialog.mockClear()
    const container = document.createElement('div')
    const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg: false, setupAppId: appId })
    app.config.warnHandler = () => undefined

    try {
      app.mount(container)
      await vi.waitFor(() => expect(container.querySelector('[data-test="resumed-channel-routing"]')).not.toBeNull())
      await vi.waitFor(() => expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel')).toBe(true))

      const channelView = vi.mocked(sendOnboardingEvent).mock.calls.find(call => call[0] === 'onboarding_step_viewed' && call[1]?.step === 'channel')
      expect(channelView?.[1]).toMatchObject({
        flow: 'existing_org',
        onboarding_attempt_id: attemptId,
        onboarding_run_id: runId,
        previous_step: 'app_icon',
        resumed: false,
      })
      expect(vi.mocked(sendOnboardingEvent).mock.calls.some(call => call[0] === 'onboarding_resume_dialog_skipped')).toBe(false)
      expect(writerMocks.dialog.openDialog).not.toHaveBeenCalled()
      // A reload of Getting started is a new visit, not the creation handoff.
      expect(window.history.state).toEqual({ unrelated: 'kept' })
    }
    finally {
      app.unmount()
      await new Promise(resolve => setTimeout(resolve, 0))
      window.history.replaceState(previousHistoryState, '')
      writerMocks.main.user = previousUser
      writerMocks.main.authGeneration += 1
      writerMocks.organization.currentOrganization = previousOrganization
      writerMocks.abTestAssignments = previousAssignments
      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 = {}
          writerMocks.organization.currentOrganization = { gid: 'test-org', name: 'Test Org' }
          writerMocks.abTestAssignments = abtests
          writerMocks.main.user = {
            id: 'user-bento-retry',
            image_url: 'avatar.png',
            first_name: 'Example',
            last_name: 'User',
            onboarding: {
              profile_completed_at: '2026-10-20T10:00:00.000Z',
              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, setupAppId: appId })
          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' : 'setup'))).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')).toBe(!expectsChannel)
            expect(viewed.some(call => call[1]?.step === 'install')).toBe(false)
            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('preserves server-owned state from the refreshed CAS snapshot on retry', async () => {
    const previousUser = writerMocks.main.user
    const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia')
    const initialBentoEvents = {
      'cli:command_invoked': {
        details: [{ observed_at: '2026-08-22T10:00:00.000Z' }],
        occurrence_count: 1,
        sent_at: '2026-08-22T10:00:01.000Z',
      },
    }
    const refreshedBentoEvents = {
      'cli:command_invoked': {
        details: [
          { observed_at: '2026-08-22T10:00:00.000Z' },
          { observed_at: '2026-08-22T10:05:00.000Z' },
        ],
        occurrence_count: 2,
        sent_at: '2026-08-22T10:05:01.000Z',
      },
    }
    const initialABTests = {
      new_emails: {
        assigned_at: '2026-08-23T13:15:06.300Z',
        branch: 'A',
      },
    }
    const refreshedABTests = {
      new_emails: {
        assigned_at: '2026-08-23T13:15:06.300Z',
        branch: 'B',
      },
    }
    const initialOnboarding = {
      profile_completed_at: '2026-10-20T10:00:00.000Z',
      abtests: initialABTests,
      bento_events: initialBentoEvents,
      future_server_state: { revision: 1 },
    }
    const refreshedOnboarding = {
      abtests: refreshedABTests,
      bento_events: refreshedBentoEvents,
      future_server_state: { revision: 2 },
    }
    writerMocks.refreshUser.mockReset()
    writerMocks.replaceUserOnboardingIfUnchanged.mockReset()
    writerMocks.main.user = {
      id: 'user-bento-retry',
      image_url: 'avatar.png',
      first_name: 'Example',
      last_name: 'User',
      onboarding: initialOnboarding,
    }
    writerMocks.refreshUser.mockResolvedValueOnce({
      data: { ...writerMocks.main.user, onboarding: refreshedOnboarding },
      error: null,
    })
    writerMocks.replaceUserOnboardingIfUnchanged
      .mockResolvedValueOnce({ data: null, error: null })
      .mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({
        data: { ...writerMocks.main.user, onboarding },
        error: null,
      }))
    Object.defineProperty(window, 'matchMedia', {
      configurable: true,
      value: vi.fn(() => ({
        addEventListener: vi.fn(),
        addListener: vi.fn(),
        matches: false,
        removeEventListener: vi.fn(),
        removeListener: vi.fn(),
      })),
    })
    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(writerMocks.replaceUserOnboardingIfUnchanged).toHaveBeenCalledTimes(2))

      expect(writerMocks.replaceUserOnboardingIfUnchanged.mock.calls[0]?.[2]).toEqual(expect.objectContaining({
        abtests: initialABTests,
        bento_events: initialBentoEvents,
        future_server_state: { revision: 1 },
      }))
      expect(writerMocks.replaceUserOnboardingIfUnchanged.mock.calls[1]?.[1]).toEqual(refreshedOnboarding)
      expect(writerMocks.replaceUserOnboardingIfUnchanged.mock.calls[1]?.[2]).toEqual(expect.objectContaining({
        abtests: refreshedABTests,
        bento_events: refreshedBentoEvents,
        future_server_state: { revision: 2 },
      }))
      expect(writerMocks.replaceUserOnboardingIfUnchanged.mock.calls[1]?.[2]?.abtests).not.toEqual(initialABTests)
      expect(writerMocks.replaceUserOnboardingIfUnchanged.mock.calls[1]?.[2]?.bento_events).not.toEqual(initialBentoEvents)
    }
    finally {
      app.unmount()
      writerMocks.main.user = previousUser
      if (matchMediaDescriptor)
        Object.defineProperty(window, 'matchMedia', matchMediaDescriptor)
      else
        Reflect.deleteProperty(window, 'matchMedia')
    }
  })

  it('preserves the saved user intent and channel assignment when resuming the first app', async () => {
    const previousUser = writerMocks.main.user
    const previousRouteQuery = writerMocks.route.query
    const previousOrganization = writerMocks.organization.currentOrganization
    const previousAssignments = writerMocks.abTestAssignments
    const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia')
    const assignedAt = '2026-09-11T10:00:00.000Z'
    const currentOnboarding = {
      profile_completed_at: '2026-10-20T10:00:00.000Z',
      abtests: {
        new_channel: {
          assigned_at: assignedAt,
          branch: 'A',
        },
      },
      flow: 'pre_org',
      intent: 'ota',
      status: 'in_progress',
      step: 'organization',
      updated_at: '2026-09-11T10:01:00.000Z',
    }
    writerMocks.route.query = {}
    writerMocks.organization.currentOrganization = {
      gid: 'resumed-org',
      name: 'Resumed Org',
      onboarding: { intent: 'builder' },
    }
    writerMocks.abTestAssignments = currentOnboarding.abtests
    writerMocks.main.user = {
      id: 'user-bento-retry',
      image_url: 'avatar.png',
      first_name: 'Example',
      last_name: 'User',
      onboarding: currentOnboarding,
    }
    writerMocks.loadApp.mockReset()
    writerMocks.loadApp.mockResolvedValue({
      data: {
        android_store_url: null,
        app_id: 'com.example.resumed',
        existing_app: true,
        icon_url: null,
        ios_store_url: null,
        name: 'Resumed App',
        owner_org: 'resumed-org',
      },
      error: null,
    })
    writerMocks.replaceUserOnboardingIfUnchanged.mockReset()
    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 })),
    })
    const container = document.createElement('div')
    const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg: true, setupAppId: 'com.example.resumed' })
    app.config.warnHandler = () => undefined

    try {
      app.mount(container)
      await vi.waitFor(() => expect(writerMocks.replaceUserOnboardingIfUnchanged).toHaveBeenCalled())

      const persistedOnboarding = writerMocks.replaceUserOnboardingIfUnchanged.mock.calls[0]?.[2]
      expect(persistedOnboarding).toEqual(expect.objectContaining({
        abtests: currentOnboarding.abtests,
        intent: 'ota',
      }))
    }
    finally {
      app.unmount()
      writerMocks.main.user = previousUser
      writerMocks.route.query = previousRouteQuery
      writerMocks.organization.currentOrganization = previousOrganization
      writerMocks.abTestAssignments = previousAssignments
      if (matchMediaDescriptor)
        Object.defineProperty(window, 'matchMedia', matchMediaDescriptor)
      else
        Reflect.deleteProperty(window, 'matchMedia')
    }
  })

  it('clears the persisted intent when the user explicitly restarts onboarding', async () => {
    const previousUser = writerMocks.main.user
    const previousDialogRole = writerMocks.dialog.lastButtonRole
    const previousAssignments = writerMocks.abTestAssignments
    const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia')
    const currentOnboarding = {
      profile_completed_at: '2026-10-20T10:00:00.000Z',
      abtests: {
        new_channel: {
          assigned_at: '2026-09-11T10:00:00.000Z',
          branch: 'A',
        },
      },
      flow: 'pre_org',
      intent: 'ota',
      status: 'in_progress',
      step: 'organization',
      updated_at: '2026-09-11T10:01:00.000Z',
    }
    writerMocks.dialog.lastButtonRole = 'onboarding-resume-restart'
    writerMocks.abTestAssignments = currentOnboarding.abtests
    writerMocks.main.user = {
      id: 'user-bento-retry',
      image_url: 'avatar.png',
      first_name: 'Example',
      last_name: 'User',
      onboarding: currentOnboarding,
    }
    writerMocks.replaceUserOnboardingIfUnchanged.mockReset()
    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 })),
    })
    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(writerMocks.replaceUserOnboardingIfUnchanged).toHaveBeenCalled())

      expect(writerMocks.replaceUserOnboardingIfUnchanged.mock.calls[0]?.[2]).not.toHaveProperty('intent')
    }
    finally {
      app.unmount()
      writerMocks.main.user = previousUser
      writerMocks.dialog.lastButtonRole = previousDialogRole
      writerMocks.abTestAssignments = previousAssignments
      if (matchMediaDescriptor)
        Object.defineProperty(window, 'matchMedia', matchMediaDescriptor)
      else
        Reflect.deleteProperty(window, 'matchMedia')
    }
  })

  it('keeps the saved company when restart persistence fails and the app name changes', async () => {
    const previousUser = writerMocks.main.user
    const previousDialogRole = writerMocks.dialog.lastButtonRole
    const previousAssignments = writerMocks.abTestAssignments
    const matchMediaDescriptor = Object.getOwnPropertyDescriptor(window, 'matchMedia')
    const currentOnboarding = {
      profile_completed_at: '2026-10-20T10:00:00.000Z',
      abtests: {
        new_channel: {
          assigned_at: '2026-09-11T10:00:00.000Z',
          branch: 'A',
        },
      },
      flow: 'pre_org',
      intent: 'ota',
      org_name: 'Saved Company',
      status: 'in_progress',
      step: 'organization',
      updated_at: '2026-09-11T10:01:00.000Z',
    }
    writerMocks.dialog.lastButtonRole = 'onboarding-resume-restart'
    writerMocks.abTestAssignments = currentOnboarding.abtests
    writerMocks.main.user = { ...previousUser, onboarding: currentOnboarding }
    writerMocks.replaceUserOnboardingIfUnchanged.mockReset()
    writerMocks.replaceUserOnboardingIfUnchanged
      .mockResolvedValueOnce({ data: null, error: new Error('restart write failed') })
      .mockResolvedValueOnce({ data: null, error: new Error('restart retry failed') })
      .mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({
        data: { ...writerMocks.main.user, onboarding },
        error: null,
      }))
    Object.defineProperty(window, 'matchMedia', {
      configurable: true,
      value: vi.fn(() => ({ matches: false })),
    })
    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(writerMocks.replaceUserOnboardingIfUnchanged.mock.calls.length).toBeGreaterThanOrEqual(3))
      await vi.waitFor(() => expect(container.querySelector('[data-test="onboarding-intent-ota"]')).not.toBeNull())
      expect(writerMocks.main.user.onboarding).toMatchObject({ org_name: 'Saved Company' })

      container.querySelector<HTMLButtonElement>('[data-test="onboarding-intent-ota"]')!.click()
      await nextTick()
      container.querySelector<HTMLButtonElement>('[data-test="app-onboarding-continue-intent"]')!.click()
      await vi.waitFor(() => expect(container.querySelector('[data-test="app-onboarding-name"]')).not.toBeNull())

      const appName = container.querySelector<HTMLInputElement>('[data-test="app-onboarding-name"]')!
      appName.value = 'Different App'
      appName.dispatchEvent(new Event('input', { bubbles: true }))
      await nextTick()
      container.querySelector<HTMLButtonElement>('[data-test="app-onboarding-continue"]')!.click()
      await nextTick()
      container.querySelector<HTMLButtonElement>('[data-test="app-onboarding-continue"]')!.click()
      await nextTick()

      expect(container.querySelector('[data-test="onboarding-company-name-summary"]')?.textContent).toBe('Saved Company')
      expect(container.querySelector('[data-test="onboarding-org-name"]')).toBeNull()
      await vi.waitFor(() => expect(writerMocks.main.user.onboarding).toMatchObject({
        app_name: 'Different App',
        org_name: 'Saved Company',
      }))
    }
    finally {
      app.unmount()
      writerMocks.main.user = previousUser
      writerMocks.dialog.lastButtonRole = previousDialogRole
      writerMocks.abTestAssignments = previousAssignments
      if (matchMediaDescriptor)
        Object.defineProperty(window, 'matchMedia', matchMediaDescriptor)
      else
        Reflect.deleteProperty(window, 'matchMedia')
    }
  })

  it('emits the pending app ready event only after the final 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')
    const pendingApp = {
      android_store_url: null,
      app_id: 'com.example.pending-ready',
      existing_app: true,
      icon_url: null,
      ios_store_url: null,
      name: 'Pending ready app',
      need_onboarding: true,
      onboarding: {},
      owner_org: 'ready-org',
    }
    const abtests = { new_channel: { assigned_at: '2026-09-21T00:00:00.000Z', branch: 'A' } }
    writerMocks.route.query = {}
    writerMocks.organization.currentOrganization = {
      gid: pendingApp.owner_org,
      name: 'Ready Org',
      onboarding: { intent: 'ota' },
    }
    writerMocks.main.user = {
      id: 'user-bento-retry',
      image_url: 'avatar.png',
      first_name: 'Example',
      last_name: 'User',
      onboarding: {
        profile_completed_at: '2026-10-20T10:00:00.000Z',
        abtests,
        app_id: pendingApp.app_id,
        flow: 'existing_org',
        setup_stage: 'channel-create',
        status: 'in_progress',
        step: 'channel',
        updated_at: '2026-09-21T00:00:00.000Z',
      },
    }
    writerMocks.loadApp.mockReset()
    writerMocks.loadApp.mockResolvedValue({ data: pendingApp, error: null })
    writerMocks.replaceUserOnboardingIfUnchanged.mockReset()
    writerMocks.replaceUserOnboardingIfUnchanged.mockImplementation(async (_userId, _expectedOnboarding, onboarding) => ({
      data: { ...writerMocks.main.user, onboarding },
      error: null,
    }))
    writerMocks.sendOnboardingEvent.mockClear()
    Object.defineProperty(window, 'matchMedia', {
      configurable: true,
      value: vi.fn(() => ({ matches: false })),
    })
    const clipboardWrite = vi.fn(async () => undefined)
    const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
    Object.defineProperty(navigator, 'clipboard', {
      configurable: true,
      value: { writeText: clipboardWrite },
    })
    const container = document.createElement('div')
    const app = createApp(AppOnboardingFlow, { onboarding: true, preOrg: false, setupAppId: pendingApp.app_id })
    app.config.warnHandler = () => undefined

    try {
      app.mount(container)
      await vi.waitFor(() => expect(container.querySelector('[data-test="resumed-channel-create"]')).not.toBeNull())
      await nextTick()

      expect(writerMocks.sendOnboardingEvent.mock.calls.filter(([event]) => event === 'app:onboarding_ready')).toHaveLength(0)

      container.querySelector<HTMLButtonElement>('[data-test="resumed-channel-create"]')!.click()
      await nextTick()

      await vi.waitFor(() => expect(container.querySelector('[data-test="onboarding-setup-cli"]')).not.toBeNull())
      await vi.waitFor(() => expect(writerMocks.sendOnboardingEvent).toHaveBeenCalledWith('app:onboarding_ready', {
        app_id: pendingApp.app_id,
        org_id: pendingApp.owner_org,
      }))
      expect(clipboardWrite).not.toHaveBeenCalled()
      expect(writerMocks.sendOnboardingEvent.mock.calls.filter(([event]) => event === 'app:onboarding_ready')).toHaveLength(1)
    }
    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')
      if (clipboardDescriptor)
        Object.defineProperty(navigator, 'clipboard', clipboardDescriptor)
      else
        Reflect.deleteProperty(navigator, 'clipboard')
    }
  })
})