tests/app-onboarding.unit.test.ts
Rama main · réplica de lectura del repositorio original.
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<string, string>)[`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<string, unknown> = {
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<Record<string, unknown>> } } }
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([])
})
})