From 453e60ace9927e5daf76ab51c6b142440cc9271f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 14:22:58 +0200 Subject: [PATCH 01/20] docs(onboarding): design resume telemetry identities --- ...ding-resume-telemetry-identities-design.md | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md 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..0fe53ec4a1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-15-frontend-onboarding-resume-telemetry-identities-design.md @@ -0,0 +1,278 @@ +# 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. A small migration updates the column comment +to document the expanded payload; it does not add a column, constraint, or +index. + +## 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. + +`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 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; +- 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. From 22fddcb8b7c899826d248f435e12c94743b46cc6 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 14:33:48 +0200 Subject: [PATCH 02/20] docs(onboarding): plan resume telemetry identities --- ...-onboarding-resume-telemetry-identities.md | 768 ++++++++++++++++++ ...ding-resume-telemetry-identities-design.md | 5 +- 2 files changed, 770 insertions(+), 3 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-15-frontend-onboarding-resume-telemetry-identities.md 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..de38e65003 --- /dev/null +++ b/docs/superpowers/plans/2026-08-15-frontend-onboarding-resume-telemetry-identities.md @@ -0,0 +1,768 @@ +# 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 implementation/test lines; stop and simplify + before the entire PR reaches 500 changed lines. +- 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. +- Modify `src/components/dashboard/AppOnboardingFlow.vue` — wire the identity + context into persistence, dialog decisions, and tracker initialization. + Budget: about 35 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. Budget: about 25 changed lines. + +No files are created by the implementation. + +### 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 six 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. 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 index 0fe53ec4a1..7f99ebe73c 100644 --- 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 @@ -91,9 +91,8 @@ 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. A small migration updates the column comment -to document the expanded payload; it does not add a column, constraint, or -index. +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 From 01d8d2b5eea55c214422a7a041ed1999a9c43b89 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 14:44:50 +0200 Subject: [PATCH 03/20] feat(onboarding): persist telemetry identity metadata --- src/utils/userOnboardingProgress.ts | 24 ++++++++++++++++++++- tests/user-onboarding-progress.unit.test.ts | 24 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) 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/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') From 7be89b28079f4071f390003345c2dedff6b9c954 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 14:58:03 +0200 Subject: [PATCH 04/20] feat(onboarding): add resume telemetry identity context --- src/utils/onboardingProgressAnalytics.ts | 86 ++++++++- ...onboarding-progress-analytics.unit.test.ts | 165 +++++++++++++++--- 2 files changed, 223 insertions(+), 28 deletions(-) diff --git a/src/utils/onboardingProgressAnalytics.ts b/src/utils/onboardingProgressAnalytics.ts index cfef205665..b0fa371a73 100644 --- a/src/utils/onboardingProgressAnalytics.ts +++ b/src/utils/onboardingProgressAnalytics.ts @@ -27,6 +27,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 @@ -89,10 +103,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 @@ -101,7 +183,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 @@ -114,7 +195,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/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts index b7eeb6dd7c..7ecadbd13d 100644 --- a/tests/onboarding-progress-analytics.unit.test.ts +++ b/tests/onboarding-progress-analytics.unit.test.ts @@ -1,15 +1,122 @@ 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('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 +134,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 +145,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 +193,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 +203,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 +217,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,7 +233,8 @@ describe('onboarding progress analytics', () => { expect.objectContaining({ field_length: 11, flow: 'pre_org', - onboarding_attempt_id: expect.any(String), + onboarding_attempt_id: ATTEMPT_A1, + onboarding_run_id: RUN_R1, onboarding_version: 2, step: 'details', }), @@ -140,6 +244,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, @@ -165,7 +270,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, @@ -179,6 +285,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, @@ -195,7 +302,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', @@ -207,6 +315,7 @@ describe('onboarding progress analytics', () => { let now = 10 const capture = vi.fn() const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'existing_org', now: () => now, @@ -238,6 +347,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, @@ -269,6 +379,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'], @@ -293,6 +404,7 @@ describe('onboarding progress analytics', () => { throw new Error('PostHog unavailable') }) const tracker = createOnboardingProgressTracker({ + ...trackerIdentity, capture, flow: 'existing_org', steps: ['details', 'choice', 'install'], @@ -314,6 +426,7 @@ describe('onboarding progress analytics', () => { 'intent', 'next_step', 'onboarding_attempt_id', + 'onboarding_run_id', 'onboarding_version', 'previous_step', 'resumed', From 9064123fffbd10b3e707d4b47b65835571aa6891 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 15:10:17 +0200 Subject: [PATCH 05/20] feat(onboarding): connect resume telemetry identities --- src/components/dashboard/AppOnboardingFlow.vue | 18 +++++++++++++++++- ...nboarding-progress-integration.unit.test.ts | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 91f62962b9..18cfb04965 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -51,7 +51,7 @@ import { clearOnboardingAppDraft, loadOnboardingAppDraft, } from '~/utils/onboardingAppDraft' -import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker } from '~/utils/onboardingProgressAnalytics' +import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker, createOnboardingTelemetryIdentity } from '~/utils/onboardingProgressAnalytics' import { allowOnboardingDashboardExploration, ONBOARDING_DASHBOARD_EXPLORED_EVENT } from '~/utils/onboardingRedirect' import { slugifyOnboardingSegment } from '~/utils/onboardingSlug' import { @@ -78,6 +78,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)) @@ -322,6 +323,8 @@ function initializeProgressTracking(resumed: boolean) { resumed, steps: trackedSteps, supaHost: config.supaHost, + onboardingAttemptId: onboardingTelemetry.attemptId, + onboardingRunId: onboardingTelemetry.runId, }) progressTracker.viewStep(flowStep.value) if (pendingDashboardExplored) @@ -354,6 +357,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), @@ -367,6 +371,8 @@ function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress' importedStoreAppId: importedStoreAppId.value, orgName: orgNameInput.value, estimatedUsersIndex: estimatedUsersIndex.value, + onboardingAttemptId: telemetry.onboardingAttemptId, + lastRunId: telemetry.lastRunId, }) } @@ -509,6 +515,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'), @@ -518,15 +531,18 @@ async function maybeResumeSavedOnboarding() { { text: t('onboarding-resume-continue'), id: 'onboarding-resume-continue', role: 'primary' }, ], }) + onboardingTelemetry.recordResumeDialogViewed() await dialogStore.onDialogDismiss() if (dialogStore.lastButtonRole === 'onboarding-resume-restart') { + onboardingTelemetry.recordResumeRestarted() resetOnboardingForm() existingApp.value = true existingAppSetup.value = 'manual' return false } + onboardingTelemetry.recordResumeContinued() applyOnboardingProgress(saved) return true } diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index c9bbb87183..db7af67aa6 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -16,7 +16,7 @@ function sourceBetween(start: string, end: string) { 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'") @@ -25,8 +25,17 @@ describe('app onboarding progress analytics integration', () => { 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)') + 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(') + const resumeLoader = sourceBetween('async function loadResumeApp()', 'async function importStoreMetadata()') expect(resumeLoader).not.toContain('initializeProgressTracking') expect(resumeLoader).not.toContain('viewStep') @@ -42,6 +51,13 @@ describe('app onboarding progress analytics integration', () => { expect(initializationIndex).toBeGreaterThan(loadingFinishedIndex) }) + 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('retains the existing intent compatibility event', () => { expect(onboardingSource).toContain("pushEvent('onboarding_intent_selected', config.supaHost, {") }) From 266595bc89213359912d75cc2945116287359ef6 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 15:26:20 +0200 Subject: [PATCH 06/20] test(onboarding): enforce resume telemetry ordering --- ...boarding-progress-integration.unit.test.ts | 72 +++++++++++++++---- 1 file changed, 57 insertions(+), 15 deletions(-) diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index db7af67aa6..b55232d5c5 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -14,20 +14,30 @@ 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, createOnboardingTelemetryIdentity } 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({') @@ -35,16 +45,48 @@ describe('app onboarding progress analytics integration', () => { 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')` + 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', + ]) + expectSourceOrder(resumeDialog.slice(restartBranchEnd), [ + 'onboardingTelemetry.recordResumeContinued()', + 'applyOnboardingProgress(saved)', + ]) 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(mountedFlow).toContain('let resumedFlow = false') expect(mountedFlow).toContain('resumedFlow = await maybeResumeSavedOnboarding()') expect(mountedFlow).toContain('const resumed = await loadResumeApp()') expect(mountedFlow).toContain('resumedFlow = resumed') + expect(mountedFlow).not.toContain('.viewStep(') + expect(mountedFlow.match(/initializeProgressTracking\(resumedFlow\)/g)).toHaveLength(1) + const finallyBlock = mountedFlow.slice(mountedFlow.indexOf('finally {')) + expect(finallyBlock).toContain('initializeProgressTracking(resumedFlow)') + expectSourceOrder(mountedFlow, [ + 'resumedFlow = await maybeResumeSavedOnboarding()', + 'finally {', + 'initializeProgressTracking(resumedFlow)', + ]) const loadingFinishedIndex = mountedFlow.indexOf('isLoading.value = false') const initializationIndex = mountedFlow.indexOf('initializeProgressTracking(resumedFlow)') expect(loadingFinishedIndex).toBeGreaterThanOrEqual(0) @@ -59,7 +101,7 @@ describe('app onboarding progress analytics integration', () => { }) 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 the unload warning scoped to unfinished pre-org onboarding', () => { @@ -76,10 +118,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()') @@ -88,7 +130,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') }) @@ -98,10 +140,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', () => { @@ -112,10 +154,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))') @@ -131,6 +173,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))')) }) }) From e46b4762a54287edf138fc66349cc2005c77e96f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 15:39:51 +0200 Subject: [PATCH 07/20] fix(onboarding): persist resume identity before step view --- src/components/dashboard/AppOnboardingFlow.vue | 2 +- tests/app-onboarding-progress-integration.unit.test.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 18cfb04965..6849b28fab 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -1421,9 +1421,9 @@ onMounted(async () => { } finally { isHydratingOnboarding.value = false + await persistOnboardingProgress() isLoading.value = false initializeProgressTracking(resumedFlow) - void persistOnboardingProgress() } }) diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index b55232d5c5..a49aa2caad 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -80,17 +80,17 @@ describe('app onboarding progress analytics integration', () => { expect(mountedFlow).toContain('resumedFlow = resumed') expect(mountedFlow).not.toContain('.viewStep(') expect(mountedFlow.match(/initializeProgressTracking\(resumedFlow\)/g)).toHaveLength(1) + expect(mountedFlow.match(/persistOnboardingProgress\(\)/g)).toHaveLength(1) const finallyBlock = mountedFlow.slice(mountedFlow.indexOf('finally {')) expect(finallyBlock).toContain('initializeProgressTracking(resumedFlow)') expectSourceOrder(mountedFlow, [ 'resumedFlow = await maybeResumeSavedOnboarding()', 'finally {', + 'isHydratingOnboarding.value = false', + 'await persistOnboardingProgress()', + 'isLoading.value = false', 'initializeProgressTracking(resumedFlow)', ]) - const loadingFinishedIndex = mountedFlow.indexOf('isLoading.value = false') - const initializationIndex = mountedFlow.indexOf('initializeProgressTracking(resumedFlow)') - expect(loadingFinishedIndex).toBeGreaterThanOrEqual(0) - expect(initializationIndex).toBeGreaterThan(loadingFinishedIndex) }) it.concurrent('persists telemetry identity metadata with each progress snapshot', () => { From 370edc0497ee2bef22a2928551c363a94b62b2df Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 15:49:44 +0200 Subject: [PATCH 08/20] fix(onboarding): avoid resume tracking after unmount --- .../dashboard/AppOnboardingFlow.vue | 16 +++++++++--- ...boarding-progress-integration.unit.test.ts | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 6849b28fab..98bcfc64b0 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -305,6 +305,8 @@ let progressTracker: ReturnType | null = let persistChain = Promise.resolve() let persistFieldsTimer: ReturnType | undefined let pendingDashboardExplored = false +let onboardingFlowDisposed = false +let onboardingInitialPersistInFlight = false function trackV2DetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) { if (props.preOrg) @@ -1421,17 +1423,25 @@ onMounted(async () => { } finally { isHydratingOnboarding.value = false + onboardingInitialPersistInFlight = true await persistOnboardingProgress() - isLoading.value = false - initializeProgressTracking(resumedFlow) + onboardingInitialPersistInFlight = false + function finishOnboardingMount() { + if (onboardingFlowDisposed) + return + isLoading.value = false + 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) void persistOnboardingProgress() if (localIconPreview.value.startsWith('blob:')) diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index a49aa2caad..a5c9576f34 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -100,6 +100,31 @@ describe('app onboarding progress analytics integration', () => { expect(snapshot).toContain('lastRunId: telemetry.lastRunId') }) + it.concurrent('does not initialize tracking after unmount during the initial persistence', () => { + expect(onboardingSource).toContain('let onboardingFlowDisposed = false') + expect(onboardingSource).toContain('let onboardingInitialPersistInFlight = false') + + const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') + expect(mountedFlow).toContain('function finishOnboardingMount()') + expectSourceOrder(mountedFlow, [ + 'onboardingInitialPersistInFlight = true', + 'await persistOnboardingProgress()', + 'onboardingInitialPersistInFlight = false', + 'if (onboardingFlowDisposed)', + 'return', + 'isLoading.value = false', + 'initializeProgressTracking(resumedFlow)', + 'finishOnboardingMount()', + ]) + + const unmountFlow = sourceBetween('onBeforeUnmount(() => {', 'watch(existingApp,') + expectSourceOrder(unmountFlow, [ + 'onboardingFlowDisposed = true', + 'if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight)', + 'void persistOnboardingProgress()', + ]) + }) + it.concurrent('retains the existing intent compatibility event', () => { expect(onboardingSource).toContain(`pushEvent('onboarding_intent_selected', config.supaHost, {`) }) From 504b952823e6003b60421427d2533ca4b6627e5b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 16:19:56 +0200 Subject: [PATCH 09/20] fix(onboarding): gate resume tracking on persisted identity --- .../dashboard/AppOnboardingFlow.vue | 27 +++++++++++----- ...boarding-progress-integration.unit.test.ts | 32 +++++++++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 98bcfc64b0..f717d59fac 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -302,7 +302,7 @@ 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 persistChain = Promise.resolve(true) let persistFieldsTimer: ReturnType | undefined let pendingDashboardExplored = false let onboardingFlowDisposed = false @@ -383,6 +383,7 @@ async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_prog .then(() => writeOnboardingProgress(status)) .catch((error) => { console.error('Failed to persist onboarding progress', error) + return false }) return persistChain } @@ -399,11 +400,11 @@ function schedulePersistOnboardingProgress() { async function writeOnboardingProgress(status: UserOnboardingStatus) { const userId = onboardingUserId.value if (!userId || isHydratingOnboarding.value) - return + return false const current = parseUserOnboardingProgress(main.user?.onboarding) if (current?.status === 'completed' && status !== 'completed') - return + return false const progress = snapshotOnboardingProgress(status) const onboarding = progress as unknown as Json @@ -424,16 +425,16 @@ async function writeOnboardingProgress(status: UserOnboardingStatus) { if (error) { console.error('Failed to persist onboarding progress', error) - return + return false } if (data && main.user?.id === userId) { main.user = { ...data, image_url: main.user.image_url } - return + return true } if (status === 'completed' || main.user?.id !== userId) - return + return false const { data: latest, error: latestError } = await supabase .from('users') @@ -444,6 +445,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 false } function resetOnboardingForm() { @@ -536,6 +538,9 @@ async function maybeResumeSavedOnboarding() { onboardingTelemetry.recordResumeDialogViewed() await dialogStore.onDialogDismiss() + if (onboardingFlowDisposed) + return false + if (dialogStore.lastButtonRole === 'onboarding-resume-restart') { onboardingTelemetry.recordResumeRestarted() resetOnboardingForm() @@ -544,6 +549,9 @@ async function maybeResumeSavedOnboarding() { return false } + if (dialogStore.lastButtonRole !== 'onboarding-resume-continue') + return false + onboardingTelemetry.recordResumeContinued() applyOnboardingProgress(saved) return true @@ -1424,13 +1432,16 @@ onMounted(async () => { finally { isHydratingOnboarding.value = false onboardingInitialPersistInFlight = true - await persistOnboardingProgress() + let onboardingIdentityPersisted = await persistOnboardingProgress() + if (!onboardingIdentityPersisted && !onboardingFlowDisposed) + onboardingIdentityPersisted = await persistOnboardingProgress() onboardingInitialPersistInFlight = false function finishOnboardingMount() { if (onboardingFlowDisposed) return isLoading.value = false - initializeProgressTracking(resumedFlow) + if (onboardingIdentityPersisted) + initializeProgressTracking(resumedFlow) } finishOnboardingMount() } diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index a5c9576f34..c791d4ee23 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -53,6 +53,12 @@ describe('app onboarding progress analytics integration', () => { ]) const restartCheck = `if (dialogStore.lastButtonRole === 'onboarding-resume-restart')` + expectSourceOrder(resumeDialog, [ + 'await dialogStore.onDialogDismiss()', + 'if (onboardingFlowDisposed)', + 'return false', + restartCheck, + ]) const restartBranchStart = resumeDialog.indexOf(restartCheck) const restartBranchEnd = resumeDialog.indexOf('\n }\n', restartBranchStart) expect(restartBranchStart).toBeGreaterThan(resumeDialog.indexOf('await dialogStore.onDialogDismiss()')) @@ -64,7 +70,10 @@ describe('app onboarding progress analytics integration', () => { 'resetOnboardingForm()', 'return false', ]) + const continueCheck = `if (dialogStore.lastButtonRole !== 'onboarding-resume-continue')` expectSourceOrder(resumeDialog.slice(restartBranchEnd), [ + continueCheck, + 'return false', 'onboardingTelemetry.recordResumeContinued()', 'applyOnboardingProgress(saved)', ]) @@ -80,7 +89,7 @@ describe('app onboarding progress analytics integration', () => { expect(mountedFlow).toContain('resumedFlow = resumed') expect(mountedFlow).not.toContain('.viewStep(') expect(mountedFlow.match(/initializeProgressTracking\(resumedFlow\)/g)).toHaveLength(1) - expect(mountedFlow.match(/persistOnboardingProgress\(\)/g)).toHaveLength(1) + expect(mountedFlow.match(/persistOnboardingProgress\(\)/g)).toHaveLength(2) const finallyBlock = mountedFlow.slice(mountedFlow.indexOf('finally {')) expect(finallyBlock).toContain('initializeProgressTracking(resumedFlow)') expectSourceOrder(mountedFlow, [ @@ -100,6 +109,22 @@ describe('app onboarding progress analytics integration', () => { expect(snapshot).toContain('lastRunId: telemetry.lastRunId') }) + it.concurrent('reports whether the requested progress write updated the current user', () => { + const persistenceQueue = sourceBetween('async function persistOnboardingProgress(', 'function schedulePersistOnboardingProgress(') + expect(persistenceQueue).toContain('return false') + expect(persistenceQueue).toContain('return persistChain') + + const writer = sourceBetween('async function writeOnboardingProgress(', 'function resetOnboardingForm(') + expect(writer).toContain('if (!userId || isHydratingOnboarding.value)\n return false') + expect(writer).toContain(`if (current?.status === 'completed' && status !== 'completed')\n return false`) + expect(writer).toContain('if (data && main.user?.id === userId) {') + expect(writer).toContain('main.user = { ...data, image_url: main.user.image_url }\n return true') + expect(writer).toContain(`if (status === 'completed' || main.user?.id !== userId)\n return false`) + expect(writer.match(/return true/g)).toHaveLength(1) + expect(writer.match(/return false/g)).toHaveLength(5) + expect(writer.trimEnd().endsWith('return false\n}')).toBe(true) + }) + it.concurrent('does not initialize tracking after unmount during the initial persistence', () => { expect(onboardingSource).toContain('let onboardingFlowDisposed = false') expect(onboardingSource).toContain('let onboardingInitialPersistInFlight = false') @@ -108,11 +133,14 @@ describe('app onboarding progress analytics integration', () => { expect(mountedFlow).toContain('function finishOnboardingMount()') expectSourceOrder(mountedFlow, [ 'onboardingInitialPersistInFlight = true', - 'await persistOnboardingProgress()', + 'let onboardingIdentityPersisted = await persistOnboardingProgress()', + 'if (!onboardingIdentityPersisted && !onboardingFlowDisposed)', + 'onboardingIdentityPersisted = await persistOnboardingProgress()', 'onboardingInitialPersistInFlight = false', 'if (onboardingFlowDisposed)', 'return', 'isLoading.value = false', + 'if (onboardingIdentityPersisted)', 'initializeProgressTracking(resumedFlow)', 'finishOnboardingMount()', ]) From a551e4af744708b34d3da0c8f5cf41f4afda6cf5 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 16:28:40 +0200 Subject: [PATCH 10/20] fix(onboarding): abort dismissed resume mounts --- .../dashboard/AppOnboardingFlow.vue | 25 +++++++++++----- ...boarding-progress-integration.unit.test.ts | 30 +++++++++++++++---- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index f717d59fac..5b1fa5fcd9 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -539,7 +539,7 @@ async function maybeResumeSavedOnboarding() { await dialogStore.onDialogDismiss() if (onboardingFlowDisposed) - return false + return null if (dialogStore.lastButtonRole === 'onboarding-resume-restart') { onboardingTelemetry.recordResumeRestarted() @@ -550,7 +550,7 @@ async function maybeResumeSavedOnboarding() { } if (dialogStore.lastButtonRole !== 'onboarding-resume-continue') - return false + return null onboardingTelemetry.recordResumeContinued() applyOnboardingProgress(saved) @@ -1408,11 +1408,17 @@ function trackDashboardExplored() { onMounted(async () => { window.addEventListener(ONBOARDING_DASHBOARD_EXPLORED_EVENT, trackDashboardExplored) let resumedFlow = false + let onboardingMountAborted = false isLoading.value = true isHydratingOnboarding.value = true try { if (props.preOrg) { - resumedFlow = await maybeResumeSavedOnboarding() + const resumeResult = await maybeResumeSavedOnboarding() + if (resumeResult === null) { + onboardingMountAborted = true + return + } + resumedFlow = resumeResult return } @@ -1431,16 +1437,19 @@ onMounted(async () => { } finally { isHydratingOnboarding.value = false - onboardingInitialPersistInFlight = true - let onboardingIdentityPersisted = await persistOnboardingProgress() - if (!onboardingIdentityPersisted && !onboardingFlowDisposed) + let onboardingIdentityPersisted = false + if (!onboardingMountAborted) { + onboardingInitialPersistInFlight = true onboardingIdentityPersisted = await persistOnboardingProgress() - onboardingInitialPersistInFlight = false + if (!onboardingIdentityPersisted && !onboardingFlowDisposed) + onboardingIdentityPersisted = await persistOnboardingProgress() + onboardingInitialPersistInFlight = false + } function finishOnboardingMount() { if (onboardingFlowDisposed) return isLoading.value = false - if (onboardingIdentityPersisted) + if (!onboardingMountAborted && onboardingIdentityPersisted) initializeProgressTracking(resumedFlow) } finishOnboardingMount() diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index c791d4ee23..43f1a90b72 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -56,7 +56,7 @@ describe('app onboarding progress analytics integration', () => { expectSourceOrder(resumeDialog, [ 'await dialogStore.onDialogDismiss()', 'if (onboardingFlowDisposed)', - 'return false', + 'return null', restartCheck, ]) const restartBranchStart = resumeDialog.indexOf(restartCheck) @@ -73,18 +73,27 @@ describe('app onboarding progress analytics integration', () => { const continueCheck = `if (dialogStore.lastButtonRole !== 'onboarding-resume-continue')` expectSourceOrder(resumeDialog.slice(restartBranchEnd), [ continueCheck, - 'return false', + '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 = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') + expect(mountedFlow).toContain('let onboardingMountAborted = false') expect(mountedFlow).toContain('let resumedFlow = false') - expect(mountedFlow).toContain('resumedFlow = await maybeResumeSavedOnboarding()') + expectSourceOrder(mountedFlow, [ + 'const resumeResult = await maybeResumeSavedOnboarding()', + 'if (resumeResult === null)', + 'onboardingMountAborted = true', + 'return', + 'resumedFlow = resumeResult', + ]) expect(mountedFlow).toContain('const resumed = await loadResumeApp()') expect(mountedFlow).toContain('resumedFlow = resumed') expect(mountedFlow).not.toContain('.viewStep(') @@ -93,7 +102,7 @@ describe('app onboarding progress analytics integration', () => { const finallyBlock = mountedFlow.slice(mountedFlow.indexOf('finally {')) expect(finallyBlock).toContain('initializeProgressTracking(resumedFlow)') expectSourceOrder(mountedFlow, [ - 'resumedFlow = await maybeResumeSavedOnboarding()', + 'resumedFlow = resumeResult', 'finally {', 'isHydratingOnboarding.value = false', 'await persistOnboardingProgress()', @@ -130,17 +139,26 @@ describe('app onboarding progress analytics integration', () => { expect(onboardingSource).toContain('let onboardingInitialPersistInFlight = false') const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') + const persistenceGuard = 'if (!onboardingMountAborted) {' + 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 onboardingIdentityPersisted = false', + persistenceGuard, 'onboardingInitialPersistInFlight = true', - 'let onboardingIdentityPersisted = await persistOnboardingProgress()', + 'onboardingIdentityPersisted = await persistOnboardingProgress()', 'if (!onboardingIdentityPersisted && !onboardingFlowDisposed)', 'onboardingIdentityPersisted = await persistOnboardingProgress()', 'onboardingInitialPersistInFlight = false', 'if (onboardingFlowDisposed)', 'return', 'isLoading.value = false', - 'if (onboardingIdentityPersisted)', + 'if (!onboardingMountAborted && onboardingIdentityPersisted)', 'initializeProgressTracking(resumedFlow)', 'finishOnboardingMount()', ]) From 0903f610667caf5cb056270c4050a1ecf8fc3c4f Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 16:36:40 +0200 Subject: [PATCH 11/20] fix(onboarding): preserve progress on persist conflicts --- .../dashboard/AppOnboardingFlow.vue | 27 ++++++------ ...boarding-progress-integration.unit.test.ts | 41 ++++++++++++------- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 5b1fa5fcd9..d7c2cca267 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -88,6 +88,7 @@ type AppRow = Omit & type StandardFlowStep = 'details' | 'choice' | 'install' | 'setup' type PreOrgFlowStep = 'intent' | 'details' | 'organization' | 'setup' type OnboardingFlowStep = StandardFlowStep | PreOrgFlowStep +type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict_or_skipped' interface UserCountStop { value: number @@ -302,7 +303,7 @@ 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(true) +let persistChain: Promise = Promise.resolve('persisted') let persistFieldsTimer: ReturnType | undefined let pendingDashboardExplored = false let onboardingFlowDisposed = false @@ -383,7 +384,7 @@ async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_prog .then(() => writeOnboardingProgress(status)) .catch((error) => { console.error('Failed to persist onboarding progress', error) - return false + return 'retryable_failure' as const }) return persistChain } @@ -400,11 +401,11 @@ function schedulePersistOnboardingProgress() { async function writeOnboardingProgress(status: UserOnboardingStatus) { const userId = onboardingUserId.value if (!userId || isHydratingOnboarding.value) - return false + return 'conflict_or_skipped' const current = parseUserOnboardingProgress(main.user?.onboarding) if (current?.status === 'completed' && status !== 'completed') - return false + return 'conflict_or_skipped' const progress = snapshotOnboardingProgress(status) const onboarding = progress as unknown as Json @@ -425,16 +426,16 @@ async function writeOnboardingProgress(status: UserOnboardingStatus) { if (error) { console.error('Failed to persist onboarding progress', error) - return false + return 'retryable_failure' } if (data && main.user?.id === userId) { main.user = { ...data, image_url: main.user.image_url } - return true + return 'persisted' } if (status === 'completed' || main.user?.id !== userId) - return false + return 'conflict_or_skipped' const { data: latest, error: latestError } = await supabase .from('users') @@ -445,7 +446,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 false + return 'conflict_or_skipped' } function resetOnboardingForm() { @@ -1437,19 +1438,19 @@ onMounted(async () => { } finally { isHydratingOnboarding.value = false - let onboardingIdentityPersisted = false + let onboardingPersistResult: OnboardingPersistResult = 'conflict_or_skipped' if (!onboardingMountAborted) { onboardingInitialPersistInFlight = true - onboardingIdentityPersisted = await persistOnboardingProgress() - if (!onboardingIdentityPersisted && !onboardingFlowDisposed) - onboardingIdentityPersisted = await persistOnboardingProgress() + onboardingPersistResult = await persistOnboardingProgress() + if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed) + onboardingPersistResult = await persistOnboardingProgress() onboardingInitialPersistInFlight = false } function finishOnboardingMount() { if (onboardingFlowDisposed) return isLoading.value = false - if (!onboardingMountAborted && onboardingIdentityPersisted) + if (!onboardingMountAborted && onboardingPersistResult === 'persisted') initializeProgressTracking(resumedFlow) } finishOnboardingMount() diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 43f1a90b72..9c41538a82 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -118,20 +118,33 @@ describe('app onboarding progress analytics integration', () => { expect(snapshot).toContain('lastRunId: telemetry.lastRunId') }) - it.concurrent('reports whether the requested progress write updated the current user', () => { + it.concurrent('distinguishes persisted, retryable, and conflict progress outcomes', () => { + expect(onboardingSource).toContain(`type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict_or_skipped'`) + const persistenceQueue = sourceBetween('async function persistOnboardingProgress(', 'function schedulePersistOnboardingProgress(') - expect(persistenceQueue).toContain('return false') + expect(persistenceQueue).toContain(`return 'retryable_failure'`) expect(persistenceQueue).toContain('return persistChain') const writer = sourceBetween('async function writeOnboardingProgress(', 'function resetOnboardingForm(') - expect(writer).toContain('if (!userId || isHydratingOnboarding.value)\n return false') - expect(writer).toContain(`if (current?.status === 'completed' && status !== 'completed')\n return false`) + expect(writer).toContain(`if (!userId || isHydratingOnboarding.value)\n return 'conflict_or_skipped'`) + expect(writer).toContain(`if (current?.status === 'completed' && status !== 'completed')\n return 'conflict_or_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 true') - expect(writer).toContain(`if (status === 'completed' || main.user?.id !== userId)\n return false`) - expect(writer.match(/return true/g)).toHaveLength(1) - expect(writer.match(/return false/g)).toHaveLength(5) - expect(writer.trimEnd().endsWith('return false\n}')).toBe(true) + 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 'conflict_or_skipped'`) + + const noRowRefresh = writer.slice(writer.indexOf('const { data: latest, error: latestError }')) + expectSourceOrder(noRowRefresh, [ + 'const { data: latest, error: latestError }', + 'if (latestError)', + 'if (latest && main.user?.id === userId)', + `return 'conflict_or_skipped'`, + ]) + expect(writer.trimEnd().endsWith(`return 'conflict_or_skipped'\n}`)).toBe(true) }) it.concurrent('does not initialize tracking after unmount during the initial persistence', () => { @@ -148,17 +161,17 @@ describe('app onboarding progress analytics integration', () => { expect(initialPersistence.match(/persistOnboardingProgress\(\)/g)).toHaveLength(2) expect(mountedFlow).toContain('function finishOnboardingMount()') expectSourceOrder(mountedFlow, [ - 'let onboardingIdentityPersisted = false', + `let onboardingPersistResult: OnboardingPersistResult = 'conflict_or_skipped'`, persistenceGuard, 'onboardingInitialPersistInFlight = true', - 'onboardingIdentityPersisted = await persistOnboardingProgress()', - 'if (!onboardingIdentityPersisted && !onboardingFlowDisposed)', - 'onboardingIdentityPersisted = await persistOnboardingProgress()', + 'onboardingPersistResult = await persistOnboardingProgress()', + `if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed)`, + 'onboardingPersistResult = await persistOnboardingProgress()', 'onboardingInitialPersistInFlight = false', 'if (onboardingFlowDisposed)', 'return', 'isLoading.value = false', - 'if (!onboardingMountAborted && onboardingIdentityPersisted)', + `if (!onboardingMountAborted && onboardingPersistResult === 'persisted')`, 'initializeProgressTracking(resumedFlow)', 'finishOnboardingMount()', ]) From f2d45606d76d6aceb0b0c7a01d217223270491e3 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 16:44:06 +0200 Subject: [PATCH 12/20] fix(onboarding): keep persistence blocked after conflicts --- .../dashboard/AppOnboardingFlow.vue | 21 ++++++---- ...boarding-progress-integration.unit.test.ts | 40 +++++++++++++------ 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index d7c2cca267..f90663887e 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -88,7 +88,7 @@ type AppRow = Omit & type StandardFlowStep = 'details' | 'choice' | 'install' | 'setup' type PreOrgFlowStep = 'intent' | 'details' | 'organization' | 'setup' type OnboardingFlowStep = StandardFlowStep | PreOrgFlowStep -type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict_or_skipped' +type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict' | 'skipped' interface UserCountStop { value: number @@ -308,6 +308,7 @@ let persistFieldsTimer: ReturnType | undefined let pendingDashboardExplored = false let onboardingFlowDisposed = false let onboardingInitialPersistInFlight = false +let onboardingPersistenceBlocked = false function trackV2DetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) { if (props.preOrg) @@ -380,6 +381,8 @@ function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress' } async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_progress') { + if (onboardingPersistenceBlocked) + return 'skipped' persistChain = persistChain .then(() => writeOnboardingProgress(status)) .catch((error) => { @@ -390,7 +393,7 @@ async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_prog } function schedulePersistOnboardingProgress() { - if (isHydratingOnboarding.value) + if (isHydratingOnboarding.value || onboardingPersistenceBlocked) return window.clearTimeout(persistFieldsTimer) persistFieldsTimer = setTimeout(() => { @@ -401,11 +404,11 @@ function schedulePersistOnboardingProgress() { async function writeOnboardingProgress(status: UserOnboardingStatus) { const userId = onboardingUserId.value if (!userId || isHydratingOnboarding.value) - return 'conflict_or_skipped' + return 'skipped' const current = parseUserOnboardingProgress(main.user?.onboarding) if (current?.status === 'completed' && status !== 'completed') - return 'conflict_or_skipped' + return 'skipped' const progress = snapshotOnboardingProgress(status) const onboarding = progress as unknown as Json @@ -435,7 +438,7 @@ async function writeOnboardingProgress(status: UserOnboardingStatus) { } if (status === 'completed' || main.user?.id !== userId) - return 'conflict_or_skipped' + return 'skipped' const { data: latest, error: latestError } = await supabase .from('users') @@ -446,7 +449,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_or_skipped' + return 'conflict' } function resetOnboardingForm() { @@ -1438,12 +1441,14 @@ onMounted(async () => { } finally { isHydratingOnboarding.value = false - let onboardingPersistResult: OnboardingPersistResult = 'conflict_or_skipped' + let onboardingPersistResult: OnboardingPersistResult = 'skipped' if (!onboardingMountAborted) { onboardingInitialPersistInFlight = true onboardingPersistResult = await persistOnboardingProgress() if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed) onboardingPersistResult = await persistOnboardingProgress() + if (onboardingPersistResult === 'conflict') + onboardingPersistenceBlocked = true onboardingInitialPersistInFlight = false } function finishOnboardingMount() { @@ -1462,7 +1467,7 @@ onBeforeUnmount(() => { window.clearTimeout(persistFieldsTimer) window.removeEventListener(ONBOARDING_DASHBOARD_EXPLORED_EVENT, trackDashboardExplored) detailsFieldTracker.dispose() - if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight) + if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingPersistenceBlocked) void persistOnboardingProgress() if (localIconPreview.value.startsWith('blob:')) diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 9c41538a82..3881e6309e 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -118,16 +118,21 @@ describe('app onboarding progress analytics integration', () => { expect(snapshot).toContain('lastRunId: telemetry.lastRunId') }) - it.concurrent('distinguishes persisted, retryable, and conflict progress outcomes', () => { - expect(onboardingSource).toContain(`type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict_or_skipped'`) + it.concurrent('distinguishes persisted, retryable, conflict, and skipped progress outcomes', () => { + expect(onboardingSource).toContain(`type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict' | 'skipped'`) const persistenceQueue = sourceBetween('async function persistOnboardingProgress(', 'function schedulePersistOnboardingProgress(') - expect(persistenceQueue).toContain(`return 'retryable_failure'`) - expect(persistenceQueue).toContain('return persistChain') + expectSourceOrder(persistenceQueue, [ + 'if (onboardingPersistenceBlocked)', + `return 'skipped'`, + 'persistChain = persistChain', + `return 'retryable_failure'`, + 'return persistChain', + ]) const writer = sourceBetween('async function writeOnboardingProgress(', 'function resetOnboardingForm(') - expect(writer).toContain(`if (!userId || isHydratingOnboarding.value)\n return 'conflict_or_skipped'`) - expect(writer).toContain(`if (current?.status === 'completed' && status !== 'completed')\n return 'conflict_or_skipped'`) + 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)`, @@ -135,21 +140,22 @@ describe('app onboarding progress analytics integration', () => { ]) 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 'conflict_or_skipped'`) + expect(writer).toContain(`if (status === 'completed' || main.user?.id !== userId)\n return 'skipped'`) const noRowRefresh = writer.slice(writer.indexOf('const { data: latest, error: latestError }')) expectSourceOrder(noRowRefresh, [ 'const { data: latest, error: latestError }', 'if (latestError)', 'if (latest && main.user?.id === userId)', - `return 'conflict_or_skipped'`, + `return 'conflict'`, ]) - expect(writer.trimEnd().endsWith(`return 'conflict_or_skipped'\n}`)).toBe(true) + expect(writer.trimEnd().endsWith(`return 'conflict'\n}`)).toBe(true) }) - it.concurrent('does not initialize tracking after unmount during the initial persistence', () => { + it.concurrent('blocks tracking and later writes after an initial conflict or disposal', () => { expect(onboardingSource).toContain('let onboardingFlowDisposed = false') expect(onboardingSource).toContain('let onboardingInitialPersistInFlight = false') + expect(onboardingSource).toContain('let onboardingPersistenceBlocked = false') const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') const persistenceGuard = 'if (!onboardingMountAborted) {' @@ -161,12 +167,14 @@ describe('app onboarding progress analytics integration', () => { expect(initialPersistence.match(/persistOnboardingProgress\(\)/g)).toHaveLength(2) expect(mountedFlow).toContain('function finishOnboardingMount()') expectSourceOrder(mountedFlow, [ - `let onboardingPersistResult: OnboardingPersistResult = 'conflict_or_skipped'`, + `let onboardingPersistResult: OnboardingPersistResult = 'skipped'`, persistenceGuard, 'onboardingInitialPersistInFlight = true', 'onboardingPersistResult = await persistOnboardingProgress()', `if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed)`, 'onboardingPersistResult = await persistOnboardingProgress()', + `if (onboardingPersistResult === 'conflict')`, + 'onboardingPersistenceBlocked = true', 'onboardingInitialPersistInFlight = false', 'if (onboardingFlowDisposed)', 'return', @@ -176,10 +184,18 @@ describe('app onboarding progress analytics integration', () => { 'finishOnboardingMount()', ]) + const scheduledPersistence = sourceBetween('function schedulePersistOnboardingProgress(', 'async function writeOnboardingProgress(') + expectSourceOrder(scheduledPersistence, [ + 'if (isHydratingOnboarding.value || onboardingPersistenceBlocked)', + 'return', + 'persistFieldsTimer = setTimeout', + 'void persistOnboardingProgress()', + ]) + const unmountFlow = sourceBetween('onBeforeUnmount(() => {', 'watch(existingApp,') expectSourceOrder(unmountFlow, [ 'onboardingFlowDisposed = true', - 'if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight)', + 'if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingPersistenceBlocked)', 'void persistOnboardingProgress()', ]) }) From d84edf772edb20c71edec95a1f4fcf2efffc8813 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 16:50:47 +0200 Subject: [PATCH 13/20] fix(onboarding): guard queued writes after conflicts --- src/components/dashboard/AppOnboardingFlow.vue | 7 ++++++- ...p-onboarding-progress-integration.unit.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index f90663887e..82a6382325 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -384,7 +384,11 @@ async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_prog if (onboardingPersistenceBlocked) return 'skipped' persistChain = persistChain - .then(() => writeOnboardingProgress(status)) + .then(() => { + if (onboardingPersistenceBlocked) + return 'skipped' + return writeOnboardingProgress(status) + }) .catch((error) => { console.error('Failed to persist onboarding progress', error) return 'retryable_failure' as const @@ -440,6 +444,7 @@ async function writeOnboardingProgress(status: UserOnboardingStatus) { if (status === 'completed' || main.user?.id !== userId) return 'skipped' + onboardingPersistenceBlocked = true const { data: latest, error: latestError } = await supabase .from('users') .select() diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 3881e6309e..441b5539a3 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -126,6 +126,14 @@ describe('app onboarding progress analytics integration', () => { 'if (onboardingPersistenceBlocked)', `return 'skipped'`, 'persistChain = persistChain', + ]) + expect(persistenceQueue.match(/if \(onboardingPersistenceBlocked\)/g)).toHaveLength(2) + const queuedPersistence = persistenceQueue.slice(persistenceQueue.indexOf('persistChain = persistChain')) + expectSourceOrder(queuedPersistence, [ + '.then(() => {', + 'if (onboardingPersistenceBlocked)', + `return 'skipped'`, + 'return writeOnboardingProgress(status)', `return 'retryable_failure'`, 'return persistChain', ]) @@ -143,6 +151,13 @@ describe('app onboarding progress analytics integration', () => { 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'`, + 'onboardingPersistenceBlocked = true', + 'const { data: latest, error: latestError }', + `return 'conflict'`, + ]) expectSourceOrder(noRowRefresh, [ 'const { data: latest, error: latestError }', 'if (latestError)', From 32f36fd7697fa72b32e2feb1be6c7cad65da89d5 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 17:11:03 +0200 Subject: [PATCH 14/20] fix(onboarding): allow terminal progress after conflicts --- src/components/dashboard/AppOnboardingFlow.vue | 5 +++-- tests/app-onboarding-progress-integration.unit.test.ts | 10 +++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 82a6382325..694051fd3a 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -381,11 +381,11 @@ function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress' } async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_progress') { - if (onboardingPersistenceBlocked) + if (onboardingPersistenceBlocked && status !== 'completed') return 'skipped' persistChain = persistChain .then(() => { - if (onboardingPersistenceBlocked) + if (onboardingPersistenceBlocked && status !== 'completed') return 'skipped' return writeOnboardingProgress(status) }) @@ -1460,6 +1460,7 @@ onMounted(async () => { if (onboardingFlowDisposed) return isLoading.value = false + // Unpersisted telemetry identities must not emit onboarding events. if (!onboardingMountAborted && onboardingPersistResult === 'persisted') initializeProgressTracking(resumedFlow) } diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 441b5539a3..a753eaecdb 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -122,16 +122,18 @@ describe('app onboarding progress analytics integration', () => { expect(onboardingSource).toContain(`type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict' | 'skipped'`) const persistenceQueue = sourceBetween('async function persistOnboardingProgress(', 'function schedulePersistOnboardingProgress(') + const blockedWriteGuard = `if (onboardingPersistenceBlocked && status !== 'completed')` + expect(persistenceQueue).toContain(`status: UserOnboardingStatus = 'in_progress'`) expectSourceOrder(persistenceQueue, [ - 'if (onboardingPersistenceBlocked)', + blockedWriteGuard, `return 'skipped'`, 'persistChain = persistChain', ]) - expect(persistenceQueue.match(/if \(onboardingPersistenceBlocked\)/g)).toHaveLength(2) + expect(persistenceQueue.match(/if \(onboardingPersistenceBlocked && status !== 'completed'\)/g)).toHaveLength(2) const queuedPersistence = persistenceQueue.slice(persistenceQueue.indexOf('persistChain = persistChain')) expectSourceOrder(queuedPersistence, [ '.then(() => {', - 'if (onboardingPersistenceBlocked)', + blockedWriteGuard, `return 'skipped'`, 'return writeOnboardingProgress(status)', `return 'retryable_failure'`, @@ -194,10 +196,12 @@ describe('app onboarding progress analytics integration', () => { 'if (onboardingFlowDisposed)', 'return', 'isLoading.value = false', + '// Unpersisted telemetry identities must not emit onboarding events.', `if (!onboardingMountAborted && onboardingPersistResult === 'persisted')`, 'initializeProgressTracking(resumedFlow)', 'finishOnboardingMount()', ]) + expect(mountedFlow).not.toContain(`onboardingPersistResult === 'skipped'`) const scheduledPersistence = sourceBetween('function schedulePersistOnboardingProgress(', 'async function writeOnboardingProgress(') expectSourceOrder(scheduledPersistence, [ From 54147dd2bb8712ef6e84b0f23e4414601e05363a Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 17:33:27 +0200 Subject: [PATCH 15/20] fix(onboarding): recover tracking after persistence outage --- src/components/dashboard/AppOnboardingFlow.vue | 14 +++++++++++--- ...-onboarding-progress-integration.unit.test.ts | 16 ++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 694051fd3a..fb9ebfc76f 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -309,6 +309,7 @@ let pendingDashboardExplored = false let onboardingFlowDisposed = false let onboardingInitialPersistInFlight = false let onboardingPersistenceBlocked = false +let pendingProgressTrackingResumed: boolean | null = null function trackV2DetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) { if (props.preOrg) @@ -393,7 +394,14 @@ async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_prog console.error('Failed to persist onboarding progress', error) return 'retryable_failure' as const }) - return persistChain + const result = await persistChain + if (status !== 'completed' && result === 'persisted' && pendingProgressTrackingResumed !== null) { + const resumed = pendingProgressTrackingResumed + pendingProgressTrackingResumed = null + if (!onboardingFlowDisposed && !progressTracker) + initializeProgressTracking(resumed) + } + return result } function schedulePersistOnboardingProgress() { @@ -1452,8 +1460,8 @@ onMounted(async () => { onboardingPersistResult = await persistOnboardingProgress() if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed) onboardingPersistResult = await persistOnboardingProgress() - if (onboardingPersistResult === 'conflict') - onboardingPersistenceBlocked = true + if (onboardingPersistResult === 'retryable_failure') + pendingProgressTrackingResumed = resumedFlow onboardingInitialPersistInFlight = false } function finishOnboardingMount() { diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index a753eaecdb..474665f56f 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -137,7 +137,13 @@ describe('app onboarding progress analytics integration', () => { `return 'skipped'`, 'return writeOnboardingProgress(status)', `return 'retryable_failure'`, - 'return persistChain', + 'const result = await persistChain', + `if (status !== 'completed' && result === 'persisted' && pendingProgressTrackingResumed !== null)`, + 'const resumed = pendingProgressTrackingResumed', + 'pendingProgressTrackingResumed = null', + 'if (!onboardingFlowDisposed && !progressTracker)', + 'initializeProgressTracking(resumed)', + 'return result', ]) const writer = sourceBetween('async function writeOnboardingProgress(', 'function resetOnboardingForm(') @@ -169,10 +175,11 @@ describe('app onboarding progress analytics integration', () => { expect(writer.trimEnd().endsWith(`return 'conflict'\n}`)).toBe(true) }) - it.concurrent('blocks tracking and later writes after an initial conflict or disposal', () => { + it.concurrent('defers tracking after retryable initial writes while blocking skipped and conflict outcomes', () => { expect(onboardingSource).toContain('let onboardingFlowDisposed = false') expect(onboardingSource).toContain('let onboardingInitialPersistInFlight = false') expect(onboardingSource).toContain('let onboardingPersistenceBlocked = false') + expect(onboardingSource).toContain('let pendingProgressTrackingResumed: boolean | null = null') const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') const persistenceGuard = 'if (!onboardingMountAborted) {' @@ -190,8 +197,8 @@ describe('app onboarding progress analytics integration', () => { 'onboardingPersistResult = await persistOnboardingProgress()', `if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed)`, 'onboardingPersistResult = await persistOnboardingProgress()', - `if (onboardingPersistResult === 'conflict')`, - 'onboardingPersistenceBlocked = true', + `if (onboardingPersistResult === 'retryable_failure')`, + 'pendingProgressTrackingResumed = resumedFlow', 'onboardingInitialPersistInFlight = false', 'if (onboardingFlowDisposed)', 'return', @@ -201,6 +208,7 @@ describe('app onboarding progress analytics integration', () => { 'initializeProgressTracking(resumedFlow)', 'finishOnboardingMount()', ]) + expect(mountedFlow).not.toContain(`if (onboardingPersistResult === 'conflict')\n onboardingPersistenceBlocked = true`) expect(mountedFlow).not.toContain(`onboardingPersistResult === 'skipped'`) const scheduledPersistence = sourceBetween('function schedulePersistOnboardingProgress(', 'async function writeOnboardingProgress(') From 604c0a51730e38896903e8393c36c3d4186b8d23 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 18:01:37 +0200 Subject: [PATCH 16/20] fix(onboarding): preserve tracking during persistence outages --- ...-onboarding-resume-telemetry-identities.md | 96 +++++++++++++++++++ ...ding-resume-telemetry-identities-design.md | 10 ++ .../dashboard/AppOnboardingFlow.vue | 30 +++--- ...boarding-progress-integration.unit.test.ts | 39 ++++---- 4 files changed, 135 insertions(+), 40 deletions(-) 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 index de38e65003..549e12ee22 100644 --- 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 @@ -766,3 +766,99 @@ 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" +``` 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 index 7f99ebe73c..ea2cb01fa3 100644 --- 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 @@ -229,6 +229,13 @@ future resumes of that progress preserve A2 and advance the saved run ID. 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. @@ -258,6 +265,9 @@ coverage for the component lifecycle: - 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; - no new direct `viewStep()` call exists in either resume decision branch. Extend the existing registration Playwright scenario to retain its functional diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index fb9ebfc76f..4f22a5e6f8 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -307,9 +307,9 @@ let persistChain: Promise = Promise.resolve('persisted' let persistFieldsTimer: ReturnType | undefined let pendingDashboardExplored = false let onboardingFlowDisposed = false +let onboardingMountAborted = false let onboardingInitialPersistInFlight = false let onboardingPersistenceBlocked = false -let pendingProgressTrackingResumed: boolean | null = null function trackV2DetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) { if (props.preOrg) @@ -382,11 +382,11 @@ function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress' } async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_progress') { - if (onboardingPersistenceBlocked && status !== 'completed') + if (onboardingMountAborted || (onboardingPersistenceBlocked && status !== 'completed')) return 'skipped' persistChain = persistChain .then(() => { - if (onboardingPersistenceBlocked && status !== 'completed') + if (onboardingMountAborted || (onboardingPersistenceBlocked && status !== 'completed')) return 'skipped' return writeOnboardingProgress(status) }) @@ -394,18 +394,11 @@ async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_prog console.error('Failed to persist onboarding progress', error) return 'retryable_failure' as const }) - const result = await persistChain - if (status !== 'completed' && result === 'persisted' && pendingProgressTrackingResumed !== null) { - const resumed = pendingProgressTrackingResumed - pendingProgressTrackingResumed = null - if (!onboardingFlowDisposed && !progressTracker) - initializeProgressTracking(resumed) - } - return result + return persistChain } function schedulePersistOnboardingProgress() { - if (isHydratingOnboarding.value || onboardingPersistenceBlocked) + if (isHydratingOnboarding.value || onboardingPersistenceBlocked || onboardingMountAborted) return window.clearTimeout(persistFieldsTimer) persistFieldsTimer = setTimeout(() => { @@ -1425,7 +1418,6 @@ function trackDashboardExplored() { onMounted(async () => { window.addEventListener(ONBOARDING_DASHBOARD_EXPLORED_EVENT, trackDashboardExplored) let resumedFlow = false - let onboardingMountAborted = false isLoading.value = true isHydratingOnboarding.value = true try { @@ -1460,16 +1452,16 @@ onMounted(async () => { onboardingPersistResult = await persistOnboardingProgress() if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed) onboardingPersistResult = await persistOnboardingProgress() - if (onboardingPersistResult === 'retryable_failure') - pendingProgressTrackingResumed = resumedFlow onboardingInitialPersistInFlight = false } function finishOnboardingMount() { - if (onboardingFlowDisposed) + if (onboardingFlowDisposed || onboardingMountAborted) return isLoading.value = false - // Unpersisted telemetry identities must not emit onboarding events. - if (!onboardingMountAborted && onboardingPersistResult === 'persisted') + const shouldInitializeProgressTracking + = onboardingPersistResult === 'persisted' + || onboardingPersistResult === 'retryable_failure' + if (!onboardingMountAborted && shouldInitializeProgressTracking) initializeProgressTracking(resumedFlow) } finishOnboardingMount() @@ -1481,7 +1473,7 @@ onBeforeUnmount(() => { window.clearTimeout(persistFieldsTimer) window.removeEventListener(ONBOARDING_DASHBOARD_EXPLORED_EVENT, trackDashboardExplored) detailsFieldTracker.dispose() - if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingPersistenceBlocked) + if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingPersistenceBlocked && !onboardingMountAborted) void persistOnboardingProgress() if (localIconPreview.value.startsWith('blob:')) diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 474665f56f..84ffe8f36b 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -85,7 +85,8 @@ describe('app onboarding progress analytics integration', () => { expect(resumeLoader).not.toContain('viewStep') const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') - expect(mountedFlow).toContain('let onboardingMountAborted = false') + expect(onboardingSource).toContain('let onboardingMountAborted = false') + expect(mountedFlow).not.toContain('let onboardingMountAborted = false') expect(mountedFlow).toContain('let resumedFlow = false') expectSourceOrder(mountedFlow, [ 'const resumeResult = await maybeResumeSavedOnboarding()', @@ -122,29 +123,24 @@ describe('app onboarding progress analytics integration', () => { expect(onboardingSource).toContain(`type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict' | 'skipped'`) const persistenceQueue = sourceBetween('async function persistOnboardingProgress(', 'function schedulePersistOnboardingProgress(') - const blockedWriteGuard = `if (onboardingPersistenceBlocked && status !== 'completed')` + const abortedWriteGuard = `if (onboardingMountAborted || (onboardingPersistenceBlocked && status !== 'completed'))` expect(persistenceQueue).toContain(`status: UserOnboardingStatus = 'in_progress'`) expectSourceOrder(persistenceQueue, [ - blockedWriteGuard, + abortedWriteGuard, `return 'skipped'`, 'persistChain = persistChain', ]) - expect(persistenceQueue.match(/if \(onboardingPersistenceBlocked && status !== 'completed'\)/g)).toHaveLength(2) + expect(persistenceQueue.match(/if \(onboardingMountAborted \|\| \(onboardingPersistenceBlocked && status !== 'completed'\)\)/g)).toHaveLength(2) const queuedPersistence = persistenceQueue.slice(persistenceQueue.indexOf('persistChain = persistChain')) expectSourceOrder(queuedPersistence, [ '.then(() => {', - blockedWriteGuard, + abortedWriteGuard, `return 'skipped'`, 'return writeOnboardingProgress(status)', `return 'retryable_failure'`, - 'const result = await persistChain', - `if (status !== 'completed' && result === 'persisted' && pendingProgressTrackingResumed !== null)`, - 'const resumed = pendingProgressTrackingResumed', - 'pendingProgressTrackingResumed = null', - 'if (!onboardingFlowDisposed && !progressTracker)', - 'initializeProgressTracking(resumed)', - 'return result', + 'return persistChain', ]) + expect(persistenceQueue).not.toContain('initializeProgressTracking') const writer = sourceBetween('async function writeOnboardingProgress(', 'function resetOnboardingForm(') expect(writer).toContain(`if (!userId || isHydratingOnboarding.value)\n return 'skipped'`) @@ -175,11 +171,12 @@ describe('app onboarding progress analytics integration', () => { expect(writer.trimEnd().endsWith(`return 'conflict'\n}`)).toBe(true) }) - it.concurrent('defers tracking after retryable initial writes while blocking skipped and conflict outcomes', () => { + 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 onboardingMountAborted = false') expect(onboardingSource).toContain('let onboardingInitialPersistInFlight = false') expect(onboardingSource).toContain('let onboardingPersistenceBlocked = false') - expect(onboardingSource).toContain('let pendingProgressTrackingResumed: boolean | null = null') + expect(onboardingSource).not.toContain('pendingProgressTrackingResumed') const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') const persistenceGuard = 'if (!onboardingMountAborted) {' @@ -197,23 +194,23 @@ describe('app onboarding progress analytics integration', () => { 'onboardingPersistResult = await persistOnboardingProgress()', `if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed)`, 'onboardingPersistResult = await persistOnboardingProgress()', - `if (onboardingPersistResult === 'retryable_failure')`, - 'pendingProgressTrackingResumed = resumedFlow', 'onboardingInitialPersistInFlight = false', - 'if (onboardingFlowDisposed)', + 'if (onboardingFlowDisposed || onboardingMountAborted)', 'return', 'isLoading.value = false', - '// Unpersisted telemetry identities must not emit onboarding events.', - `if (!onboardingMountAborted && onboardingPersistResult === 'persisted')`, + `onboardingPersistResult === 'persisted'`, + `onboardingPersistResult === 'retryable_failure'`, 'initializeProgressTracking(resumedFlow)', 'finishOnboardingMount()', ]) + expect(mountedFlow).toContain(`if (!onboardingMountAborted && shouldInitializeProgressTracking) + initializeProgressTracking(resumedFlow)`) expect(mountedFlow).not.toContain(`if (onboardingPersistResult === 'conflict')\n onboardingPersistenceBlocked = true`) expect(mountedFlow).not.toContain(`onboardingPersistResult === 'skipped'`) const scheduledPersistence = sourceBetween('function schedulePersistOnboardingProgress(', 'async function writeOnboardingProgress(') expectSourceOrder(scheduledPersistence, [ - 'if (isHydratingOnboarding.value || onboardingPersistenceBlocked)', + 'if (isHydratingOnboarding.value || onboardingPersistenceBlocked || onboardingMountAborted)', 'return', 'persistFieldsTimer = setTimeout', 'void persistOnboardingProgress()', @@ -222,7 +219,7 @@ describe('app onboarding progress analytics integration', () => { const unmountFlow = sourceBetween('onBeforeUnmount(() => {', 'watch(existingApp,') expectSourceOrder(unmountFlow, [ 'onboardingFlowDisposed = true', - 'if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingPersistenceBlocked)', + 'if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingPersistenceBlocked && !onboardingMountAborted)', 'void persistOnboardingProgress()', ]) }) From 90f2824576cd299c5fec05fdcf6fc5b03746ba0b Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 19:02:32 +0200 Subject: [PATCH 17/20] test(onboarding): exercise persistence lifecycle --- ...-onboarding-resume-telemetry-identities.md | 93 +++++++++++ ...ding-resume-telemetry-identities-design.md | 9 ++ .../dashboard/AppOnboardingFlow.vue | 47 +++--- src/utils/onboardingProgressPersistence.ts | 68 ++++++++ ...boarding-progress-integration.unit.test.ts | 55 +++---- ...onboarding-progress-analytics.unit.test.ts | 44 ++++++ ...boarding-progress-persistence.unit.test.ts | 149 ++++++++++++++++++ 7 files changed, 405 insertions(+), 60 deletions(-) create mode 100644 src/utils/onboardingProgressPersistence.ts create mode 100644 tests/onboarding-progress-persistence.unit.test.ts 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 index 549e12ee22..1f11ab4d90 100644 --- 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 @@ -862,3 +862,96 @@ Expected: both commands PASS. 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** + +Create an identity helper with a `capture` dependency that throws. Call dialog, +Continue, and Restart recording methods and assert none throws; then assert the +active attempt/run metadata remains valid and usable. + +- [ ] **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 run typecheck:frontend +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 index ea2cb01fa3..882b028817 100644 --- 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 @@ -209,6 +209,12 @@ 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. @@ -268,6 +274,9 @@ coverage for the component lifecycle: - 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 diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 455854887f..11754778c4 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' @@ -61,6 +62,7 @@ import { } from '~/utils/onboardingAppDraft' import { onboardingPrimaryButtonClass, onboardingSecondaryButtonClass } from '~/utils/onboardingButtonClasses' 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 { @@ -99,7 +101,6 @@ type AppRow = Omit & type StandardFlowStep = 'details' | 'choice' | 'install' | 'setup' type PreOrgFlowStep = 'intent' | 'details' | 'organization' | 'setup' type OnboardingFlowStep = StandardFlowStep | PreOrgFlowStep -type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict' | 'skipped' interface UserCountStop { value: number @@ -334,13 +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 = Promise.resolve('persisted') let persistFieldsTimer: ReturnType | undefined let pendingDashboardExplored = false let onboardingFlowDisposed = false -let onboardingMountAborted = false let onboardingInitialPersistInFlight = false -let onboardingPersistenceBlocked = 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) @@ -420,23 +422,11 @@ function snapshotOnboardingProgress(status: UserOnboardingStatus = 'in_progress' } async function persistOnboardingProgress(status: UserOnboardingStatus = 'in_progress') { - if (onboardingMountAborted || (onboardingPersistenceBlocked && status !== 'completed')) - return 'skipped' - persistChain = persistChain - .then(() => { - if (onboardingMountAborted || (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 + return onboardingProgressPersistence.persist(status) } function schedulePersistOnboardingProgress() { - if (isHydratingOnboarding.value || onboardingPersistenceBlocked || onboardingMountAborted) + if (isHydratingOnboarding.value || onboardingProgressPersistence.isBlocked() || onboardingProgressPersistence.isAborted()) return window.clearTimeout(persistFieldsTimer) persistFieldsTimer = setTimeout(() => { @@ -483,7 +473,6 @@ async function writeOnboardingProgress(status: UserOnboardingStatus) { if (status === 'completed' || main.user?.id !== userId) return 'skipped' - onboardingPersistenceBlocked = true const { data: latest, error: latestError } = await supabase .from('users') .select() @@ -1583,7 +1572,7 @@ onMounted(async () => { if (props.preOrg) { const resumeResult = await maybeResumeSavedOnboarding() if (resumeResult === null) { - onboardingMountAborted = true + onboardingProgressPersistence.abort() return } resumedFlow = resumeResult @@ -1606,7 +1595,7 @@ onMounted(async () => { finally { isHydratingOnboarding.value = false let onboardingPersistResult: OnboardingPersistResult = 'skipped' - if (!onboardingMountAborted) { + if (!onboardingProgressPersistence.isAborted()) { onboardingInitialPersistInFlight = true onboardingPersistResult = await persistOnboardingProgress() if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed) @@ -1614,13 +1603,17 @@ onMounted(async () => { onboardingInitialPersistInFlight = false } function finishOnboardingMount() { - if (onboardingFlowDisposed || onboardingMountAborted) + if (onboardingFlowDisposed || onboardingProgressPersistence.isAborted()) return isLoading.value = false - const shouldInitializeProgressTracking - = onboardingPersistResult === 'persisted' - || onboardingPersistResult === 'retryable_failure' - if (!onboardingMountAborted && shouldInitializeProgressTracking) + const shouldInitializeProgressTracking = shouldInitializeOnboardingProgressTracking( + onboardingPersistResult, + { + aborted: onboardingProgressPersistence.isAborted(), + disposed: onboardingFlowDisposed, + }, + ) + if (shouldInitializeProgressTracking) initializeProgressTracking(resumedFlow) } finishOnboardingMount() @@ -1632,7 +1625,7 @@ onBeforeUnmount(() => { window.clearTimeout(persistFieldsTimer) window.removeEventListener(ONBOARDING_DASHBOARD_EXPLORED_EVENT, trackDashboardExplored) detailsFieldTracker.dispose() - if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingPersistenceBlocked && !onboardingMountAborted) + if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingProgressPersistence.isBlocked() && !onboardingProgressPersistence.isAborted()) void persistOnboardingProgress() if (localIconPreview.value.startsWith('blob:')) 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/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 9c8a8e8b6e..56b0962078 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -85,13 +85,12 @@ describe('app onboarding progress analytics integration', () => { expect(resumeLoader).not.toContain('viewStep') const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') - expect(onboardingSource).toContain('let onboardingMountAborted = false') - expect(mountedFlow).not.toContain('let onboardingMountAborted = false') + expect(onboardingSource).toContain(`import { createOnboardingProgressPersistence, shouldInitializeOnboardingProgressTracking } from '~/utils/onboardingProgressPersistence'`) expect(mountedFlow).toContain('let resumedFlow = false') expectSourceOrder(mountedFlow, [ 'const resumeResult = await maybeResumeSavedOnboarding()', 'if (resumeResult === null)', - 'onboardingMountAborted = true', + 'onboardingProgressPersistence.abort()', 'return', 'resumedFlow = resumeResult', ]) @@ -119,27 +118,20 @@ describe('app onboarding progress analytics integration', () => { expect(snapshot).toContain('lastRunId: telemetry.lastRunId') }) - it.concurrent('distinguishes persisted, retryable, conflict, and skipped progress outcomes', () => { - expect(onboardingSource).toContain(`type OnboardingPersistResult = 'persisted' | 'retryable_failure' | 'conflict' | 'skipped'`) + 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(') - const abortedWriteGuard = `if (onboardingMountAborted || (onboardingPersistenceBlocked && status !== 'completed'))` expect(persistenceQueue).toContain(`status: UserOnboardingStatus = 'in_progress'`) - expectSourceOrder(persistenceQueue, [ - abortedWriteGuard, - `return 'skipped'`, - 'persistChain = persistChain', - ]) - expect(persistenceQueue.match(/if \(onboardingMountAborted \|\| \(onboardingPersistenceBlocked && status !== 'completed'\)\)/g)).toHaveLength(2) - const queuedPersistence = persistenceQueue.slice(persistenceQueue.indexOf('persistChain = persistChain')) - expectSourceOrder(queuedPersistence, [ - '.then(() => {', - abortedWriteGuard, - `return 'skipped'`, - 'return writeOnboardingProgress(status)', - `return 'retryable_failure'`, - 'return persistChain', - ]) + 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(') @@ -158,10 +150,10 @@ describe('app onboarding progress analytics integration', () => { const noRowConflict = writer.slice(writer.indexOf(`if (status === 'completed' || main.user?.id !== userId)`)) expectSourceOrder(noRowConflict, [ `return 'skipped'`, - 'onboardingPersistenceBlocked = true', 'const { data: latest, error: latestError }', `return 'conflict'`, ]) + expect(writer).not.toContain('onboardingProgressPersistence') expectSourceOrder(noRowRefresh, [ 'const { data: latest, error: latestError }', 'if (latestError)', @@ -173,13 +165,11 @@ describe('app onboarding progress analytics integration', () => { 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 onboardingMountAborted = false') expect(onboardingSource).toContain('let onboardingInitialPersistInFlight = false') - expect(onboardingSource).toContain('let onboardingPersistenceBlocked = false') expect(onboardingSource).not.toContain('pendingProgressTrackingResumed') const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') - const persistenceGuard = 'if (!onboardingMountAborted) {' + const persistenceGuard = 'if (!onboardingProgressPersistence.isAborted()) {' const persistenceGuardStart = mountedFlow.indexOf(persistenceGuard) const persistenceGuardEnd = mountedFlow.indexOf('\n }\n', persistenceGuardStart) expect(persistenceGuardStart).toBeGreaterThan(mountedFlow.indexOf('isHydratingOnboarding.value = false')) @@ -195,22 +185,21 @@ describe('app onboarding progress analytics integration', () => { `if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed)`, 'onboardingPersistResult = await persistOnboardingProgress()', 'onboardingInitialPersistInFlight = false', - 'if (onboardingFlowDisposed || onboardingMountAborted)', + 'if (onboardingFlowDisposed || onboardingProgressPersistence.isAborted())', 'return', 'isLoading.value = false', - `onboardingPersistResult === 'persisted'`, - `onboardingPersistResult === 'retryable_failure'`, + 'shouldInitializeOnboardingProgressTracking(', + 'aborted: onboardingProgressPersistence.isAborted()', + 'disposed: onboardingFlowDisposed', 'initializeProgressTracking(resumedFlow)', 'finishOnboardingMount()', ]) - expect(mountedFlow).toContain(`if (!onboardingMountAborted && shouldInitializeProgressTracking) + expect(mountedFlow).toContain(`if (shouldInitializeProgressTracking) initializeProgressTracking(resumedFlow)`) - expect(mountedFlow).not.toContain(`if (onboardingPersistResult === 'conflict')\n onboardingPersistenceBlocked = true`) - expect(mountedFlow).not.toContain(`onboardingPersistResult === 'skipped'`) const scheduledPersistence = sourceBetween('function schedulePersistOnboardingProgress(', 'async function writeOnboardingProgress(') expectSourceOrder(scheduledPersistence, [ - 'if (isHydratingOnboarding.value || onboardingPersistenceBlocked || onboardingMountAborted)', + 'if (isHydratingOnboarding.value || onboardingProgressPersistence.isBlocked() || onboardingProgressPersistence.isAborted())', 'return', 'persistFieldsTimer = setTimeout', 'void persistOnboardingProgress()', @@ -219,7 +208,7 @@ describe('app onboarding progress analytics integration', () => { const unmountFlow = sourceBetween('onBeforeUnmount(() => {', 'watch(existingApp,') expectSourceOrder(unmountFlow, [ 'onboardingFlowDisposed = true', - 'if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingPersistenceBlocked && !onboardingMountAborted)', + 'if (!isHydratingOnboarding.value && !onboardingInitialPersistInFlight && !onboardingProgressPersistence.isBlocked() && !onboardingProgressPersistence.isAborted())', 'void persistOnboardingProgress()', ]) }) diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts index 4013d9b524..4cc5ed6a26 100644 --- a/tests/onboarding-progress-analytics.unit.test.ts +++ b/tests/onboarding-progress-analytics.unit.test.ts @@ -113,6 +113,50 @@ describe('onboarding progress analytics', () => { }) }) + it.concurrent('keeps resume identity metadata valid when lifecycle capture throws', () => { + const capture = vi.fn(() => { + 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(capture.mock.calls.map(call => call[0])).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({ 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) + }, + ) +}) From 3d5e2091c5368031112c090d8a017756b8e5e9e4 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 19:25:44 +0200 Subject: [PATCH 18/20] test(onboarding): type lifecycle capture mock --- tests/onboarding-progress-analytics.unit.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts index 4cc5ed6a26..6d742b8692 100644 --- a/tests/onboarding-progress-analytics.unit.test.ts +++ b/tests/onboarding-progress-analytics.unit.test.ts @@ -114,7 +114,9 @@ describe('onboarding progress analytics', () => { }) it.concurrent('keeps resume identity metadata valid when lifecycle capture throws', () => { - const capture = vi.fn(() => { + const capturedEvents: string[] = [] + const capture = vi.fn((name: string) => { + capturedEvents.push(name) throw new Error('PostHog unavailable') }) const createIdentity = () => { @@ -141,7 +143,7 @@ describe('onboarding progress analytics', () => { expect(() => restartedIdentity.recordResumeDialogViewed()).not.toThrow() expect(() => restartedIdentity.recordResumeRestarted()).not.toThrow() - expect(capture.mock.calls.map(call => call[0])).toEqual([ + expect(capturedEvents).toEqual([ 'onboarding_resume_dialog_viewed', 'onboarding_resume_continued', 'onboarding_resume_dialog_viewed', From bb51d6b2ccfbc5e107d9c7a7f821c5a6d789ebcb Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 19:30:23 +0200 Subject: [PATCH 19/20] docs(onboarding): align persistence lifecycle plan --- ...-onboarding-resume-telemetry-identities.md | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) 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 index 1f11ab4d90..776cf3a110 100644 --- 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 @@ -19,8 +19,9 @@ 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 implementation/test lines; stop and simplify - before the entire PR reaches 500 changed lines. +- 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 @@ -35,18 +36,25 @@ This is deliberately a small PR: 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, and tracker initialization. - Budget: about 35 changed lines. + 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. Budget: about 25 changed lines. + ownership contract plus component/controller integration coverage. +- Create `tests/onboarding-progress-persistence.unit.test.ts` — executable + abort, conflict, queue, failure, and initialization behavior coverage. -No files are created by the implementation. +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 @@ -756,8 +764,9 @@ Run: git diff --stat origin/main...HEAD ``` -Expected: only the six files in the File Map, with no admin-dashboard, PostHog -query, generated type, migration, translation, or Playwright changes. +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: @@ -930,9 +939,11 @@ test. - [ ] **Step 5: Add identity capture-failure coverage** -Create an identity helper with a `capture` dependency that throws. Call dialog, -Continue, and Restart recording methods and assert none throws; then assert the -active attempt/run metadata remains valid and usable. +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** @@ -941,7 +952,7 @@ 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 run typecheck:frontend +bun typecheck bun test:unit git diff --check origin/main...HEAD ``` From 690521bf7bc3a7efc16c33d06d0610ae199b628c Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Sat, 15 Aug 2026 19:46:09 +0200 Subject: [PATCH 20/20] fix(onboarding): skip persistence after unmount --- src/components/dashboard/AppOnboardingFlow.vue | 2 +- tests/app-onboarding-progress-integration.unit.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index 11754778c4..4ce856733d 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -1595,7 +1595,7 @@ onMounted(async () => { finally { isHydratingOnboarding.value = false let onboardingPersistResult: OnboardingPersistResult = 'skipped' - if (!onboardingProgressPersistence.isAborted()) { + if (!onboardingFlowDisposed && !onboardingProgressPersistence.isAborted()) { onboardingInitialPersistInFlight = true onboardingPersistResult = await persistOnboardingProgress() if (onboardingPersistResult === 'retryable_failure' && !onboardingFlowDisposed) diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index 56b0962078..c299344ea4 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -169,7 +169,7 @@ describe('app onboarding progress analytics integration', () => { expect(onboardingSource).not.toContain('pendingProgressTrackingResumed') const mountedFlow = sourceBetween('onMounted(async () => {', 'onBeforeUnmount(() => {') - const persistenceGuard = 'if (!onboardingProgressPersistence.isAborted()) {' + 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'))