diff --git a/docs/superpowers/plans/2026-08-15-frontend-onboarding-resume-telemetry-identities.md b/docs/superpowers/plans/2026-08-15-frontend-onboarding-resume-telemetry-identities.md new file mode 100644 index 0000000000..776cf3a110 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-frontend-onboarding-resume-telemetry-identities.md @@ -0,0 +1,968 @@ +# Frontend Onboarding Resume Telemetry Identities Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve `onboarding_attempt_id` across accepted frontend onboarding resumes, distinguish each wizard mount with `onboarding_run_id`, and capture the resume dialog outcome without duplicating step-view events. + +**Architecture:** Extend the existing frontend onboarding analytics helper with one small identity context, rather than adding another service or backend path. Persist the active attempt and latest run inside the existing `users.onboarding` JSON payload, inject those IDs into the existing progress tracker, and let the current initialization/navigation helpers remain the only owners of `onboarding_step_viewed`. + +**Tech Stack:** Vue 3 Composition API, TypeScript, Vitest, PostHog through the existing `pushEvent` service, Supabase JSONB through the existing authenticated client. + +--- + +## Source Design And Scope Guard + +Implement against +[`docs/superpowers/specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md`](../specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md). + +This is deliberately a small PR: + +- Start the implementation branch from `origin/main`, not from the planning + branch, so planning documents do not count toward the implementation PR. +- Target at most 300 changed production lines; stop and simplify before the + production diff reaches 500 changed lines. Keep the required tests focused, + but do not trade behavioral coverage for a smaller test diff. +- Do not change PostHog queries, the admin dashboard, the resume dialog UI, + translations, Playwright scenarios, or backend endpoints. +- Do not add a migration, database constraint, index, Postgres test, generic + telemetry framework, run-started event, or run-ended event. +- Cover the normal fresh, Continue, Restart, and pre-fix legacy paths. Preserve + existing compare-and-swap behavior rather than adding new multi-tab recovery. + +## File Map And Line Budget + +- Modify `src/utils/onboardingProgressAnalytics.ts` — add the compact identity + context and inject supplied attempt/run IDs into the existing tracker. + Budget: about 90 changed lines. +- Modify `src/utils/userOnboardingProgress.ts` — parse and build the two optional + persisted telemetry fields. Budget: about 25 changed lines. +- Create `src/utils/onboardingProgressPersistence.ts` — isolate the serialized + persistence lifecycle and its abort/conflict barriers. Budget: about 70 + changed lines. +- Modify `src/components/dashboard/AppOnboardingFlow.vue` — wire the identity + context into persistence, dialog decisions, tracker initialization, and the + lifecycle controller. Budget: about 90 changed lines. +- Modify `tests/onboarding-progress-analytics.unit.test.ts` — deterministic + identity/event coverage and existing tracker assertions. Budget: about 100 + changed lines. +- Modify `tests/user-onboarding-progress.unit.test.ts` — persisted metadata + parsing/building coverage. Budget: about 20 changed lines. +- Modify `tests/app-onboarding-progress-integration.unit.test.ts` — ordering and + ownership contract plus component/controller integration coverage. +- Create `tests/onboarding-progress-persistence.unit.test.ts` — executable + abort, conflict, queue, failure, and initialization behavior coverage. + +The implementation creates only the persistence controller and its focused unit +test. The expected production total is about 275 changed lines, below the +500-line production ceiling. + +### Task 1: Persist the existing attempt ID and latest run ID + +**Files:** +- Modify: `tests/user-onboarding-progress.unit.test.ts` +- Modify: `src/utils/userOnboardingProgress.ts:14-47,108-218` + +- [ ] **Step 1: Extend the existing parse/build test with telemetry metadata** + +In the first parsing test, add these fields to the valid raw payload and expected +result: + +```ts +onboarding_attempt_id: '7e64f484-4171-47b6-86f7-0ef5d49e0ef8', +last_run_id: 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276', +``` + +In the compact-payload test, pass and expect the camelCase builder inputs mapped +to their persisted snake_case names: + +```ts +const progress = buildUserOnboardingProgress({ + status: 'in_progress', + step: 'details', + flow: 'pre_org', + onboardingAttemptId: '7e64f484-4171-47b6-86f7-0ef5d49e0ef8', + lastRunId: 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276', + intent: 'builder', + appName: ' Hello ', + appId: '', + existingApp: true, + existingAppSetup: 'import', + storeUrl: 'https://apps.apple.com/app/id123', + orgName: ' ', + updatedAt: '2026-08-15T00:00:00.000Z', +}) + +expect(progress).toMatchObject({ + onboarding_attempt_id: '7e64f484-4171-47b6-86f7-0ef5d49e0ef8', + last_run_id: 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276', +}) +``` + +Add one narrow malformed-metadata assertion proving operational progress still +parses: + +```ts +expect(parseUserOnboardingProgress({ + status: 'in_progress', + step: 'details', + flow: 'pre_org', + onboarding_attempt_id: 'invalid', + last_run_id: 'invalid', + updated_at: '2026-08-15T00:00:00.000Z', +})).toMatchObject({ + status: 'in_progress', + step: 'details', + flow: 'pre_org', +}) +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +bunx vitest run tests/user-onboarding-progress.unit.test.ts +``` + +Expected: FAIL because `UserOnboardingProgressInput` does not accept +`onboardingAttemptId` or `lastRunId`, and parsed output omits both fields. + +- [ ] **Step 3: Add the minimal persisted metadata model** + +Add these two fields to `UserOnboardingProgress` after `completed_at`, and add +the camelCase pair to `UserOnboardingProgressInput` after `completedAt`: + +```ts +onboarding_attempt_id?: string +last_run_id?: string + +onboardingAttemptId?: string +lastRunId?: string +``` + +Add format validation beside the existing constants: + +```ts +const onboardingAttemptIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const onboardingRunIdPattern = /^ir_[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +``` + +Append this exact parsing logic to `applyOptionalUserOnboardingFields()` before +returning `progress`: + +```ts +const onboardingAttemptId = optionalTrimmedString(raw.onboarding_attempt_id, 64) +if (onboardingAttemptId && onboardingAttemptIdPattern.test(onboardingAttemptId)) + progress.onboarding_attempt_id = onboardingAttemptId + +const lastRunId = optionalTrimmedString(raw.last_run_id, 67) +if (lastRunId && onboardingRunIdPattern.test(lastRunId)) + progress.last_run_id = lastRunId +``` + +Append this builder logic before the completion timestamp handling: + +```ts +if (input.onboardingAttemptId && onboardingAttemptIdPattern.test(input.onboardingAttemptId)) + progress.onboarding_attempt_id = input.onboardingAttemptId + +if (input.lastRunId && onboardingRunIdPattern.test(input.lastRunId)) + progress.last_run_id = input.lastRunId +``` + +Do not add the fixed-size IDs to `OPTIONAL_STRING_KEYS`; free-text fields should +be reduced first if the payload approaches the existing byte cap. + +- [ ] **Step 4: Run the focused test and verify it passes** + +Run: + +```bash +bunx vitest run tests/user-onboarding-progress.unit.test.ts +``` + +Expected: PASS with all existing size and Unicode tests unchanged. + +- [ ] **Step 5: Commit the persistence model** + +```bash +git add src/utils/userOnboardingProgress.ts tests/user-onboarding-progress.unit.test.ts +git commit -m "feat(onboarding): persist telemetry identity metadata" +``` + +### Task 2: Add a compact resume identity context and inject it into the tracker + +**Files:** +- Modify: `tests/onboarding-progress-analytics.unit.test.ts` +- Modify: `src/utils/onboardingProgressAnalytics.ts:26-229` + +- [ ] **Step 1: Write deterministic identity lifecycle tests** + +Import the new helper and define stable IDs: + +```ts +import { + createOnboardingProgressTracker, + createOnboardingTelemetryIdentity, + ONBOARDING_ANALYTICS_VERSION, +} from '../src/utils/onboardingProgressAnalytics' + +const ATTEMPT_A1 = '7e64f484-4171-47b6-86f7-0ef5d49e0ef8' +const ATTEMPT_A2 = '89c8aa2f-78df-4ee5-a78d-ef540f33aa43' +const RUN_R1 = 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276' +const RUN_R2_UUID = '9f6a0407-64f9-4696-b447-8dd976674b5c' +const RUN_R2 = `ir_${RUN_R2_UUID}` +``` + +Add one Continue test covering dialog ordering, identity switching, duplicate +guards, and persisted output: + +```ts +it.concurrent('keeps the fresh attempt for the dialog then restores the saved attempt on Continue', () => { + const capture = vi.fn() + const ids = [ATTEMPT_A2, RUN_R2_UUID] + const identity = createOnboardingTelemetryIdentity({ + capture, + flow: 'pre_org', + idFactory: () => ids.shift()!, + supaHost: 'https://supabase.capgo.test', + }) + + identity.prepareResumeCandidate({ + onboardingAttemptId: ATTEMPT_A1, + lastRunId: RUN_R1, + savedStep: 'organization', + steps, + }) + identity.recordResumeDialogViewed() + identity.recordResumeDialogViewed() + identity.recordResumeContinued() + identity.recordResumeContinued() + + expect(capture.mock.calls.map(call => call[0])).toEqual([ + 'onboarding_resume_dialog_viewed', + 'onboarding_resume_continued', + ]) + expect(capture.mock.calls[0]?.[2]).toMatchObject({ + onboarding_attempt_id: ATTEMPT_A2, + onboarding_run_id: RUN_R2, + resume_onboarding_attempt_id: ATTEMPT_A1, + resumed_from_run_id: RUN_R1, + saved_step: 'organization', + }) + expect(capture.mock.calls[1]?.[2]).toMatchObject({ + initial_onboarding_attempt_id: ATTEMPT_A2, + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R2, + }) + expect(identity.getProgressMetadata()).toEqual({ + onboardingAttemptId: ATTEMPT_A1, + lastRunId: RUN_R2, + }) +}) +``` + +Add one Restart test: + +```ts +it.concurrent('keeps the fresh attempt when Restart is selected', () => { + const capture = vi.fn() + const ids = [ATTEMPT_A2, RUN_R2_UUID] + const identity = createOnboardingTelemetryIdentity({ + capture, + flow: 'pre_org', + idFactory: () => ids.shift()!, + supaHost: 'https://supabase.capgo.test', + }) + identity.prepareResumeCandidate({ + onboardingAttemptId: ATTEMPT_A1, + lastRunId: RUN_R1, + savedStep: 'organization', + steps, + }) + + identity.recordResumeDialogViewed() + identity.recordResumeRestarted() + + expect(capture.mock.calls.at(-1)?.[0]).toBe('onboarding_resume_restarted') + expect(capture.mock.calls.at(-1)?.[2]).toMatchObject({ + onboarding_attempt_id: ATTEMPT_A2, + onboarding_run_id: RUN_R2, + }) + expect(identity.getProgressMetadata()).toEqual({ + onboardingAttemptId: ATTEMPT_A2, + lastRunId: RUN_R2, + }) +}) +``` + +Change existing tracker tests to supply stable IDs: + +```ts +const trackerIdentity = { + onboardingAttemptId: ATTEMPT_A1, + onboardingRunId: RUN_R1, +} + +const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, + capture, + flow: 'pre_org', + resumed: false, + steps, + supaHost: 'https://supabase.capgo.test', +}) +``` + +Use `...trackerIdentity` in every existing tracker construction. Replace +`expect.any(String)` attempt assertions with `ATTEMPT_A1`, add +`onboarding_run_id: RUN_R1` to exact property objects, and add +`onboarding_run_id` to the allowed-key set. + +Replace the existing “uses one unique attempt id for every event from a tracker +instance” test with this supplied-identity contract: + +```ts +it.concurrent('uses the supplied attempt and run ids for every tracker event', () => { + const capture = vi.fn() + const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, + capture, + flow: 'pre_org', + resumed: false, + steps, + supaHost: 'https://supabase.capgo.test', + }) + + tracker.viewStep('intent') + tracker.completeStep('intent', { nextStep: 'details' }) + + expect(capture.mock.calls.every(call => ( + call[2]?.onboarding_attempt_id === ATTEMPT_A1 + && call[2]?.onboarding_run_id === RUN_R1 + ))).toBe(true) +}) +``` + +- [ ] **Step 2: Run the focused test and verify it fails** + +Run: + +```bash +bunx vitest run tests/onboarding-progress-analytics.unit.test.ts +``` + +Expected: FAIL because `createOnboardingTelemetryIdentity` and the two tracker +identity options do not exist. + +- [ ] **Step 3: Implement the identity context** + +Add these types beside `CaptureEvent`: + +```ts +interface OnboardingResumeCandidate { + lastRunId?: string + onboardingAttemptId?: string + savedStep: OnboardingAnalyticsStep + steps: readonly OnboardingAnalyticsStep[] +} + +interface CreateOnboardingTelemetryIdentityOptions { + capture?: CaptureEvent + flow: OnboardingAnalyticsFlow + idFactory?: () => string + supaHost: string +} +``` + +Add this helper before `CreateOnboardingProgressTrackerOptions`: + +```ts +export function createOnboardingTelemetryIdentity(options: CreateOnboardingTelemetryIdentityOptions) { + const capture = options.capture ?? pushEvent + const idFactory = options.idFactory ?? (() => crypto.randomUUID()) + const initialAttemptId = idFactory() + const runId = `ir_${idFactory()}` + let activeAttemptId = initialAttemptId + let candidate: OnboardingResumeCandidate | undefined + let dialogViewed = false + let decision: 'continue' | 'restart' | undefined + + function safelyCapture(name: string, properties: AnalyticsProperties) { + try { + capture(name, options.supaHost, properties) + } + catch { + // Analytics must never interrupt onboarding. + } + } + + function candidateProperties(): AnalyticsProperties { + if (!candidate) + return {} + const stepIndex = candidate.steps.indexOf(candidate.savedStep) + return { + flow: options.flow, + onboarding_version: ONBOARDING_ANALYTICS_VERSION, + saved_step: candidate.savedStep, + step_index: stepIndex, + total_steps: candidate.steps.length, + ...(candidate.onboardingAttemptId + ? { resume_onboarding_attempt_id: candidate.onboardingAttemptId } + : {}), + ...(candidate.lastRunId ? { resumed_from_run_id: candidate.lastRunId } : {}), + } + } + + function currentProperties(): AnalyticsProperties { + return { + onboarding_attempt_id: activeAttemptId, + onboarding_run_id: runId, + } + } + + return { + get attemptId() { return activeAttemptId }, + get runId() { return runId }, + getProgressMetadata: () => ({ + onboardingAttemptId: activeAttemptId, + lastRunId: runId, + }), + prepareResumeCandidate: (next: OnboardingResumeCandidate) => { + candidate = next + }, + recordResumeDialogViewed: () => { + if (!candidate || dialogViewed) + return + dialogViewed = true + safelyCapture('onboarding_resume_dialog_viewed', { + ...candidateProperties(), + ...currentProperties(), + }) + }, + recordResumeContinued: () => { + if (!candidate || decision) + return + decision = 'continue' + const initial = activeAttemptId + if (candidate.onboardingAttemptId) + activeAttemptId = candidate.onboardingAttemptId + safelyCapture('onboarding_resume_continued', { + ...candidateProperties(), + ...currentProperties(), + ...(activeAttemptId !== initial + ? { initial_onboarding_attempt_id: initial } + : {}), + }) + }, + recordResumeRestarted: () => { + if (!candidate || decision) + return + decision = 'restart' + safelyCapture('onboarding_resume_restarted', { + ...candidateProperties(), + ...currentProperties(), + }) + }, + } +} +``` + +- [ ] **Step 4: Inject supplied identity into existing tracker properties** + +Extend `CreateOnboardingProgressTrackerOptions`: + +```ts +interface CreateOnboardingProgressTrackerOptions { + capture?: CaptureEvent + flow: OnboardingAnalyticsFlow + now?: () => number + onboardingAttemptId: string + onboardingRunId: string + resumed: boolean + steps: readonly OnboardingAnalyticsStep[] + supaHost: string +} +``` + +Delete `const onboardingAttemptId = crypto.randomUUID()` from +`createOnboardingProgressTracker()` and change its shared properties to: + +```ts +return { + flow: options.flow, + onboarding_attempt_id: options.onboardingAttemptId, + onboarding_run_id: options.onboardingRunId, + onboarding_version: ONBOARDING_ANALYTICS_VERSION, + resumed: options.resumed, + step, + step_index: stepIndex, + total_steps: options.steps.length, +} +``` + +Do not change event names, completion timing, copy-event payloads, or details +debouncing. + +- [ ] **Step 5: Run the focused analytics test** + +Run: + +```bash +bunx vitest run tests/onboarding-progress-analytics.unit.test.ts +``` + +Expected: PASS. Continue uses A1/R2 with A2 in +`initial_onboarding_attempt_id`; Restart remains A2/R2; all existing tracker +events carry the supplied attempt/run pair. + +- [ ] **Step 6: Commit the analytics identity context** + +```bash +git add src/utils/onboardingProgressAnalytics.ts tests/onboarding-progress-analytics.unit.test.ts +git commit -m "feat(onboarding): add resume telemetry identity context" +``` + +### Task 3: Wire resume decisions without emitting step views manually + +**Files:** +- Modify: `tests/app-onboarding-progress-integration.unit.test.ts` +- Modify: `src/components/dashboard/AppOnboardingFlow.vue:46-57,303-526,1382-1414` + +- [ ] **Step 1: Add source-contract assertions for ownership and ordering** + +Extend the initialization test with: + +```ts +expect(onboardingSource).toContain('createOnboardingTelemetryIdentity') +expect(initializer).toContain('onboardingAttemptId: onboardingTelemetry.attemptId') +expect(initializer).toContain('onboardingRunId: onboardingTelemetry.runId') + +const resumeDialog = sourceBetween( + 'async function maybeResumeSavedOnboarding()', + 'function whiteCardToggleButtonClass(', +) +expect(resumeDialog).toContain('onboardingTelemetry.prepareResumeCandidate({') +expect(resumeDialog).toContain('onboardingTelemetry.recordResumeDialogViewed()') +expect(resumeDialog).toContain('onboardingTelemetry.recordResumeContinued()') +expect(resumeDialog).toContain('onboardingTelemetry.recordResumeRestarted()') +expect(resumeDialog).not.toContain('.viewStep(') +``` + +Extend the persistence assertions with: + +```ts +const snapshot = sourceBetween( + 'function snapshotOnboardingProgress(', + 'async function persistOnboardingProgress(', +) +expect(snapshot).toContain('const telemetry = onboardingTelemetry.getProgressMetadata()') +expect(snapshot).toContain('onboardingAttemptId: telemetry.onboardingAttemptId') +expect(snapshot).toContain('lastRunId: telemetry.lastRunId') +``` + +- [ ] **Step 2: Run the focused integration test and verify it fails** + +Run: + +```bash +bunx vitest run tests/app-onboarding-progress-integration.unit.test.ts +``` + +Expected: FAIL because the component has not created or wired the identity +context. + +- [ ] **Step 3: Create one identity context per component mount** + +Add `createOnboardingTelemetryIdentity` to the existing analytics import and +create the context after local config is available: + +```ts +import { + createOnboardingDetailsFieldDebouncer, + createOnboardingProgressTracker, + createOnboardingTelemetryIdentity, +} from '~/utils/onboardingProgressAnalytics' + +const onboardingTelemetry = createOnboardingTelemetryIdentity({ + flow: props.preOrg ? 'pre_org' : 'existing_org', + supaHost: config.supaHost, +}) +``` + +Supply the active identity when initializing the existing progress tracker: + +```ts +progressTracker = createOnboardingProgressTracker({ + flow: props.preOrg ? 'pre_org' : 'existing_org', + onboardingAttemptId: onboardingTelemetry.attemptId, + onboardingRunId: onboardingTelemetry.runId, + resumed, + steps: trackedSteps, + supaHost: config.supaHost, +}) +``` + +Keep the existing `progressTracker.viewStep(flowStep.value)` call exactly where +it is. + +- [ ] **Step 4: Persist the active pair in every existing progress snapshot** + +At the start of `snapshotOnboardingProgress()`, read the identity metadata and +pass it to the existing builder: + +```ts +function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress') { + const flow = props.preOrg ? 'pre_org' : 'existing_org' + const telemetry = onboardingTelemetry.getProgressMetadata() + return buildUserOnboardingProgress({ + status, + step: clampResumableOnboardingStep(flowStep.value, flow), + flow, + onboardingAttemptId: telemetry.onboardingAttemptId, + lastRunId: telemetry.lastRunId, + intent: selectedIntent.value, + appName: appName.value, + appId: generatedAppId.value, + existingApp: existingApp.value, + existingAppSetup: existingAppSetup.value, + storeUrl: storeUrl.value, + importedStoreAppId: importedStoreAppId.value, + orgName: orgNameInput.value, + estimatedUsersIndex: estimatedUsersIndex.value, + }) +} +``` + +Do not add another persistence call. The existing mount, transition, debounce, +logout, completion, and unmount writes will carry the pair automatically. + +- [ ] **Step 5: Gate resume lifecycle events around the existing dialog** + +After the existing prompt guard succeeds, prepare the candidate using the +clamped step that will actually be shown: + +```ts +const resumableStep = clampResumableOnboardingStep(saved.step, flow) +onboardingTelemetry.prepareResumeCandidate({ + onboardingAttemptId: saved.onboarding_attempt_id, + lastRunId: saved.last_run_id, + savedStep: resumableStep, + steps: appOnboardingSteps.value.map(step => step.id), +}) +``` + +Insert the lifecycle call immediately after the existing +`dialogStore.openDialog(...)` call and before its existing dismissal wait: + +```ts +onboardingTelemetry.recordResumeDialogViewed() +await dialogStore.onDialogDismiss() +``` + +Record one decision in each existing branch without calling `viewStep()`: + +```ts +if (dialogStore.lastButtonRole === 'onboarding-resume-restart') { + onboardingTelemetry.recordResumeRestarted() + resetOnboardingForm() + existingApp.value = true + existingAppSetup.value = 'manual' + return false +} + +onboardingTelemetry.recordResumeContinued() +applyOnboardingProgress(saved) +return true +``` + +The existing `onMounted()` `finally` block remains responsible for calling +`initializeProgressTracking(resumedFlow)` once. Continue therefore emits one +restored-step view with A1/R2 and `resumed: true`; Restart emits one `intent` +view with A2/R2 and `resumed: false`. + +- [ ] **Step 6: Run all three focused unit files** + +Run: + +```bash +bunx vitest run tests/onboarding-progress-analytics.unit.test.ts tests/user-onboarding-progress.unit.test.ts tests/app-onboarding-progress-integration.unit.test.ts +``` + +Expected: PASS with no direct `viewStep()` call in either resume decision +branch. + +- [ ] **Step 7: Commit component wiring** + +```bash +git add src/components/dashboard/AppOnboardingFlow.vue tests/app-onboarding-progress-integration.unit.test.ts +git commit -m "feat(onboarding): connect resume telemetry identities" +``` + +### Task 4: Verify the small-PR contract + +**Files:** +- Verify only; no planned modifications. + +- [ ] **Step 1: Run repository lint before final validation** + +Run: + +```bash +bun lint +``` + +Expected: PASS with no ESLint or OXC errors. + +- [ ] **Step 2: Run focused telemetry and persistence tests** + +Run: + +```bash +bunx vitest run tests/onboarding-progress-analytics.unit.test.ts tests/user-onboarding-progress.unit.test.ts tests/app-onboarding-progress-integration.unit.test.ts +``` + +Expected: PASS. + +- [ ] **Step 3: Run frontend type checking** + +Run: + +```bash +bun run typecheck:frontend +``` + +Expected: PASS with no Vue or TypeScript errors. + +- [ ] **Step 4: Run the complete unit suite** + +Run: + +```bash +bun test:unit +``` + +Expected: PASS. + +- [ ] **Step 5: Enforce the PR line budget** + +Run: + +```bash +git diff --numstat origin/main...HEAD | awk '{ added += $1; deleted += $2 } END { print added + deleted }' +``` + +Expected: a number below `500`. If it is `500` or higher, reduce duplicated test +setup or inline identity wiring; do not expand scope or remove required +behavioral assertions. + +- [ ] **Step 6: Confirm the final diff contains only scoped files** + +Run: + +```bash +git diff --stat origin/main...HEAD +``` + +Expected: only the eight implementation/test files in the File Map, with no +admin-dashboard, PostHog query, generated type, migration, translation, or +Playwright changes. + +Run: + +```bash +git status --short +``` + +Expected: clean working tree. + +### Task 5: Keep navigation telemetry complete after a transient persistence outage + +**Files:** +- Modify: `tests/app-onboarding-progress-integration.unit.test.ts` +- Modify: `src/components/dashboard/AppOnboardingFlow.vue:305-410` +- Modify: `src/components/dashboard/AppOnboardingFlow.vue:1449-1475` + +- [ ] **Step 1: Write the failing mount-gate regression test** + +Replace the late-recovery assertions with a source-contract assertion that the +mount initializes only for a confirmed write or exhausted retryable failures: + +```ts +expect(onboardingSource).not.toContain('pendingProgressTrackingResumed') +expectSourceOrder(mountedFlow, [ + `if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed)`, + 'onboardingPersistResult = await persistOnboardingProgress()', + `onboardingPersistResult === 'persisted'`, + `onboardingPersistResult === 'retryable_failure'`, + 'initializeProgressTracking(resumedFlow)', +]) +expect(mountedFlow).not.toContain(`onboardingPersistResult === 'skipped'`) +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run: + +```bash +bunx vitest run tests/app-onboarding-progress-integration.unit.test.ts +``` + +Expected: FAIL because the component still uses +`pendingProgressTrackingResumed` and late initialization. + +- [ ] **Step 3: Replace late recovery with immediate retryable-failure tracking** + +Remove `pendingProgressTrackingResumed` and the initialization side effect from +`persistOnboardingProgress()`, returning its serialized result directly: + +```ts +persistChain = persistChain + .then(() => { + if (onboardingPersistenceBlocked && status !== 'completed') + return 'skipped' + return writeOnboardingProgress(status) + }) + .catch((error) => { + console.error('Failed to persist onboarding progress', error) + return 'retryable_failure' as const + }) +return persistChain +``` + +After the two mount attempts, initialize for `persisted` or +`retryable_failure`, while continuing to exclude `skipped`, `conflict`, abort, +and disposal: + +```ts +const shouldInitializeProgressTracking + = onboardingPersistResult === 'persisted' + || onboardingPersistResult === 'retryable_failure' + +if (!onboardingMountAborted && shouldInitializeProgressTracking) + initializeProgressTracking(resumedFlow) +``` + +- [ ] **Step 4: Run focused verification and verify GREEN** + +Run: + +```bash +bunx vitest run tests/onboarding-progress-analytics.unit.test.ts tests/user-onboarding-progress.unit.test.ts tests/app-onboarding-progress-integration.unit.test.ts +``` + +Expected: PASS with exactly one mount-owned first step view and no delayed +tracker initialization in `persistOnboardingProgress()`. + +- [ ] **Step 5: Run lint and frontend type checking** + +Run: + +```bash +bun lint +bun run typecheck:frontend +``` + +Expected: both commands PASS. + +- [ ] **Step 6: Commit the correction** + +```bash +git add src/components/dashboard/AppOnboardingFlow.vue tests/app-onboarding-progress-integration.unit.test.ts docs/superpowers/specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md docs/superpowers/plans/2026-08-15-frontend-onboarding-resume-telemetry-identities.md +git commit -m "fix(onboarding): preserve tracking during persistence outages" +``` + +### Task 6: Behavior-test the persistence lifecycle + +**Files:** +- Create: `src/utils/onboardingProgressPersistence.ts` +- Create: `tests/onboarding-progress-persistence.unit.test.ts` +- Modify: `src/components/dashboard/AppOnboardingFlow.vue` +- Modify: `tests/app-onboarding-progress-integration.unit.test.ts` +- Modify: `tests/onboarding-progress-analytics.unit.test.ts` + +- [ ] **Step 1: Write failing behavioral persistence tests** + +Define the wished-for controller API and use deferred promises to cover: + +```ts +const controller = createOnboardingProgressPersistence({ write }) + +const first = controller.persist() +const queued = controller.persist() +controller.abort() +resolveFirst('persisted') + +expect(await first).toBe('persisted') +expect(await queued).toBe('skipped') +expect(write).toHaveBeenCalledTimes(1) +``` + +Add separate tests proving a conflict blocks queued and later non-terminal +writes, an explicit `completed` write bypasses only the conflict barrier, a +write exception returns `retryable_failure` without poisoning the queue, and +`shouldInitializeOnboardingProgressTracking()` accepts only `persisted` or +`retryable_failure` on a live non-aborted mount. + +- [ ] **Step 2: Verify the new unit test is RED** + +Run: + +```bash +bunx vitest run tests/onboarding-progress-persistence.unit.test.ts +``` + +Expected: FAIL because `onboardingProgressPersistence.ts` does not exist. + +- [ ] **Step 3: Implement the minimal persistence controller** + +Create a typed controller that owns one promise chain plus abort and conflict +state. It accepts the existing component writer and optional error callback, +checks barriers both before enqueue and inside the serialized callback, marks +conflicts from the writer result, and exposes `persist`, `abort`, `isAborted`, +and `isBlocked`. Keep the initialization decision as a pure exported helper. + +Do not move Supabase access, snapshots, retries, UI state, or tracker creation +into the controller. + +- [ ] **Step 4: Wire the component to the tested controller** + +Replace the component-local promise chain and abort/conflict booleans with the +controller. Keep the existing `persistOnboardingProgress()` wrapper and writer, +but let the controller own conflict activation and all barrier checks. Use the +pure initialization helper in the mount finalizer. + +Keep source-contract tests only for integration ownership: one mount +initializer, no resume-branch view calls, controller guards used by scheduling +and unmount, and no manual watcher. Move race semantics to the executable unit +test. + +- [ ] **Step 5: Add identity capture-failure coverage** + +In `tests/onboarding-progress-analytics.unit.test.ts`, add a local identity +helper fixture whose `capture` dependency throws. Call dialog, Continue, and +Restart recording methods and assert none throws; then assert the active +attempt/run metadata remains valid and usable. No additional helper file is +created. + +- [ ] **Step 6: Run focused and repository verification** + +Run: + +```bash +bunx vitest run tests/onboarding-progress-persistence.unit.test.ts tests/onboarding-progress-analytics.unit.test.ts tests/user-onboarding-progress.unit.test.ts tests/app-onboarding-progress-integration.unit.test.ts +bun lint +bun typecheck +bun test:unit +git diff --check origin/main...HEAD +``` + +Expected: all commands PASS and the production changed-line count remains below +`500` with `codedb.snapshot` excluded. + +- [ ] **Step 7: Commit the behavioral coverage** + +```bash +git add src/utils/onboardingProgressPersistence.ts src/components/dashboard/AppOnboardingFlow.vue tests/onboarding-progress-persistence.unit.test.ts tests/app-onboarding-progress-integration.unit.test.ts tests/onboarding-progress-analytics.unit.test.ts docs/superpowers/specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md docs/superpowers/plans/2026-08-15-frontend-onboarding-resume-telemetry-identities.md +git commit -m "test(onboarding): exercise persistence lifecycle" +``` diff --git a/docs/superpowers/specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md b/docs/superpowers/specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md new file mode 100644 index 0000000000..882b028817 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md @@ -0,0 +1,296 @@ +# Frontend Onboarding Resume Telemetry Identities + +## Goal + +Preserve one logical frontend onboarding attempt across the persisted resume flow +introduced by PR #3062, while distinguishing each browser visit and recording +the resume dialog outcome without duplicating existing step events. + +The design follows the CLI identity model from PR #3058 but retains the +frontend's existing `onboarding_attempt_id` nomenclature and underscore-style +event names. + +## Problem + +`createOnboardingProgressTracker()` currently creates a new +`onboarding_attempt_id` every time `AppOnboardingFlow` mounts. PR #3062 persists +the wizard's operational state in `users.onboarding`, but it does not persist +the analytics attempt identity. + +As a result, a user can view and complete early steps under attempt A1, leave, +then continue the same saved wizard under attempt A2. PostHog reports an +abandoned A1 funnel and an unrelated resumed A2 funnel even though the database +correctly treats both visits as one onboarding journey. + +## Scope + +This change covers the persisted frontend create-app resume dialog and the +shared frontend onboarding event helper. It adds run identity to all events +produced by that helper and preserves attempt continuity when the user accepts +the persisted resume offered by the pre-organization flow. + +It does not: + +- change the resume dialog, wizard copy, or navigation behavior; +- change PostHog transport, retry, or delivery behavior; +- add frontend run-started or run-ended events; +- reconstruct identities for events captured before this change; +- change the database-backed admin wizard drop-off chart; +- change the existing-org pending-app resume behavior beyond attaching a run ID + to events produced during that run. + +## Identity Contract + +Keep these identities distinct: + +- `onboarding_attempt_id` is the existing frontend logical-attempt identity. It + survives an accepted resume and remains the grouping key used by current + PostHog funnel queries. It retains the existing raw UUID format. +- `onboarding_run_id` identifies one mount of `AppOnboardingFlow`. It uses the + CLI-compatible `ir_` format and is never replaced during that mount. +- `initial_onboarding_attempt_id` appears only on an accepted-resume event. It + records the fresh attempt generated for the returning mount before that mount + switches to the saved attempt. +- `resumed_from_run_id` is the previously persisted `last_run_id`, when valid. + It links a returning run to the most recent run that worked on the saved + attempt. +- `resume_onboarding_attempt_id` identifies the saved attempt offered by the + resume dialog while the fresh attempt remains active. + +Every mount creates a fresh attempt ID and run ID before inspecting saved +progress. Given saved attempt A1/run R1, the returning mount begins with fresh +attempt A2/run R2. A2 is allowed to remain ephemeral when the user continues +A1; it still identifies the pre-decision dialog event for that mount. + +## Persistence Contract + +Extend the existing `users.onboarding` JSON object with: + +```json +{ + "onboarding_attempt_id": "A1", + "last_run_id": "R2" +} +``` + +New writes always store the pair together: + +- Fresh onboarding stores A1/R1 with its first progress snapshot. +- While a resume dialog is pending, the saved A1/R1 pair remains untouched. +- Continue retains A1 and replaces `last_run_id` with R2. +- Restart replaces both values with A2/R2. +- Later progress writes in the same run preserve the active pair. + +`last_run_id` means the most recent run that worked on the persisted attempt. +Before it is replaced during a later accepted decision, its saved value is +reported as `resumed_from_run_id`. + +Operational progress remains valid when telemetry fields are absent or +malformed. The parser ignores invalid telemetry metadata without rejecting the +saved wizard step or form fields. A valid saved attempt without a valid previous +run may still be continued; the next successful write repairs the pair. + +The existing JSONB column, size check, and partial step index can contain these +fields without structural changes. To keep the implementation PR small, do not +add a comment-only migration, constraint, column, index, or Postgres test. + +## Event Contract + +All existing onboarding events emitted through the progress tracker gain +`onboarding_run_id`. They retain their existing names, properties, +`onboarding_attempt_id`, and `onboarding_version` semantics. + +Add these lifecycle events: + +### `onboarding_resume_dialog_viewed` + +Emit at most once per mount, immediately after the valid resume dialog is +opened and before waiting for a decision. + +Properties include: + +- `onboarding_attempt_id: A2`; +- `onboarding_run_id: R2`; +- `resume_onboarding_attempt_id: A1` when saved metadata contains A1; +- `resumed_from_run_id: R1` when saved metadata contains R1; +- `flow`, `saved_step`, `step_index`, `total_steps`, and + `onboarding_version`. + +No step-view event is emitted while the dialog is pending. + +### `onboarding_resume_continued` + +When saved progress contains a valid A1, switch the active attempt to A1 before +emitting this event. Emit it at most once per mount. + +Properties include: + +- `onboarding_attempt_id: A1`, or A2 for legacy progress without a valid saved + attempt; +- `onboarding_run_id: R2`; +- `initial_onboarding_attempt_id: A2` when the active attempt switched to A1; +- `resumed_from_run_id: R1` when available; +- `flow`, `saved_step`, `step_index`, `total_steps`, and + `onboarding_version`. + +Afterward, progress persistence stores A1/R2. + +### `onboarding_resume_restarted` + +Keep fresh A2 active and emit this event at most once per mount. + +Properties include: + +- `onboarding_attempt_id: A2`; +- `onboarding_run_id: R2`; +- `resume_onboarding_attempt_id: A1` when available; +- `resumed_from_run_id: R1` when available; +- `flow`, `saved_step`, `step_index`, `total_steps`, and + `onboarding_version`. + +Afterward, progress persistence stores A2/R2. + +## Step-Event Ownership And Ordering + +Resume lifecycle handlers do not emit `onboarding_step_viewed` directly and do +not add a watcher for `flowStep`. + +Existing flow code remains the sole owner of step-view events: + +- `initializeProgressTracking()` emits the first real visible step after + hydration; +- `completeAndViewStep()` emits the next step during forward navigation; +- `viewPreviousStep()` emits the destination step during backward navigation. + +The resume path must preserve this order: + +### Continue + +1. Open the dialog and emit `onboarding_resume_dialog_viewed` with A2/R2. +2. Switch the active identity to A1/R2. +3. Emit `onboarding_resume_continued` with + `initial_onboarding_attempt_id: A2`. +4. Restore the saved form and `flowStep` and return `resumed = true`. +5. Let the existing `initializeProgressTracking(true)` call emit exactly one + `onboarding_step_viewed` for the restored step with A1/R2 and + `resumed: true`. + +### Restart + +1. Open the dialog and emit `onboarding_resume_dialog_viewed` with A2/R2. +2. Keep A2/R2 active and emit `onboarding_resume_restarted`. +3. Reset the form, set `flowStep` to `intent`, and return `resumed = false`. +4. Let the existing `initializeProgressTracking(false)` call emit exactly one + `onboarding_step_viewed` for `intent` with A2/R2 and `resumed: false`. + +This ordering prevents a temporary `intent` view before the resume decision and +prevents a duplicate restored-step or restart-step view afterward. + +## Architecture + +Create a small typed frontend identity context alongside the existing +onboarding analytics helper. It owns: + +- fresh attempt and run generation; +- the optional saved resume candidate; +- the active-attempt switch on Continue; +- persisted identity metadata; +- shared resume-event properties; +- at-most-once guards for dialog and decision events; +- best-effort delegation to the existing `pushEvent` service. + +`createOnboardingProgressTracker()` stops generating its own attempt ID. Its +caller supplies the active `onboarding_attempt_id` and `onboarding_run_id`, and +the tracker adds both values to existing step, interaction, copy, and dashboard +exploration events. + +`AppOnboardingFlow.vue` remains the orchestration layer. It prepares the resume +candidate from parsed progress, records the dialog and decision, applies or +resets wizard state, and only then initializes the existing progress tracker. +It does not build identity properties itself. + +A small dependency-injected persistence controller owns serialization and the +component-lifetime abort/conflict barriers. Its write dependency remains in the +component, while deterministic unit tests drive deferred writes to prove queued +non-terminal suppression, terminal conflict bypass, retry recovery, and durable +abort behavior without mounting the full wizard. + +`userOnboardingProgress.ts` parses, validates, builds, and clamps the two +optional persisted identity fields. Invalid identity fields are dropped rather +than invalidating otherwise resumable progress. + +## Legacy Progress + +Saved progress created before this change has no recoverable PostHog attempt or +run identity. Historical events cannot be joined retroactively. + +For such progress, the returning mount's fresh A2/R2 pair becomes the active and +persisted pair after either Continue or Restart. The dialog and decision still +emit, but omit unavailable previous-attempt and previous-run properties. All +future resumes of that progress preserve A2 and advance the saved run ID. + +## Failure And Race Handling + +- Analytics remains best-effort and must never block the dialog, navigation, or + progress writes. +- A failed progress write may prevent continuity on a later visit, but it must + not alter the active in-memory identity for the current run. +- The initial progress write retries once after a retryable failure. If both + attempts fail, initialize the in-memory tracker for the resolved visible step + so later navigation events are not dropped. Regular progress writes may still + persist the same active identity later in the run. +- A `skipped` initial write does not prove that the active identity was stored, + and a compare-and-swap conflict proves that another snapshot won. Neither + outcome initializes the tracker. +- The existing `updated_at` compare-and-swap behavior remains authoritative for + concurrent tabs. A stale tab does not overwrite newer progress merely to + claim telemetry identity ownership. +- No identity pair is written while initial hydration or the resume decision is + pending. +- Local development continues to suppress PostHog through `pushEvent`. +- New analytics properties contain only generated IDs, enums, and numeric step + metadata; they do not include user-entered text. + +## Testing + +Add deterministic unit coverage for the identity context and integration +coverage for the component lifecycle: + +- fresh onboarding creates and persists A1/R1; +- every existing tracker event contains the supplied attempt and run IDs; +- dialog-viewed uses A2/R2 while saved progress remains A1/R1; +- no `onboarding_step_viewed` occurs while the dialog is open; +- Continue emits with A1/R2 and + `initial_onboarding_attempt_id: A2`; +- initialization emits exactly one restored-step view with A1/R2 and + `resumed: true`; +- Restart emits with A2/R2; +- initialization emits exactly one `intent` view with A2/R2 and + `resumed: false`; +- repeated resumes preserve the attempt while advancing R1 to R2 to R3; +- dialog and decision events cannot duplicate within one mount; +- legacy and malformed identity metadata do not break operational resume; +- persistence and capture failures do not interrupt onboarding; +- two retryable initial persistence failures do not drop later step transitions; +- skipped and conflicting initial writes do not emit step events with an + unconfirmed identity; +- deferred-write tests execute the persistence controller's abort, conflict, + terminal, and retry behavior rather than relying only on component source + markers; +- no new direct `viewStep()` call exists in either resume decision branch. + +Extend the existing registration Playwright scenario to retain its functional +Continue/Restart assertions. Keep event-order assertions in deterministic unit +tests where PostHog capture can be injected and observed directly. + +## Success Criteria + +- A user who continues saved onboarding contributes one + `onboarding_attempt_id` to the existing PostHog funnel across visits. +- Each browser visit is distinguishable by `onboarding_run_id`. +- Dialog view, Continue, and Restart are independently measurable. +- A pending dialog produces no false `intent` view. +- Continue and Restart each produce exactly one subsequent real step-view event + through the existing navigation/initialization path. +- Existing PostHog funnel queries and the database-backed admin drop-off chart + require no behavioral changes. diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index a0662ef1ae..4ce856733d 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -9,6 +9,7 @@ import type { OnboardingInteractionProperties, OnboardingStepCompletionProperties, } from '~/utils/onboardingProgressAnalytics' +import type { OnboardingPersistResult } from '~/utils/onboardingProgressPersistence' import type { UserOnboardingStatus } from '~/utils/userOnboardingProgress' import mime from 'mime' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' @@ -60,7 +61,8 @@ import { loadOnboardingAppDraft, } from '~/utils/onboardingAppDraft' import { onboardingPrimaryButtonClass, onboardingSecondaryButtonClass } from '~/utils/onboardingButtonClasses' -import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker } from '~/utils/onboardingProgressAnalytics' +import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker, createOnboardingTelemetryIdentity } from '~/utils/onboardingProgressAnalytics' +import { createOnboardingProgressPersistence, shouldInitializeOnboardingProgressTracking } from '~/utils/onboardingProgressPersistence' import { allowOnboardingDashboardExploration, ONBOARDING_DASHBOARD_EXPLORED_EVENT } from '~/utils/onboardingRedirect' import { slugifyOnboardingSegment } from '~/utils/onboardingSlug' import { @@ -89,6 +91,7 @@ const organizationStore = useOrganizationStore() const dashboardAppsStore = useDashboardAppsStore() const onboardingUserId = computed(() => main.user?.id ?? main.auth?.id ?? null) const config = getLocalConfig() +const onboardingTelemetry = createOnboardingTelemetryIdentity({ flow: props.preOrg ? 'pre_org' : 'existing_org', supaHost: config.supaHost }) const STORE_ICON_FETCH_TIMEOUT_MS = 10_000 const removeBeforeUnloadWarning = useBeforeUnloadWarning(Boolean(props.preOrg)) @@ -332,9 +335,14 @@ const setupTitle = computed(() => usesBuilderSetupCommand.value ? t('unified-onb const setupSubtitle = computed(() => usesBuilderSetupCommand.value ? t('unified-onboarding-setup-builder-subtitle') : t('unified-onboarding-setup-ota-subtitle')) let progressTracker: ReturnType | null = null -let persistChain = Promise.resolve() let persistFieldsTimer: ReturnType | undefined let pendingDashboardExplored = false +let onboardingFlowDisposed = false +let onboardingInitialPersistInFlight = false +const onboardingProgressPersistence = createOnboardingProgressPersistence({ + write: writeOnboardingProgress, + onError: error => console.error('Failed to persist onboarding progress', error), +}) function trackDetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) { if (props.preOrg) @@ -360,6 +368,8 @@ function initializeProgressTracking(resumed: boolean) { resumed, steps: trackedSteps, supaHost: config.supaHost, + onboardingAttemptId: onboardingTelemetry.attemptId, + onboardingRunId: onboardingTelemetry.runId, }) progressTracker.viewStep(flowStep.value) if (pendingDashboardExplored) @@ -392,6 +402,7 @@ function viewPreviousStep(nextStep: OnboardingFlowStep) { function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress') { const flow = props.preOrg ? 'pre_org' : 'existing_org' + const telemetry = onboardingTelemetry.getProgressMetadata() return buildUserOnboardingProgress({ status, step: clampResumableOnboardingStep(flowStep.value, flow), @@ -405,20 +416,17 @@ function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress' importedStoreAppId: importedStoreAppId.value, orgName: orgNameInput.value, estimatedUsersIndex: estimatedUsersIndex.value, + onboardingAttemptId: telemetry.onboardingAttemptId, + lastRunId: telemetry.lastRunId, }) } async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_progress') { - persistChain = persistChain - .then(() => writeOnboardingProgress(status)) - .catch((error) => { - console.error('Failed to persist onboarding progress', error) - }) - return persistChain + return onboardingProgressPersistence.persist(status) } function schedulePersistOnboardingProgress() { - if (isHydratingOnboarding.value) + if (isHydratingOnboarding.value || onboardingProgressPersistence.isBlocked() || onboardingProgressPersistence.isAborted()) return window.clearTimeout(persistFieldsTimer) persistFieldsTimer = setTimeout(() => { @@ -429,11 +437,11 @@ function schedulePersistOnboardingProgress() { async function writeOnboardingProgress(status: UserOnboardingStatus) { const userId = onboardingUserId.value if (!userId || isHydratingOnboarding.value) - return + return 'skipped' const current = parseUserOnboardingProgress(main.user?.onboarding) if (current?.status === 'completed' && status !== 'completed') - return + return 'skipped' const progress = snapshotOnboardingProgress(status) const onboarding = progress as unknown as Json @@ -454,16 +462,16 @@ async function writeOnboardingProgress(status: UserOnboardingStatus) { if (error) { console.error('Failed to persist onboarding progress', error) - return + return 'retryable_failure' } if (data && main.user?.id === userId) { main.user = { ...data, image_url: main.user.image_url } - return + return 'persisted' } if (status === 'completed' || main.user?.id !== userId) - return + return 'skipped' const { data: latest, error: latestError } = await supabase .from('users') @@ -474,6 +482,7 @@ async function writeOnboardingProgress(status: UserOnboardingStatus) { console.error('Failed to refresh onboarding progress snapshot', latestError) if (latest && main.user?.id === userId) main.user = { ...latest, image_url: main.user.image_url } + return 'conflict' } function resetOnboardingForm() { @@ -547,6 +556,13 @@ async function maybeResumeSavedOnboarding() { return false } + const resumableStep = clampResumableOnboardingStep(saved.step, flow) + onboardingTelemetry.prepareResumeCandidate({ + onboardingAttemptId: saved.onboarding_attempt_id, + lastRunId: saved.last_run_id, + savedStep: resumableStep, + steps: appOnboardingSteps.value.map(step => step.id), + }) dialogStore.openDialog({ title: t('onboarding-resume-title'), description: t('onboarding-resume-description'), @@ -556,15 +572,24 @@ async function maybeResumeSavedOnboarding() { { text: t('onboarding-resume-continue'), id: 'onboarding-resume-continue', role: 'primary' }, ], }) + onboardingTelemetry.recordResumeDialogViewed() await dialogStore.onDialogDismiss() + if (onboardingFlowDisposed) + return null + if (dialogStore.lastButtonRole === 'onboarding-resume-restart') { + onboardingTelemetry.recordResumeRestarted() resetOnboardingForm() existingApp.value = true existingAppSetup.value = 'manual' return false } + if (dialogStore.lastButtonRole !== 'onboarding-resume-continue') + return null + + onboardingTelemetry.recordResumeContinued() applyOnboardingProgress(saved) return true } @@ -1545,7 +1570,12 @@ onMounted(async () => { isHydratingOnboarding.value = true try { if (props.preOrg) { - resumedFlow = await maybeResumeSavedOnboarding() + const resumeResult = await maybeResumeSavedOnboarding() + if (resumeResult === null) { + onboardingProgressPersistence.abort() + return + } + resumedFlow = resumeResult return } @@ -1564,17 +1594,38 @@ onMounted(async () => { } finally { isHydratingOnboarding.value = false - isLoading.value = false - initializeProgressTracking(resumedFlow) - void persistOnboardingProgress() + let onboardingPersistResult: OnboardingPersistResult = 'skipped' + if (!onboardingFlowDisposed && !onboardingProgressPersistence.isAborted()) { + onboardingInitialPersistInFlight = true + onboardingPersistResult = await persistOnboardingProgress() + if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed) + onboardingPersistResult = await persistOnboardingProgress() + onboardingInitialPersistInFlight = false + } + function finishOnboardingMount() { + if (onboardingFlowDisposed || onboardingProgressPersistence.isAborted()) + return + isLoading.value = false + const shouldInitializeProgressTracking = shouldInitializeOnboardingProgressTracking( + onboardingPersistResult, + { + aborted: onboardingProgressPersistence.isAborted(), + disposed: onboardingFlowDisposed, + }, + ) + if (shouldInitializeProgressTracking) + initializeProgressTracking(resumedFlow) + } + finishOnboardingMount() } }) onBeforeUnmount(() => { + onboardingFlowDisposed = true window.clearTimeout(persistFieldsTimer) window.removeEventListener(ONBOARDING_DASHBOARD_EXPLORED_EVENT, trackDashboardExplored) detailsFieldTracker.dispose() - if (!isHydratingOnboarding.value) + if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingProgressPersistence.isBlocked() && !onboardingProgressPersistence.isAborted()) void persistOnboardingProgress() if (localIconPreview.value.startsWith('blob:')) diff --git a/src/utils/onboardingProgressAnalytics.ts b/src/utils/onboardingProgressAnalytics.ts index 59440e3bbd..8ef59313f0 100644 --- a/src/utils/onboardingProgressAnalytics.ts +++ b/src/utils/onboardingProgressAnalytics.ts @@ -38,6 +38,20 @@ type AnalyticsPrimitive = string | number | boolean type AnalyticsProperties = Record type CaptureEvent = (name: string, supaHost: string, properties?: AnalyticsProperties) => void +interface OnboardingResumeCandidate { + lastRunId?: string + onboardingAttemptId?: string + savedStep: OnboardingAnalyticsStep + steps: readonly OnboardingAnalyticsStep[] +} + +interface CreateOnboardingTelemetryIdentityOptions { + capture?: CaptureEvent + flow: OnboardingAnalyticsFlow + idFactory?: () => string + supaHost: string +} + export interface OnboardingStepCompletionProperties { appId?: string intent?: OnboardingIntent @@ -104,10 +118,78 @@ export function createOnboardingDetailsFieldDebouncer( return { dispose, schedule } } +export function createOnboardingTelemetryIdentity(options: CreateOnboardingTelemetryIdentityOptions) { + const capture = options.capture ?? pushEvent + const idFactory = options.idFactory ?? (() => crypto.randomUUID()) + const initialAttemptId = idFactory() + const onboardingRunId = `ir_${idFactory()}` + let activeAttemptId = initialAttemptId + let candidate: OnboardingResumeCandidate | undefined + const recorded = { decision: false, dialog: false } + + function safelyCapture(name: string, properties: AnalyticsProperties) { + try { + capture(name, options.supaHost, properties) + } + catch { + // Analytics must never interrupt onboarding. + } + } + + function resumeProperties(resumeCandidate: OnboardingResumeCandidate): AnalyticsProperties { + return { + flow: options.flow, + onboarding_attempt_id: activeAttemptId, + onboarding_run_id: onboardingRunId, + onboarding_version: ONBOARDING_ANALYTICS_VERSION, + ...(resumeCandidate.onboardingAttemptId ? { resume_onboarding_attempt_id: resumeCandidate.onboardingAttemptId } : {}), + ...(resumeCandidate.lastRunId ? { resumed_from_run_id: resumeCandidate.lastRunId } : {}), + saved_step: resumeCandidate.savedStep, + step_index: resumeCandidate.steps.indexOf(resumeCandidate.savedStep), + total_steps: resumeCandidate.steps.length, + } + } + + function recordResumeDialogViewed() { + if (recorded.dialog || !candidate) + return + recorded.dialog = true + safelyCapture('onboarding_resume_dialog_viewed', resumeProperties(candidate)) + } + + function recordDecision(name: string, continueSavedAttempt: boolean) { + if (recorded.decision || !candidate) + return + recorded.decision = true + const previousAttemptId = activeAttemptId + if (continueSavedAttempt && candidate.onboardingAttemptId) + activeAttemptId = candidate.onboardingAttemptId + + const properties = resumeProperties(candidate) + if (activeAttemptId !== previousAttemptId) + properties.initial_onboarding_attempt_id = initialAttemptId + safelyCapture(name, properties) + } + + return { + get attemptId() { return activeAttemptId }, + get runId() { return onboardingRunId }, + getProgressMetadata: () => ({ onboardingAttemptId: activeAttemptId, lastRunId: onboardingRunId }), + prepareResumeCandidate(next: OnboardingResumeCandidate) { + candidate = next + }, + recordResumeContinued: () => recordDecision('onboarding_resume_continued', true), + recordResumeDialogViewed, + recordResumeRestarted: () => recordDecision('onboarding_resume_restarted', false), + } +} + interface CreateOnboardingProgressTrackerOptions { capture?: CaptureEvent flow: OnboardingAnalyticsFlow now?: () => number + onboardingAttemptId: string + onboardingRunId: string resumed: boolean steps: readonly OnboardingAnalyticsStep[] supaHost: string @@ -116,7 +198,6 @@ interface CreateOnboardingProgressTrackerOptions { export function createOnboardingProgressTracker(options: CreateOnboardingProgressTrackerOptions) { const capture = options.capture ?? pushEvent const now = options.now ?? Date.now - const onboardingAttemptId = crypto.randomUUID() let activeStep: OnboardingAnalyticsStep | null = null let activePreviousStep: OnboardingAnalyticsStep | null = null let activeStepViewedAt = 0 @@ -129,7 +210,8 @@ export function createOnboardingProgressTracker(options: CreateOnboardingProgres return { flow: options.flow, - onboarding_attempt_id: onboardingAttemptId, + onboarding_attempt_id: options.onboardingAttemptId, + onboarding_run_id: options.onboardingRunId, onboarding_version: ONBOARDING_ANALYTICS_VERSION, resumed: options.resumed, step, diff --git a/src/utils/onboardingProgressPersistence.ts b/src/utils/onboardingProgressPersistence.ts new file mode 100644 index 0000000000..0bce1ef8a5 --- /dev/null +++ b/src/utils/onboardingProgressPersistence.ts @@ -0,0 +1,68 @@ +import type { UserOnboardingStatus } from './userOnboardingProgress' + +export type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict' | 'skipped' + +interface CreateOnboardingProgressPersistenceOptions { + onError?: (error: unknown) => void + write: (status: UserOnboardingStatus) => OnboardingPersistResult | Promise +} + +interface OnboardingProgressTrackingInitializationState { + aborted: boolean + disposed: boolean +} + +export function createOnboardingProgressPersistence(options: CreateOnboardingProgressPersistenceOptions) { + let chain: Promise = Promise.resolve('persisted') + let aborted = false + let blocked = false + + function shouldSkip(status: UserOnboardingStatus) { + return aborted || (blocked && status !== 'completed') + } + + function persist(status: UserOnboardingStatus = 'in_progress') { + if (shouldSkip(status)) + return Promise.resolve('skipped') + + chain = chain.then(async () => { + if (shouldSkip(status)) + return 'skipped' + + try { + const result = await options.write(status) + if (result === 'conflict') + blocked = true + return result + } + catch (error) { + try { + options.onError?.(error) + } + catch { + // Persistence failures must not poison later queue work. + } + return 'retryable_failure' + } + }) + return chain + } + + return { + abort() { + aborted = true + }, + isAborted: () => aborted, + isBlocked: () => blocked, + persist, + } +} + +export function shouldInitializeOnboardingProgressTracking( + result: OnboardingPersistResult, + state: OnboardingProgressTrackingInitializationState, +) { + return !state.aborted + && !state.disposed + && (result === 'persisted' || result === 'retryable_failure') +} diff --git a/src/utils/userOnboardingProgress.ts b/src/utils/userOnboardingProgress.ts index 76fbd66e0c..e9a8764214 100644 --- a/src/utils/userOnboardingProgress.ts +++ b/src/utils/userOnboardingProgress.ts @@ -24,6 +24,8 @@ export interface UserOnboardingProgress { imported_store_app_id?: string org_name?: string estimated_users_index?: number | null + onboarding_attempt_id?: string + last_run_id?: string updated_at: string completed_at?: string } @@ -41,6 +43,8 @@ export interface UserOnboardingProgressInput { importedStoreAppId?: string orgName?: string estimatedUsersIndex?: number | null + onboardingAttemptId?: string + lastRunId?: string updatedAt?: string completedAt?: string | null } @@ -52,6 +56,8 @@ function isOneOf(value: unknown, allowed: readonly T[]): value // Leave headroom: PostgreSQL jsonb::text is slightly larger than JSON.stringify. export const USER_ONBOARDING_MAX_JSON_BYTES = 8000 const OPTIONAL_STRING_KEYS = ['store_url', 'org_name', 'app_name', 'app_id', 'imported_store_app_id'] as const +const onboardingAttemptIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +const onboardingRunIdPattern = /^ir_[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i function truncateToCodePoints(value: string, maxLength: number): string { return Array.from(value).slice(0, maxLength).join('') @@ -162,7 +168,7 @@ export function parseUserOnboardingProgress(value: unknown): UserOnboardingProgr if (!isOneOf(raw.flow, USER_ONBOARDING_FLOWS)) return null - return applyOptionalUserOnboardingFields({ + const progress = applyOptionalUserOnboardingFields({ status: raw.status, step: raw.step, flow: raw.flow, @@ -170,6 +176,16 @@ export function parseUserOnboardingProgress(value: unknown): UserOnboardingProgr ? raw.updated_at : new Date(0).toISOString(), }, raw) + + const onboardingAttemptId = optionalTrimmedString(raw.onboarding_attempt_id, 64) + if (onboardingAttemptId && onboardingAttemptIdPattern.test(onboardingAttemptId)) + progress.onboarding_attempt_id = onboardingAttemptId + + const lastRunId = optionalTrimmedString(raw.last_run_id, 67) + if (lastRunId && onboardingRunIdPattern.test(lastRunId)) + progress.last_run_id = lastRunId + + return progress } export function buildUserOnboardingProgress(input: UserOnboardingProgressInput): UserOnboardingProgress { @@ -212,6 +228,12 @@ export function buildUserOnboardingProgress(input: UserOnboardingProgressInput): if (input.estimatedUsersIndex !== undefined) progress.estimated_users_index = input.estimatedUsersIndex + if (input.onboardingAttemptId && onboardingAttemptIdPattern.test(input.onboardingAttemptId)) + progress.onboarding_attempt_id = input.onboardingAttemptId + + if (input.lastRunId && onboardingRunIdPattern.test(input.lastRunId)) + progress.last_run_id = input.lastRunId + if (input.status === 'completed') progress.completed_at = optionalTrimmedString(input.completedAt) ?? progress.updated_at diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 962cfd35b1..c299344ea4 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -14,36 +14,207 @@ function sourceBetween(start: string, end: string) { return onboardingSource.slice(startIndex, endIndex) } +function expectSourceOrder(source: string, markers: string[]) { + let previousIndex = -1 + for (const marker of markers) { + const index = source.indexOf(marker, previousIndex + 1) + expect(index, `Expected source marker after previous marker: ${marker}`).toBeGreaterThan(previousIndex) + previousIndex = index + } +} + describe('app onboarding progress analytics integration', () => { it.concurrent('initializes tracking once the real initial or resumed step is resolved', () => { - expect(onboardingSource).toContain("import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker } from '~/utils/onboardingProgressAnalytics'") + expect(onboardingSource).toContain(`import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker, createOnboardingTelemetryIdentity } from '~/utils/onboardingProgressAnalytics'`) - const initializer = sourceBetween('function initializeProgressTracking(', 'function whiteCardToggleButtonClass(') - expect(initializer).toContain("flow: props.preOrg ? 'pre_org' : 'existing_org'") + const initializer = sourceBetween('function initializeProgressTracking(', 'function completeAndViewStep(') + expect(initializer).toContain(`flow: props.preOrg ? 'pre_org' : 'existing_org'`) expect(initializer).toContain('const trackedSteps = appOnboardingSteps.value.map(step => step.id)') - expect(initializer).toContain("if (!props.preOrg && resumed && flowStep.value === 'setup')") - expect(initializer).toContain("trackedSteps.push('setup')") + expect(initializer).toContain(`if (!props.preOrg && resumed && flowStep.value === 'setup')`) + expect(initializer).toContain(`trackedSteps.push('setup')`) expect(initializer).toContain('steps: trackedSteps') expect(initializer).toContain('resumed,') + expect(initializer).toContain('onboardingAttemptId: onboardingTelemetry.attemptId') + expect(initializer).toContain('onboardingRunId: onboardingTelemetry.runId') expect(initializer).toContain('progressTracker.viewStep(flowStep.value)') + expect(initializer.match(/\.viewStep\(/g)).toHaveLength(1) + + const resumeDialog = sourceBetween('async function maybeResumeSavedOnboarding()', 'function whiteCardToggleButtonClass(') + expect(resumeDialog).toContain('onboardingTelemetry.prepareResumeCandidate({') + expect(resumeDialog).toContain('onboardingTelemetry.recordResumeDialogViewed()') + expect(resumeDialog).toContain('onboardingTelemetry.recordResumeContinued()') + expect(resumeDialog).toContain('onboardingTelemetry.recordResumeRestarted()') + expect(resumeDialog).not.toContain('.viewStep(') + expectSourceOrder(resumeDialog, [ + 'onboardingTelemetry.prepareResumeCandidate({', + 'dialogStore.openDialog({', + 'onboardingTelemetry.recordResumeDialogViewed()', + 'await dialogStore.onDialogDismiss()', + ]) + + const restartCheck = `if (dialogStore.lastButtonRole === 'onboarding-resume-restart')` + expectSourceOrder(resumeDialog, [ + 'await dialogStore.onDialogDismiss()', + 'if (onboardingFlowDisposed)', + 'return null', + restartCheck, + ]) + const restartBranchStart = resumeDialog.indexOf(restartCheck) + const restartBranchEnd = resumeDialog.indexOf('\n }\n', restartBranchStart) + expect(restartBranchStart).toBeGreaterThan(resumeDialog.indexOf('await dialogStore.onDialogDismiss()')) + expect(restartBranchEnd).toBeGreaterThan(restartBranchStart) + const restartBranch = resumeDialog.slice(restartBranchStart, restartBranchEnd) + expectSourceOrder(restartBranch, [ + restartCheck, + 'onboardingTelemetry.recordResumeRestarted()', + 'resetOnboardingForm()', + 'return false', + ]) + const continueCheck = `if (dialogStore.lastButtonRole !== 'onboarding-resume-continue')` + expectSourceOrder(resumeDialog.slice(restartBranchEnd), [ + continueCheck, + 'return null', + 'onboardingTelemetry.recordResumeContinued()', + 'applyOnboardingProgress(saved)', + 'return true', + ]) + expect(resumeDialog.match(/return null/g)).toHaveLength(2) const resumeLoader = sourceBetween('async function loadResumeApp()', 'async function importStoreMetadata()') expect(resumeLoader).not.toContain('initializeProgressTracking') expect(resumeLoader).not.toContain('viewStep') - const mountedFlow = onboardingSource.slice(onboardingSource.indexOf('onMounted(async () => {')) + const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') + expect(onboardingSource).toContain(`import { createOnboardingProgressPersistence, shouldInitializeOnboardingProgressTracking } from '~/utils/onboardingProgressPersistence'`) expect(mountedFlow).toContain('let resumedFlow = false') - expect(mountedFlow).toContain('resumedFlow = await maybeResumeSavedOnboarding()') + expectSourceOrder(mountedFlow, [ + 'const resumeResult = await maybeResumeSavedOnboarding()', + 'if (resumeResult === null)', + 'onboardingProgressPersistence.abort()', + 'return', + 'resumedFlow = resumeResult', + ]) expect(mountedFlow).toContain('const resumed = await loadResumeApp()') expect(mountedFlow).toContain('resumedFlow = resumed') - const loadingFinishedIndex = mountedFlow.indexOf('isLoading.value = false') - const initializationIndex = mountedFlow.indexOf('initializeProgressTracking(resumedFlow)') - expect(loadingFinishedIndex).toBeGreaterThanOrEqual(0) - expect(initializationIndex).toBeGreaterThan(loadingFinishedIndex) + expect(mountedFlow).not.toContain('.viewStep(') + expect(mountedFlow.match(/initializeProgressTracking\(resumedFlow\)/g)).toHaveLength(1) + expect(mountedFlow.match(/persistOnboardingProgress\(\)/g)).toHaveLength(2) + const finallyBlock = mountedFlow.slice(mountedFlow.indexOf('finally {')) + expect(finallyBlock).toContain('initializeProgressTracking(resumedFlow)') + expectSourceOrder(mountedFlow, [ + 'resumedFlow = resumeResult', + 'finally {', + 'isHydratingOnboarding.value = false', + 'await persistOnboardingProgress()', + 'isLoading.value = false', + 'initializeProgressTracking(resumedFlow)', + ]) + }) + + it.concurrent('persists telemetry identity metadata with each progress snapshot', () => { + const snapshot = sourceBetween('function snapshotOnboardingProgress(', 'async function persistOnboardingProgress(') + expect(snapshot).toContain('const telemetry = onboardingTelemetry.getProgressMetadata()') + expect(snapshot).toContain('onboardingAttemptId: telemetry.onboardingAttemptId') + expect(snapshot).toContain('lastRunId: telemetry.lastRunId') + }) + + it.concurrent('delegates persistence serialization and barriers to the tested controller', () => { + expect(onboardingSource).toContain(`import type { OnboardingPersistResult } from '~/utils/onboardingProgressPersistence'`) + expect(onboardingSource).not.toContain(`type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict' | 'skipped'`) + expect(onboardingSource).not.toContain('let persistChain') + expect(onboardingSource).not.toContain('let onboardingMountAborted') + expect(onboardingSource).not.toContain('let onboardingPersistenceBlocked') + expect(onboardingSource).toContain('const onboardingProgressPersistence = createOnboardingProgressPersistence({') + expect(onboardingSource).toContain('write: writeOnboardingProgress,') + expect(onboardingSource).toContain(`onError: error => console.error('Failed to persist onboarding progress', error),`) + + const persistenceQueue = sourceBetween('async function persistOnboardingProgress(', 'function schedulePersistOnboardingProgress(') + expect(persistenceQueue).toContain(`status: UserOnboardingStatus = 'in_progress'`) + expect(persistenceQueue).toContain('return onboardingProgressPersistence.persist(status)') + expect(persistenceQueue).not.toContain('writeOnboardingProgress(status)') + expect(persistenceQueue).not.toContain('initializeProgressTracking') + + const writer = sourceBetween('async function writeOnboardingProgress(', 'function resetOnboardingForm(') + expect(writer).toContain(`if (!userId || isHydratingOnboarding.value)\n return 'skipped'`) + expect(writer).toContain(`if (current?.status === 'completed' && status !== 'completed')\n return 'skipped'`) + expectSourceOrder(writer, [ + 'if (error) {', + `console.error('Failed to persist onboarding progress', error)`, + `return 'retryable_failure'`, + ]) + expect(writer).toContain('if (data && main.user?.id === userId) {') + expect(writer).toContain(`main.user = { ...data, image_url: main.user.image_url }\n return 'persisted'`) + expect(writer).toContain(`if (status === 'completed' || main.user?.id !== userId)\n return 'skipped'`) + + const noRowRefresh = writer.slice(writer.indexOf('const { data: latest, error: latestError }')) + const noRowConflict = writer.slice(writer.indexOf(`if (status === 'completed' || main.user?.id !== userId)`)) + expectSourceOrder(noRowConflict, [ + `return 'skipped'`, + 'const { data: latest, error: latestError }', + `return 'conflict'`, + ]) + expect(writer).not.toContain('onboardingProgressPersistence') + expectSourceOrder(noRowRefresh, [ + 'const { data: latest, error: latestError }', + 'if (latestError)', + 'if (latest && main.user?.id === userId)', + `return 'conflict'`, + ]) + expect(writer.trimEnd().endsWith(`return 'conflict'\n}`)).toBe(true) + }) + + it.concurrent('initializes tracking after exhausted retryable initial writes while blocking skipped and conflict outcomes', () => { + expect(onboardingSource).toContain('let onboardingFlowDisposed = false') + expect(onboardingSource).toContain('let onboardingInitialPersistInFlight = false') + expect(onboardingSource).not.toContain('pendingProgressTrackingResumed') + + const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') + const persistenceGuard = 'if (!onboardingFlowDisposed && !onboardingProgressPersistence.isAborted()) {' + const persistenceGuardStart = mountedFlow.indexOf(persistenceGuard) + const persistenceGuardEnd = mountedFlow.indexOf('\n }\n', persistenceGuardStart) + expect(persistenceGuardStart).toBeGreaterThan(mountedFlow.indexOf('isHydratingOnboarding.value = false')) + expect(persistenceGuardEnd).toBeGreaterThan(persistenceGuardStart) + const initialPersistence = mountedFlow.slice(persistenceGuardStart, persistenceGuardEnd) + expect(initialPersistence.match(/persistOnboardingProgress\(\)/g)).toHaveLength(2) + expect(mountedFlow).toContain('function finishOnboardingMount()') + expectSourceOrder(mountedFlow, [ + `let onboardingPersistResult: OnboardingPersistResult = 'skipped'`, + persistenceGuard, + 'onboardingInitialPersistInFlight = true', + 'onboardingPersistResult = await persistOnboardingProgress()', + `if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed)`, + 'onboardingPersistResult = await persistOnboardingProgress()', + 'onboardingInitialPersistInFlight = false', + 'if (onboardingFlowDisposed || onboardingProgressPersistence.isAborted())', + 'return', + 'isLoading.value = false', + 'shouldInitializeOnboardingProgressTracking(', + 'aborted: onboardingProgressPersistence.isAborted()', + 'disposed: onboardingFlowDisposed', + 'initializeProgressTracking(resumedFlow)', + 'finishOnboardingMount()', + ]) + expect(mountedFlow).toContain(`if (shouldInitializeProgressTracking) + initializeProgressTracking(resumedFlow)`) + + const scheduledPersistence = sourceBetween('function schedulePersistOnboardingProgress(', 'async function writeOnboardingProgress(') + expectSourceOrder(scheduledPersistence, [ + 'if (isHydratingOnboarding.value || onboardingProgressPersistence.isBlocked() || onboardingProgressPersistence.isAborted())', + 'return', + 'persistFieldsTimer = setTimeout', + 'void persistOnboardingProgress()', + ]) + + const unmountFlow = sourceBetween('onBeforeUnmount(() => {', 'watch(existingApp,') + expectSourceOrder(unmountFlow, [ + 'onboardingFlowDisposed = true', + 'if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingProgressPersistence.isBlocked() && !onboardingProgressPersistence.isAborted())', + 'void persistOnboardingProgress()', + ]) }) it.concurrent('retains the existing intent compatibility event', () => { - expect(onboardingSource).toContain("pushEvent('onboarding_intent_selected', config.supaHost, {") + expect(onboardingSource).toContain(`pushEvent('onboarding_intent_selected', config.supaHost, {`) }) it.concurrent('keeps Maker+ invitations inside the organization progress step', () => { @@ -66,10 +237,10 @@ describe('app onboarding progress analytics integration', () => { expect(transitionHelpers).toContain('void persistOnboardingProgress()') const intentTransition = sourceBetween('function continueFromIntent()', 'function continuePreOrgDetails()') - expect(intentTransition).toContain("completeAndViewStep('details', { intent: selectedIntent.value })") + expect(intentTransition).toContain(`completeAndViewStep('details', { intent: selectedIntent.value })`) const preOrgDetailsTransition = sourceBetween('function continuePreOrgDetails()', 'async function createOrganizationAndApp()') - expect(preOrgDetailsTransition).toContain("completeAndViewStep('organization', {") + expect(preOrgDetailsTransition).toContain(`completeAndViewStep('organization', {`) expect(preOrgDetailsTransition).toContain('storeImportUsed: hasImportedStoreMetadata.value') const appCreation = sourceBetween('async function createAppRecord(', 'async function seedDemoData()') @@ -78,7 +249,7 @@ describe('app onboarding progress analytics integration', () => { expect(appCreation).toContain('completeAndViewStep(options?.nextStep ?? \'choice\', completionProperties)') const realSetupChoice = sourceBetween('function goToInstallStep()', 'function openDashboard()') - expect(realSetupChoice).toContain("completeAndViewStep('install', {") + expect(realSetupChoice).toContain(`completeAndViewStep('install', {`) expect(realSetupChoice).toContain('appId: createdApp.value.app_id') }) @@ -88,10 +259,10 @@ describe('app onboarding progress analytics integration', () => { expect(backNavigation).toContain('progressTracker?.viewStep(nextStep, previousStep)') expect(onboardingSource).toContain('@click="viewPreviousStep(\'choice\')"') expect(onboardingSource).toContain('@click="viewPreviousStep(\'details\')"') - expect(onboardingSource).toContain("props.preOrg ? viewPreviousStep('intent') : router.push('/apps')") + expect(onboardingSource).toContain(`props.preOrg ? viewPreviousStep('intent') : router.push('/apps')`) expect(onboardingSource).not.toContain('@click="flowStep = \'details\'"') expect(onboardingSource).not.toContain('@click="flowStep = \'choice\'"') - expect(onboardingSource).not.toContain("props.preOrg ? (flowStep = 'intent') : router.push('/apps')") + expect(onboardingSource).not.toContain(`props.preOrg ? (flowStep = 'intent') : router.push('/apps')`) }) it.concurrent('completes only terminal install or setup exits and leaves demo selection incomplete', () => { @@ -102,10 +273,10 @@ describe('app onboarding progress analytics integration', () => { expect(demoAction).toContain('/getting-started') const dashboardExit = sourceBetween('function openDashboard()', 'onMounted(async () => {') - expect(dashboardExit).toContain("if (flowStep.value === 'install' || flowStep.value === 'setup')") + expect(dashboardExit).toContain(`if (flowStep.value === 'install' || flowStep.value === 'setup')`) expect(dashboardExit).toContain('progressTracker?.completeStep(flowStep.value, {') expect(dashboardExit).toContain('appId: createdApp.value.app_id') - expect(dashboardExit).toContain("await persistOnboardingProgress('completed')") + expect(dashboardExit).toContain(`await persistOnboardingProgress('completed')`) expect(dashboardExit).toContain('/getting-started') expect(dashboardExit.indexOf('completeStep')).toBeLessThan(dashboardExit.indexOf('router.push')) expect(dashboardExit).toContain('window.dispatchEvent(new Event(ONBOARDING_DASHBOARD_EXPLORED_EVENT))') @@ -121,6 +292,6 @@ describe('app onboarding progress analytics integration', () => { expect(demoExit.indexOf('window.dispatchEvent')).toBeLessThan(demoExit.indexOf('allowOnboardingDashboardExploration')) const confirmedSidebarExit = sidebarSource.slice(sidebarSource.indexOf('if (requiresOnboardingExplorationConfirmation)'), sidebarSource.indexOf('if (tab.onClick)')) - expect(confirmedSidebarExit.indexOf("lastButtonRole !== 'primary'")).toBeLessThan(confirmedSidebarExit.indexOf('window.dispatchEvent(new Event(ONBOARDING_DASHBOARD_EXPLORED_EVENT))')) + expect(confirmedSidebarExit.indexOf(`lastButtonRole !== 'primary'`)).toBeLessThan(confirmedSidebarExit.indexOf('window.dispatchEvent(new Event(ONBOARDING_DASHBOARD_EXPLORED_EVENT))')) }) }) diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts index a34a9405f5..6d742b8692 100644 --- a/tests/onboarding-progress-analytics.unit.test.ts +++ b/tests/onboarding-progress-analytics.unit.test.ts @@ -1,15 +1,168 @@ import { describe, expect, it, vi } from 'vitest' import { createOnboardingProgressTracker, + createOnboardingTelemetryIdentity, ONBOARDING_ANALYTICS_VERSION, } from '../src/utils/onboardingProgressAnalytics' const steps = ['intent', 'details', 'organization', 'setup'] as const +const ATTEMPT_A1 = '7e64f484-4171-47b6-86f7-0ef5d49e0ef8' +const ATTEMPT_A2 = '89c8aa2f-78df-4ee5-a78d-ef540f33aa43' +const RUN_R1 = 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276' +const RUN_R2_UUID = '9f6a0407-64f9-4696-b447-8dd976674b5c' +const RUN_R2 = `ir_${RUN_R2_UUID}` +const trackerIdentity = { + onboardingAttemptId: ATTEMPT_A1, + onboardingRunId: RUN_R1, +} describe('onboarding progress analytics', () => { + it.concurrent('keeps the resumed attempt while continuing from saved progress', () => { + const capture = vi.fn() + const ids = [ATTEMPT_A2, RUN_R2_UUID] + const identity = createOnboardingTelemetryIdentity({ + capture, + flow: 'pre_org', + idFactory: () => ids.shift()!, + supaHost: 'https://supabase.capgo.test', + }) + identity.prepareResumeCandidate({ + lastRunId: RUN_R1, + onboardingAttemptId: ATTEMPT_A1, + savedStep: 'organization', + steps, + }) + + identity.recordResumeDialogViewed() + identity.recordResumeDialogViewed() + identity.recordResumeContinued() + identity.recordResumeContinued() + + expect(capture.mock.calls.map(call => call[0])).toEqual([ + 'onboarding_resume_dialog_viewed', + 'onboarding_resume_continued', + ]) + expect(capture.mock.calls[0]?.[2]).toEqual({ + flow: 'pre_org', + onboarding_attempt_id: ATTEMPT_A2, + onboarding_run_id: RUN_R2, + onboarding_version: ONBOARDING_ANALYTICS_VERSION, + resume_onboarding_attempt_id: ATTEMPT_A1, + resumed_from_run_id: RUN_R1, + saved_step: 'organization', + step_index: 2, + total_steps: 4, + }) + expect(capture.mock.calls[1]?.[2]).toEqual({ + flow: 'pre_org', + 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: 'organization', + step_index: 2, + total_steps: 4, + }) + expect(identity.attemptId).toBe(ATTEMPT_A1) + expect(identity.runId).toBe(RUN_R2) + 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] + const identity = createOnboardingTelemetryIdentity({ + capture, + flow: 'pre_org', + idFactory: () => ids.shift()!, + supaHost: 'https://supabase.capgo.test', + }) + identity.prepareResumeCandidate({ + lastRunId: RUN_R1, + onboardingAttemptId: ATTEMPT_A1, + savedStep: 'organization', + steps, + }) + + identity.recordResumeDialogViewed() + identity.recordResumeRestarted() + + expect(capture.mock.calls.at(-1)).toEqual([ + 'onboarding_resume_restarted', + 'https://supabase.capgo.test', + { + flow: 'pre_org', + onboarding_attempt_id: ATTEMPT_A2, + onboarding_run_id: RUN_R2, + onboarding_version: ONBOARDING_ANALYTICS_VERSION, + resume_onboarding_attempt_id: ATTEMPT_A1, + resumed_from_run_id: RUN_R1, + saved_step: 'organization', + step_index: 2, + total_steps: 4, + }, + ]) + expect(identity.getProgressMetadata()).toEqual({ + lastRunId: RUN_R2, + onboardingAttemptId: ATTEMPT_A2, + }) + }) + + it.concurrent('keeps resume identity metadata valid when lifecycle capture throws', () => { + const capturedEvents: string[] = [] + const capture = vi.fn((name: string) => { + capturedEvents.push(name) + throw new Error('PostHog unavailable') + }) + const createIdentity = () => { + const ids = [ATTEMPT_A2, RUN_R2_UUID] + const identity = createOnboardingTelemetryIdentity({ + capture, + flow: 'pre_org', + idFactory: () => ids.shift()!, + supaHost: 'https://supabase.capgo.test', + }) + identity.prepareResumeCandidate({ + lastRunId: RUN_R1, + onboardingAttemptId: ATTEMPT_A1, + savedStep: 'organization', + steps, + }) + return identity + } + const continuedIdentity = createIdentity() + const restartedIdentity = createIdentity() + + expect(() => continuedIdentity.recordResumeDialogViewed()).not.toThrow() + expect(() => continuedIdentity.recordResumeContinued()).not.toThrow() + expect(() => restartedIdentity.recordResumeDialogViewed()).not.toThrow() + expect(() => restartedIdentity.recordResumeRestarted()).not.toThrow() + + expect(capturedEvents).toEqual([ + 'onboarding_resume_dialog_viewed', + 'onboarding_resume_continued', + 'onboarding_resume_dialog_viewed', + 'onboarding_resume_restarted', + ]) + expect(continuedIdentity.getProgressMetadata()).toEqual({ + lastRunId: RUN_R2, + onboardingAttemptId: ATTEMPT_A1, + }) + expect(restartedIdentity.getProgressMetadata()).toEqual({ + lastRunId: RUN_R2, + onboardingAttemptId: ATTEMPT_A2, + }) + }) + it.concurrent('reports the initial real step with the stable version and approved properties', () => { const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'pre_org', now: () => 100, @@ -27,7 +180,8 @@ describe('onboarding progress analytics', () => { 'https://supabase.capgo.test', { flow: 'pre_org', - onboarding_attempt_id: expect.any(String), + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R1, onboarding_version: ONBOARDING_ANALYTICS_VERSION, resumed: false, step: 'intent', @@ -37,39 +191,31 @@ describe('onboarding progress analytics', () => { ) }) - it.concurrent('uses one unique attempt id for every event from a tracker instance', () => { - const firstCapture = vi.fn() - const firstTracker = createOnboardingProgressTracker({ - capture: firstCapture, - flow: 'pre_org', - resumed: false, - steps, - supaHost: 'https://supabase.capgo.test', - }) - const secondCapture = vi.fn() - const secondTracker = createOnboardingProgressTracker({ - capture: secondCapture, + it.concurrent('uses the supplied identity for every event from a tracker instance', () => { + const capture = vi.fn() + const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, + capture, flow: 'pre_org', resumed: false, steps, supaHost: 'https://supabase.capgo.test', }) - firstTracker.viewStep('intent') - firstTracker.completeStep('intent', { nextStep: 'details' }) - secondTracker.viewStep('intent') + tracker.viewStep('intent') + tracker.completeStep('intent', { nextStep: 'details' }) - const firstAttemptIds = firstCapture.mock.calls.map(call => call[2]?.onboarding_attempt_id) - const secondAttemptId = secondCapture.mock.calls[0]?.[2]?.onboarding_attempt_id - expect(firstAttemptIds[0]).toEqual(expect.any(String)) - expect(new Set(firstAttemptIds).size).toBe(1) - expect(firstAttemptIds[0]).not.toBe(secondAttemptId) + expect(capture.mock.calls.map(call => call[2])).toEqual([ + expect.objectContaining({ onboarding_attempt_id: ATTEMPT_A1, onboarding_run_id: RUN_R1 }), + expect.objectContaining({ onboarding_attempt_id: ATTEMPT_A1, onboarding_run_id: RUN_R1 }), + ]) }) it.concurrent('reports completion before the next view with duration and narrow context', () => { let now = 1_000 const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'pre_org', now: () => now, @@ -93,7 +239,8 @@ describe('onboarding progress analytics', () => { flow: 'pre_org', intent: 'ota', next_step: 'details', - onboarding_attempt_id: expect.any(String), + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R1, onboarding_version: ONBOARDING_ANALYTICS_VERSION, resumed: false, step: 'intent', @@ -102,7 +249,8 @@ describe('onboarding progress analytics', () => { }) expect(capture.mock.calls[2]?.[2]).toEqual({ flow: 'pre_org', - onboarding_attempt_id: expect.any(String), + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R1, onboarding_version: ONBOARDING_ANALYTICS_VERSION, previous_step: 'intent', resumed: false, @@ -115,6 +263,7 @@ describe('onboarding progress analytics', () => { it.concurrent('associates app-details interaction events with the active onboarding attempt', () => { const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'pre_org', resumed: false, @@ -130,8 +279,9 @@ describe('onboarding progress analytics', () => { expect.objectContaining({ field_length: 11, flow: 'pre_org', - onboarding_attempt_id: expect.any(String), - onboarding_version: 3, + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R1, + onboarding_version: ONBOARDING_ANALYTICS_VERSION, step: 'details', }), ) @@ -140,6 +290,7 @@ describe('onboarding progress analytics', () => { it.concurrent('associates organization interactions with the active attempt', () => { const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'pre_org', resumed: false, @@ -154,8 +305,9 @@ describe('onboarding progress analytics', () => { 'https://supabase.capgo.test', expect.objectContaining({ flow: 'pre_org', - onboarding_attempt_id: expect.any(String), - onboarding_version: 3, + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R1, + onboarding_version: ONBOARDING_ANALYTICS_VERSION, step: 'organization', }), ) @@ -164,6 +316,7 @@ describe('onboarding progress analytics', () => { it.concurrent('captures copy events with the active onboarding attempt context', () => { const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'pre_org', resumed: true, @@ -189,7 +342,8 @@ describe('onboarding progress analytics', () => { existing_app: true, flow: 'pre_org', intent: 'ota', - onboarding_attempt_id: expect.any(String), + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R1, onboarding_version: ONBOARDING_ANALYTICS_VERSION, org_id: 'org-id', resumed: true, @@ -203,6 +357,7 @@ describe('onboarding progress analytics', () => { it.concurrent('captures dashboard exploration with the active onboarding attempt context', () => { const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'pre_org', resumed: true, @@ -219,7 +374,8 @@ describe('onboarding progress analytics', () => { 'https://supabase.capgo.test', expect.objectContaining({ app_id: 'com.example.app', - onboarding_attempt_id: expect.any(String), + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R1, onboarding_version: ONBOARDING_ANALYTICS_VERSION, resumed: true, step: 'setup', @@ -231,6 +387,7 @@ describe('onboarding progress analytics', () => { let now = 10 const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'existing_org', now: () => now, @@ -262,6 +419,7 @@ describe('onboarding progress analytics', () => { it.concurrent('tracks a resumed setup visit through its terminal completion', () => { const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'existing_org', now: () => 50, @@ -293,6 +451,7 @@ describe('onboarding progress analytics', () => { it.concurrent('ignores completion without a matching active visit', () => { const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'existing_org', steps: ['details', 'choice', 'install'], @@ -317,6 +476,7 @@ describe('onboarding progress analytics', () => { throw new Error('PostHog unavailable') }) const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'existing_org', steps: ['details', 'choice', 'install'], @@ -338,6 +498,7 @@ describe('onboarding progress analytics', () => { 'intent', 'next_step', 'onboarding_attempt_id', + 'onboarding_run_id', 'onboarding_version', 'previous_step', 'resumed', diff --git a/tests/onboarding-progress-persistence.unit.test.ts b/tests/onboarding-progress-persistence.unit.test.ts new file mode 100644 index 0000000000..7a5ab1bc14 --- /dev/null +++ b/tests/onboarding-progress-persistence.unit.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createOnboardingProgressPersistence, + shouldInitializeOnboardingProgressTracking, +} from '../src/utils/onboardingProgressPersistence' + +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, reject, resolve } +} + +describe('onboarding progress persistence', () => { + it('skips every status when aborted before enqueue', async () => { + const write = vi.fn(() => Promise.resolve('persisted' as const)) + const persistence = createOnboardingProgressPersistence({ write }) + + persistence.abort() + + await expect(persistence.persist()).resolves.toBe('skipped') + await expect(persistence.persist('completed')).resolves.toBe('skipped') + expect(write).not.toHaveBeenCalled() + expect(persistence.isAborted()).toBe(true) + }) + + it('lets an in-flight write finish but skips queued and later writes after abort', async () => { + const firstWrite = deferred<'persisted'>() + const writeStarted = deferred() + const write = vi.fn(() => { + writeStarted.resolve() + return firstWrite.promise + }) + const persistence = createOnboardingProgressPersistence({ write }) + + const inFlight = persistence.persist() + await writeStarted.promise + const queued = persistence.persist() + persistence.abort() + const laterCompleted = persistence.persist('completed') + firstWrite.resolve('persisted') + + await expect(inFlight).resolves.toBe('persisted') + await expect(queued).resolves.toBe('skipped') + await expect(laterCompleted).resolves.toBe('skipped') + expect(write).toHaveBeenCalledTimes(1) + }) + + it('activates the conflict barrier before queued and later nonterminal writes run', async () => { + const firstWrite = deferred<'conflict'>() + const writeStarted = deferred() + const write = vi.fn(() => { + writeStarted.resolve() + return firstWrite.promise + }) + const persistence = createOnboardingProgressPersistence({ write }) + + const inFlight = persistence.persist() + await writeStarted.promise + const queued = persistence.persist() + firstWrite.resolve('conflict') + + await expect(inFlight).resolves.toBe('conflict') + await expect(queued).resolves.toBe('skipped') + await expect(persistence.persist()).resolves.toBe('skipped') + expect(persistence.isBlocked()).toBe(true) + expect(write).toHaveBeenCalledTimes(1) + }) + + it('allows completed through a conflict barrier but not through abort', async () => { + const write = vi.fn() + .mockResolvedValueOnce('conflict') + .mockResolvedValueOnce('persisted') + const persistence = createOnboardingProgressPersistence({ write }) + + await expect(persistence.persist()).resolves.toBe('conflict') + await expect(persistence.persist('completed')).resolves.toBe('persisted') + persistence.abort() + await expect(persistence.persist('completed')).resolves.toBe('skipped') + + expect(write).toHaveBeenCalledTimes(2) + expect(write).toHaveBeenNthCalledWith(2, 'completed') + }) + + it('turns a rejected write into a retryable failure without poisoning later queue work', async () => { + const failure = new Error('network unavailable') + const rejectedWrite = deferred<'persisted'>() + const writeStarted = deferred() + const write = vi.fn() + .mockImplementationOnce(() => { + writeStarted.resolve() + return rejectedWrite.promise + }) + .mockResolvedValueOnce('persisted') + const onError = vi.fn() + const persistence = createOnboardingProgressPersistence({ write, onError }) + + const first = persistence.persist() + await writeStarted.promise + const queued = persistence.persist() + rejectedWrite.reject(failure) + + await expect(first).resolves.toBe('retryable_failure') + await expect(queued).resolves.toBe('persisted') + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith(failure) + expect(write).toHaveBeenCalledTimes(2) + }) + + it('keeps queue work retryable when both the writer and onError throw', async () => { + const failure = new Error('write crashed') + const errorHandlerFailure = new Error('error handler crashed') + const write = vi.fn() + .mockImplementationOnce(() => { + throw failure + }) + .mockResolvedValueOnce('persisted') + const onError = vi.fn(() => { + throw errorHandlerFailure + }) + const persistence = createOnboardingProgressPersistence({ write, onError }) + + const first = persistence.persist() + const queued = persistence.persist() + + await expect(first).resolves.toBe('retryable_failure') + await expect(queued).resolves.toBe('persisted') + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith(failure) + expect(write).toHaveBeenCalledTimes(2) + }) + + it.each([ + ['persisted', false, false, true], + ['retryable_failure', false, false, true], + ['skipped', false, false, false], + ['conflict', false, false, false], + ['persisted', true, false, false], + ['retryable_failure', false, true, false], + ] as const)( + 'initializes for %s with aborted=%s and disposed=%s only when persistence is confirmed or retryable', + (result, aborted, disposed, expected) => { + expect(shouldInitializeOnboardingProgressTracking(result, { aborted, disposed })).toBe(expected) + }, + ) +}) diff --git a/tests/user-onboarding-progress.unit.test.ts b/tests/user-onboarding-progress.unit.test.ts index e966b8549d..7ab54992e1 100644 --- a/tests/user-onboarding-progress.unit.test.ts +++ b/tests/user-onboarding-progress.unit.test.ts @@ -26,6 +26,8 @@ describe('user onboarding progress', () => { app_id: 'com.acme.app', existing_app: false, org_name: 'Acme Org', + onboarding_attempt_id: '7e64f484-4171-47b6-86f7-0ef5d49e0ef8', + last_run_id: 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276', updated_at: '2026-08-15T00:00:00.000Z', })).toEqual({ status: 'in_progress', @@ -36,6 +38,24 @@ describe('user onboarding progress', () => { app_id: 'com.acme.app', existing_app: false, org_name: 'Acme Org', + onboarding_attempt_id: '7e64f484-4171-47b6-86f7-0ef5d49e0ef8', + last_run_id: 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276', + updated_at: '2026-08-15T00:00:00.000Z', + }) + }) + + it.concurrent('ignores malformed telemetry metadata without invalidating saved progress', () => { + expect(parseUserOnboardingProgress({ + status: 'in_progress', + step: 'details', + flow: 'pre_org', + onboarding_attempt_id: 'not-an-attempt-id', + last_run_id: 'ir_not-a-run-id', + updated_at: '2026-08-15T00:00:00.000Z', + })).toEqual({ + status: 'in_progress', + step: 'details', + flow: 'pre_org', updated_at: '2026-08-15T00:00:00.000Z', }) }) @@ -52,6 +72,8 @@ describe('user onboarding progress', () => { existingAppSetup: 'import', storeUrl: 'https://apps.apple.com/app/id123', orgName: ' ', + onboardingAttemptId: '7e64f484-4171-47b6-86f7-0ef5d49e0ef8', + lastRunId: 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276', updatedAt: '2026-08-15T00:00:00.000Z', }) @@ -64,6 +86,8 @@ describe('user onboarding progress', () => { existing_app: true, existing_app_setup: 'import', store_url: 'https://apps.apple.com/app/id123', + onboarding_attempt_id: '7e64f484-4171-47b6-86f7-0ef5d49e0ef8', + last_run_id: 'ir_6b735b41-f8ea-45b9-a46e-10c8be795276', updated_at: '2026-08-15T00:00:00.000Z', }) expect(JSON.stringify(progress)).not.toContain('data:image')