import type { AppOnboardingPatch } from '../supabase/functions/_backend/utils/appOnboarding.ts' import { describe, expect, it } from 'vitest' import { INIT_ONBOARDING_STEP_IDS } from '../cli/src/init/onboarding-steps' import messages from '../messages/en.json' import { APP_ONBOARDING_ADD_CODE_INFERRED_ANNOTATION, APP_ONBOARDING_OTA_V1_STEP_IDS, APP_ONBOARDING_V1_STEP_IDS, APP_ONBOARDING_V2_STEP_IDS, APP_ONBOARDING_V3_STEP_IDS, appendAppOnboardingStepHistory, applyAppOnboardingPatch, applyAppOnboardingStepAnnotations, defaultAppOnboarding, filterAppOnboardingReportedPatch, getAppOnboardingStepHistoryChanges, getAppOnboardingStepIds, hasSupportedOtaTodoList, mergeAppOnboarding, parseAppOnboarding, parseAppOnboardingPatch, pickAppOnboardingSource, } from '../supabase/functions/_backend/utils/appOnboarding.ts' describe('app onboarding merge', () => { it.concurrent.each([3, 4])('resets a trusted v%s step without retaining completion metadata or recording history', (version) => { const step = { status: 'done', at: 'old', update_history: [{ status: 'done', at: 'old' }], annotations: ['old'], type: 'observed' } const current = { setup: { todo_list_version: version, ...(version === 4 ? { ota_todo_list_version: '1' } : {}), steps: version === 4 ? { ota: { add_channel: step } } : { add_channel: step } } } const patch = { steps: { add_channel: { status: 'pending' as const } } } const merged = applyAppOnboardingPatch(current, patch, () => 'new') const next = appendAppOnboardingStepHistory(current, merged, patch, () => 'new') as any expect(version === 4 ? next.setup.steps.ota.add_channel : next.setup.steps.add_channel).toEqual(version === 4 ? { status: 'pending' } : undefined) expect(getAppOnboardingStepHistoryChanges(current, next, patch)).toEqual([]) expect(parseAppOnboardingPatch(patch)).toBeNull() expect(parseAppOnboardingPatch({ steps: { ota: patch.steps } })).toBeNull() expect(current.setup.steps).toEqual(version === 4 ? { ota: { add_channel: step } } : { add_channel: step }) }) it.concurrent('keeps CLI step ids in sync with the CLI onboarding list', () => { // CLI titles live in cli/src/init/onboarding-steps.ts; backend ids must stay identical. expect([...APP_ONBOARDING_V1_STEP_IDS]).toEqual([...INIT_ONBOARDING_STEP_IDS]) expect(APP_ONBOARDING_V2_STEP_IDS).toEqual(['login_cli_mcp', ...INIT_ONBOARDING_STEP_IDS.slice(1)]) expect(getAppOnboardingStepIds(1)).toBe(APP_ONBOARDING_V1_STEP_IDS) expect(getAppOnboardingStepIds(2)).toBe(APP_ONBOARDING_V2_STEP_IDS) expect(getAppOnboardingStepIds(99)).toBe(APP_ONBOARDING_V2_STEP_IDS) }) it.concurrent('translates every OTA step title, which the checklist looks up by dynamic key', () => { for (const id of APP_ONBOARDING_OTA_V1_STEP_IDS) expect((messages as Record)[`setup-checklist-step-${id}`]).toBeTruthy() }) it.concurrent('defaults missing onboarding to manual in progress', () => { expect(parseAppOnboarding(null)).toEqual(defaultAppOnboarding()) expect(parseAppOnboarding('nope')).toEqual(defaultAppOnboarding()) expect(defaultAppOnboarding().todo_list_version).toBe(2) }) it.concurrent('reads nested setup without dropping the feature ledger', () => { const ledger = { refreshed_at: '2026-08-14T00:00:00.000Z', features: { ota: { stage: 'local_only' } }, setup: { todo_list_version: 2, source: 'cli', outcome: 'in_progress', steps: { add_app: { status: 'done', at: '2026-08-14T10:00:00.000Z' } }, }, } expect(parseAppOnboarding(ledger)).toMatchObject({ source: 'cli', outcome: 'in_progress', todo_list_version: 2, steps: { add_app: { status: 'done', at: '2026-08-14T10:00:00.000Z' } }, }) const next = applyAppOnboardingPatch(ledger, { steps: { add_channel: { status: 'done' } }, }, () => '2026-08-14T10:02:00.000Z') expect(next.features).toEqual({ ota: { stage: 'local_only' } }) expect(next.refreshed_at).toBe('2026-08-14T00:00:00.000Z') expect(parseAppOnboarding(next).todo_list_version).toBe(2) expect(parseAppOnboarding(next).steps.add_channel?.status).toBe('done') expect(next.source).toBeUndefined() }) it.concurrent('never downgrades a stronger onboarding source', () => { expect(pickAppOnboardingSource('cli', 'ai')).toBe('cli') expect(pickAppOnboardingSource('manual', 'ai')).toBe('ai') expect(pickAppOnboardingSource('cli', 'mcp')).toBe('mcp') }) it.concurrent('keeps a done step when a later skip arrives', () => { const first = mergeAppOnboarding({ todo_list_version: 1 }, { source: 'cli', steps: { add_app: { status: 'done', at: '2026-08-14T10:00:00.000Z' } }, }, () => '2026-08-14T10:00:00.000Z') const skipped = mergeAppOnboarding(first, { steps: { add_app: { status: 'skipped' } }, }, () => '2026-08-14T10:01:00.000Z') expect(skipped.steps.add_app).toEqual({ status: 'done', at: '2026-08-14T10:00:00.000Z', }) expect(skipped.source).toBe('cli') expect(skipped.outcome).toBe('in_progress') }) it.concurrent('marks completed only when every CLI step is done', () => { const steps = Object.fromEntries(APP_ONBOARDING_V2_STEP_IDS.map(id => [id, { status: 'done' as const }])) const completed = mergeAppOnboarding({ source: 'cli' }, { steps }, () => '2026-08-14T12:00:00.000Z') expect(completed.outcome).toBe('completed') const skipped = mergeAppOnboarding({ source: 'cli' }, { steps: { ...steps, run_device: { status: 'skipped' }, }, }, () => '2026-08-14T12:00:00.000Z') expect(skipped.outcome).toBe('skipped') }) it.concurrent('accepts only the steps defined by each todo list version', () => { const v1 = mergeAppOnboarding({ todo_list_version: 1 }, { steps: { add_app: { status: 'done' }, login_cli_mcp: { status: 'done' }, }, }) expect(v1.steps.add_app?.status).toBe('done') expect(v1.steps.login_cli_mcp).toBeUndefined() const v2 = mergeAppOnboarding({ todo_list_version: 2 }, { steps: { add_app: { status: 'done' }, login_cli_mcp: { status: 'done' }, }, }) expect(v2.steps.add_app).toBeUndefined() expect(v2.steps.login_cli_mcp?.status).toBe('done') }) it.concurrent('keeps the original step timestamp when a later report omits at', () => { const first = mergeAppOnboarding({ todo_list_version: 1 }, { source: 'cli', steps: { add_app: { status: 'done', at: '2026-08-14T10:00:00.000Z' } }, }, () => '2026-08-14T10:00:00.000Z') const again = mergeAppOnboarding(first, { steps: { add_app: { status: 'done' } }, }, () => '2026-08-14T11:00:00.000Z') expect(again.steps.add_app).toEqual({ status: 'done', at: '2026-08-14T10:00:00.000Z', }) }) it.concurrent('keeps switched_to_manual until the CLI finishes', () => { const switched = mergeAppOnboarding({ source: 'cli' }, { outcome: 'switched_to_manual', steps: { add_app: { status: 'done' } }, }, () => '2026-08-14T12:00:00.000Z') expect(switched.outcome).toBe('switched_to_manual') }) it.concurrent('ignores empty or unknown onboarding patches', () => { expect(parseAppOnboardingPatch({})).toBeNull() expect(parseAppOnboardingPatch({ source: 'web' })).toBeNull() expect(parseAppOnboardingPatch({ steps: { not_a_step: { status: 'done' } } })).toBeNull() expect(parseAppOnboardingPatch({ source: 'ai' })).toEqual({ source: 'ai' }) expect(parseAppOnboardingPatch({ todo_list_version: 99 })).toBeNull() }) it.concurrent('ignores client history and caps server history with an overflow marker', () => { expect(parseAppOnboardingPatch({ steps: { build_project: { status: 'done', update_history: [{ forged: true }] } }, })).toEqual({ steps: { build_project: { status: 'done' } } }) let current: Record = { setup: { source: 'cli', outcome: 'in_progress', steps: { build_project: { status: 'done', at: 'step-0' } }, }, } for (let update = 1; update <= 11; update++) { const patch = { steps: { build_project: { status: 'done' as const, at: `step-${update}` } } } const merged = applyAppOnboardingPatch(current, patch, () => `merge-${update}`) current = appendAppOnboardingStepHistory(current, merged, patch, () => `server-${update}`) } const setup = current.setup as { steps: { build_project: { update_history: Array> } } } const history = setup.steps.build_project.update_history expect(history).toHaveLength(10) expect(history[0]).toEqual({ status: 'done', at: 'server-1' }) expect(history[9]).toEqual({ type: 'update_history_full', at: 'server-11' }) const duplicatePatch = { steps: { build_project: { status: 'done' as const, at: 'step-11' } } } const duplicateMerge = applyAppOnboardingPatch(current, duplicatePatch, () => 'merge-duplicate') const duplicate = appendAppOnboardingStepHistory(current, duplicateMerge, duplicatePatch, () => 'server-duplicate') const duplicateSetup = duplicate.setup as { steps: { build_project: { update_history: unknown[] } } } expect(duplicateSetup.steps.build_project.update_history).toEqual(history) expect(getAppOnboardingStepHistoryChanges(current, duplicate, duplicatePatch)).toEqual([]) const changedPatch = { steps: { login_cli_mcp: { status: 'done' as const, at: 'step-new' } } } const changedMerge = applyAppOnboardingPatch(current, changedPatch, () => 'merge-new') const changed = appendAppOnboardingStepHistory(current, changedMerge, changedPatch, () => 'server-new') expect(getAppOnboardingStepHistoryChanges(current, changed, changedPatch)).toEqual([{ stepId: 'login_cli_mcp', status: 'done', at: 'server-new', historyLength: 1, historyFull: false, }]) }) }) describe('seven-goal checklist v3', () => { const current = { setup: { todo_list_version: 3, steps: {} } } it.concurrent('uses seven required milestones without changing v1/v2 or the default', () => { expect(getAppOnboardingStepIds(3)).toEqual(['login_cli_mcp', 'add_channel', 'add_updater', 'add_code', 'run_device', 'upload_bundle', 'test_update']) expect(defaultAppOnboarding().todo_list_version).toBe(2) }) it.concurrent('ignores legacy CLI completion until all seven goals are present', () => { const state = mergeAppOnboarding(current, { outcome: 'completed', steps: { completion: { status: 'done' }, add_encryption: { status: 'skipped' } } }) expect(state.outcome).toBe('in_progress') expect(state.steps).toEqual({}) expect(mergeAppOnboarding(current, { steps: Object.fromEntries(APP_ONBOARDING_V3_STEP_IDS.map(id => [id, { status: 'done' }])) }).outcome).toBe('completed') expect(mergeAppOnboarding(current, { outcome: 'skipped' }).outcome).toBe('skipped') }) it.concurrent('keeps app-ready reports while requiring backend evidence for three milestones', () => { const patch = { source: 'cli' as const, steps: { add_code: { status: 'done' as const }, run_device: { status: 'done' as const }, upload_bundle: { status: 'done' as const }, test_update: { status: 'done' as const } } } expect(filterAppOnboardingReportedPatch(current, patch).steps).toEqual({ add_code: { status: 'done' } }) expect(filterAppOnboardingReportedPatch({ setup: { todo_list_version: 2 } }, patch)).toBe(patch) }) it.concurrent('preserves other milestone histories during an automatic update', () => { const saved = { setup: { todo_list_version: 3, steps: { add_code: { status: 'done', update_history: [{ status: 'done', at: '2026-09-16' }] } } } } const next = applyAppOnboardingPatch(saved, { steps: { run_device: { status: 'done' } } }) expect((next.setup as any).steps.add_code.update_history).toEqual(saved.setup.steps.add_code.update_history) }) }) describe('version 4 OTA checklist', () => { const pending = Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: 'pending' }])) const current = { setup: { todo_list_version: 4, ota_todo_list_version: '1', paths: ['ota'], selected_path: 'ota', steps: { ota: pending } }, features: { ota: { stage: 'local_only' } } } it.concurrent('reads the same seven goals under the OTA path and keeps all pending steps visible', () => { expect(getAppOnboardingStepIds(4, '1')).toEqual(APP_ONBOARDING_OTA_V1_STEP_IDS) expect(parseAppOnboarding(current).steps).toEqual(pending) expect(parseAppOnboarding({ setup: { todo_list_version: 4, ota_todo_list_version: '1', steps: { ota: {} } } }).steps).toEqual(pending) expect(parseAppOnboarding({ setup: { todo_list_version: 3, steps: {} } }).steps).toEqual({}) }) it.concurrent('requires the string OTA version before reading or updating v4 steps', () => { expect(getAppOnboardingStepIds(4)).toEqual([]) expect(getAppOnboardingStepIds(4, '2')).toEqual([]) expect(hasSupportedOtaTodoList(parseAppOnboarding(current))).toBe(true) for (const otaVersion of [undefined, 1, '2']) { const unsupported = { setup: { todo_list_version: 4, ota_todo_list_version: otaVersion, steps: { ota: pending } } } expect(hasSupportedOtaTodoList(parseAppOnboarding(unsupported))).toBe(false) expect(parseAppOnboarding(unsupported).steps).toEqual({}) const merged = applyAppOnboardingPatch(unsupported, { steps: { add_code: { status: 'done' } } }) as any expect(merged.setup.steps.ota.add_code).toEqual({ status: 'pending' }) expect(merged.setup.paths).toBeUndefined() expect(merged.setup.outcome).toBe('in_progress') } const builderOnly = { setup: { todo_list_version: 4, paths: ['builder'], selected_path: 'builder', steps: { builder: { configure_signing: { status: 'pending' } } } } } const merged = applyAppOnboardingPatch(builderOnly, { steps: { add_code: { status: 'done' } } }) as any expect(merged.setup.paths).toEqual(['builder']) expect(merged.setup.steps.ota).toBeUndefined() expect(merged.setup.steps.builder).toEqual(builderOnly.setup.steps.builder) }) it.concurrent('writes nested progress while preserving the path and feature ledger', () => { const patch = parseAppOnboardingPatch({ steps: { ota: { add_code: { status: 'done' }, add_channel: { status: 'pending' } } } })! expect(patch.steps).toEqual({ add_code: { status: 'done' } }) const merged = applyAppOnboardingPatch(current, patch, () => '2026-09-19T00:00:00.000Z') const setup = merged.setup as any expect(setup.ota_todo_list_version).toBe('1') expect(setup.paths).toEqual(['ota']) expect(setup.selected_path).toBe('ota') expect(setup.steps.ota.add_code).toEqual({ status: 'done', at: '2026-09-19T00:00:00.000Z' }) expect(setup.steps.ota.add_channel).toEqual({ status: 'pending' }) expect(setup.steps.add_code).toBeUndefined() expect(merged.features).toEqual(current.features) expect(parseAppOnboarding(merged).outcome).toBe('in_progress') }) it.concurrent('records nested history and requires all seven reported goals', () => { const patch = { steps: { add_code: { status: 'done' as const } } } const merged = applyAppOnboardingPatch(current, patch, () => '2026-09-19T00:00:00.000Z') const withHistory = appendAppOnboardingStepHistory(current, merged, patch, () => '2026-09-19T00:00:01.000Z') expect((withHistory.setup as any).steps.ota.add_code.update_history).toEqual([{ status: 'done', at: '2026-09-19T00:00:01.000Z' }]) expect(getAppOnboardingStepHistoryChanges(current, withHistory, patch)).toHaveLength(1) expect(mergeAppOnboarding(current, { outcome: 'completed' }).outcome).toBe('in_progress') expect(mergeAppOnboarding(current, { steps: Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: 'done' }])) }).outcome).toBe('completed') expect(filterAppOnboardingReportedPatch(current, { steps: { add_code: { status: 'done' }, run_device: { status: 'done' } } }).steps).toEqual({ add_code: { status: 'done' } }) }) it.concurrent('stores inferred add-code provenance and clears it after direct CLI evidence', () => { const inferredPatch: AppOnboardingPatch = { steps: { add_code: { status: 'done' as const, annotation: APP_ONBOARDING_ADD_CODE_INFERRED_ANNOTATION, annotationType: 'note' as const, }, }, } const inferred = applyAppOnboardingPatch(current, inferredPatch, () => '2026-09-30T10:00:00.000Z') as any const withHistory = appendAppOnboardingStepHistory(current, inferred, inferredPatch, () => '2026-09-30T10:00:01.000Z') expect(inferred.setup.steps.ota.add_code).toMatchObject({ status: 'done', annotation: APP_ONBOARDING_ADD_CODE_INFERRED_ANNOTATION, annotation_type: 'note', }) expect(getAppOnboardingStepHistoryChanges(current, withHistory, inferredPatch)).toMatchObject([{ stepId: 'add_code', completionSource: 'inferred', inferredFromStepId: 'test_update', }]) const directPatch = parseAppOnboardingPatch({ steps: { ota: { add_code: { status: 'done', annotation: 'forged' } } }, })! expect(directPatch).toEqual({ steps: { add_code: { status: 'done' } } }) const directlyVerified = applyAppOnboardingStepAnnotations(inferred, inferred, directPatch) as any expect(directlyVerified.setup.steps.ota.add_code).not.toHaveProperty('annotation') expect(directlyVerified.setup.steps.ota.add_code).not.toHaveProperty('annotation_type') }) }) describe('builder-only checklist v4', () => { it.concurrent('preserves Builder steps without creating an OTA path during legacy progress updates', () => { const current = { setup: { todo_list_version: 4, builder_todo_list_version: '1', paths: ['builder'], selected_path: 'builder', steps: { builder: { ios: { start_setup: { status: 'pending' } }, android: { start_setup: { status: 'pending' } } } }, } } const patch = { source: 'cli' as const, steps: { login_cli_mcp: { status: 'done' as const } } } const merged = applyAppOnboardingPatch(current, patch) const withHistory = appendAppOnboardingStepHistory(current, merged, patch) const setup = withHistory.setup as any expect(hasSupportedOtaTodoList(parseAppOnboarding(withHistory))).toBe(false) expect(setup.steps).toEqual(current.setup.steps) expect(setup.paths).toEqual(['builder']) expect(setup.selected_path).toBe('builder') expect(setup.outcome).toBe('in_progress') expect(getAppOnboardingStepHistoryChanges(current, withHistory, patch)).toEqual([]) }) })