diff --git a/messages/en.context.json b/messages/en.context.json index 7a4dcf8cba..f171e99d57 100644 --- a/messages/en.context.json +++ b/messages/en.context.json @@ -426,8 +426,10 @@ "app-onboarding-explore-dashboard-confirm-title": "Used in Capgo web console areas: components. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-exploring-subtitle": "Used in Capgo web console areas: components/dashboard. Role: UI sentence. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-exploring-title": "Used in Capgo web console areas: components/dashboard. Role: section or dialog title. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-icon-choose-file": "Used in pre-organization app onboarding. Role: button that opens the native image file picker.", "app-onboarding-icon-help": "Used in Capgo web console areas: components/dashboard. Role: helper or description text. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-icon-label": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-icon-no-file-selected": "Used in pre-organization app onboarding. Role: status shown before an app icon file is selected.", "app-onboarding-icon-preview-alt": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-install-badge": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-install-ready-title": "Used in app onboarding flow. Role: section or dialog title about \"app onboarding install ready title\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -477,6 +479,14 @@ "app-onboarding-toast-no-organization": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-toast-resume-not-found": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-onboarding-toast-store-metadata-error": "Used in Capgo web console areas: components/dashboard. Role: toast or status message. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "app-onboarding-v2-ai-help-status": "Used in the pre-organization app onboarding AI help prompt. Role: status clarifying that store publication is optional.", + "app-onboarding-v2-appid-dialog-description": "Used in pre-organization app onboarding. Role: dialog text explaining that Capgo's App ID may differ from native project IDs.", + "app-onboarding-v2-appid-dialog-title": "Used in pre-organization app onboarding. Role: title for the App ID explanation dialog.", + "app-onboarding-v2-appid-help": "Used in the pre-organization app onboarding flow. Role: helper text explaining which native project identifier to enter.", + "app-onboarding-v2-details-helper": "Used in the pre-organization app onboarding flow. Role: helper text clarifying that store publication is not required.", + "app-onboarding-v2-details-title": "Used in the pre-organization app onboarding flow. Role: section title for entering app details.", + "app-onboarding-v2-store-import-help": "Used in the pre-organization app onboarding flow. Role: helper text for the optional store import.", + "app-onboarding-v2-store-import-toggle": "Used in the pre-organization app onboarding flow. Role: button that reveals the optional store import controls.", "app-perm": "Used in Capgo web console areas: components/tables. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-to-channel": "Used in Capgo web console areas: pages/admin/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "app-transferred": "Used in Capgo web console areas: components/dashboard. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", @@ -1495,6 +1505,7 @@ "key-upload": "Used in Capgo web console. Role: short UI label about \"key upload\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "key-write": "Used in Capgo web console. Role: short UI label about \"key write\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "language": "Used in Capgo web console areas: pages/settings/account. Role: short UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", + "learn-more": "Used in pre-organization app onboarding. Role: button opening additional App ID guidance.", "last": "Used in Capgo web console. Role: short UI label about \"last\". Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "last-1-hour": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", "last-12-hours": "Used in Capgo web console areas: services. Role: UI label. Translate for UI; keep Capgo product names, code, and placeholders unchanged.", diff --git a/messages/en.json b/messages/en.json index 38a44399a5..af0704a5fe 100644 --- a/messages/en.json +++ b/messages/en.json @@ -408,8 +408,10 @@ "app-onboarding-existing-yes-helper": "Import details from the store listing or enter the package ID manually.", "app-onboarding-exploring-subtitle": "Demo data is temporary — your real app is waiting.", "app-onboarding-exploring-title": "Exploring the Capgo dashboard", + "app-onboarding-icon-choose-file": "Choose file", "app-onboarding-icon-help": "The icon is optional. If you import from a store listing, Capgo will try to reuse the store icon automatically.", "app-onboarding-icon-label": "App icon", + "app-onboarding-icon-no-file-selected": "No file selected", "app-onboarding-icon-preview-alt": "App icon preview", "app-onboarding-install-badge": "CLI onboarding", "app-onboarding-explore-dashboard": "Explore Capgo dashboard", @@ -462,6 +464,14 @@ "app-onboarding-toast-no-organization": "No organization selected.", "app-onboarding-toast-resume-not-found": "Unable to find the onboarding app.", "app-onboarding-toast-store-metadata-error": "Unable to fetch metadata from that store link.", + "app-onboarding-v2-ai-help-status": "Existing mobile project; store publication is optional", + "app-onboarding-v2-appid-dialog-description": "Capgo uses this App ID to identify the app in the dashboard and CLI. It is often the same as your native project ID, but it does not have to be. Changing it here does not change your iOS Bundle Identifier, Android Application ID, Capacitor appId, or store listing.", + "app-onboarding-v2-appid-dialog-title": "How does the Capgo App ID work?", + "app-onboarding-v2-appid-help": "Choose the ID Capgo should use for this app. It may differ from your native project IDs.", + "app-onboarding-v2-details-helper": "Enter the details from your project. Your app does not need to be published on the App Store or Google Play.", + "app-onboarding-v2-details-title": "Add your app details", + "app-onboarding-v2-store-import-help": "Paste a store link to prefill the app name, App ID, and icon when available.", + "app-onboarding-v2-store-import-toggle": "Import details from the App Store or Google Play", "app-not-found": "App not found", "app-not-found-description": "This app could not be found. It might have been deleted or you might not have access to it.", "app-perm": "App permission", @@ -1422,6 +1432,7 @@ "key-upload": "Upload", "key-write": "Write", "language": "Language", + "learn-more": "Learn more", "last": "Last", "last-12-months": "Last 12 Months", "last-30-minutes": "Last 30 minutes", diff --git a/playwright/e2e/register.spec.ts b/playwright/e2e/register.spec.ts index 68d3ec6d30..92a8da3510 100644 --- a/playwright/e2e/register.spec.ts +++ b/playwright/e2e/register.spec.ts @@ -38,13 +38,17 @@ test.describe('Registration', () => { await page.click('[data-test="onboarding-intent-ota"]') await page.click('[data-test="app-onboarding-continue-intent"]') - await page.click('[data-test="app-onboarding-existing-no"]') + await expect(page.locator('[data-test="app-onboarding-existing-yes"]')).toHaveCount(0) + await expect(page.locator('[data-test="app-onboarding-existing-no"]')).toHaveCount(0) + await expect(page.locator('[data-test="app-onboarding-name"]')).toBeVisible() + await expect(page.locator('#app-onboarding-app-id')).toBeVisible() await page.fill('[data-test="app-onboarding-name"]', appName) await page.click('[data-test="app-onboarding-continue"]') await expectProtectedRouteRedirect(page, '/apps', /\/onboarding\/app/, '[data-test="onboarding-logout"]') await expect(page.locator('[data-test="onboarding-org-name"]')).toHaveValue(appName) + await page.locator('[data-test="onboarding-estimated-users-option"]').first().click() await page.getByRole('button', { name: 'Back', exact: true }).click() await page.fill('[data-test="app-onboarding-name"]', editedAppName) await page.click('[data-test="app-onboarding-continue"]') diff --git a/src/components/dashboard/AppOnboardingFlow.vue b/src/components/dashboard/AppOnboardingFlow.vue index e62fd83b4a..d9cc26aec0 100644 --- a/src/components/dashboard/AppOnboardingFlow.vue +++ b/src/components/dashboard/AppOnboardingFlow.vue @@ -1,6 +1,8 @@ diff --git a/src/utils/onboardingProgressAnalytics.ts b/src/utils/onboardingProgressAnalytics.ts index a78a5bc9f5..6efc295e5d 100644 --- a/src/utils/onboardingProgressAnalytics.ts +++ b/src/utils/onboardingProgressAnalytics.ts @@ -1,10 +1,26 @@ import { pushEvent } from '~/services/posthog' -export const ONBOARDING_ANALYTICS_VERSION = 1 +export const ONBOARDING_ANALYTICS_VERSION = 2 export type OnboardingAnalyticsFlow = 'pre_org' | 'existing_org' export type OnboardingAnalyticsStep = 'intent' | 'details' | 'organization' | 'choice' | 'install' | 'setup' export type OnboardingIntent = 'ota' | 'builder' | 'both' | 'exploring' +export type OnboardingDetailsEvent + = | 'onboarding_app_id_entered' + | 'onboarding_app_id_help_opened' + | 'onboarding_app_icon_picked' + | 'onboarding_app_icon_picker_closed_without_selection' + | 'onboarding_app_icon_picker_open_failed' + | 'onboarding_app_icon_picker_opened' + | 'onboarding_app_icon_upload_failed' + | 'onboarding_app_icon_uploaded' + | 'onboarding_app_name_entered' + | 'onboarding_store_import_failed' + | 'onboarding_store_import_hidden' + | 'onboarding_store_import_shown' + | 'onboarding_store_import_submitted' + | 'onboarding_store_import_succeeded' + | 'onboarding_store_url_entered' type AnalyticsPrimitive = string | number | boolean | null type AnalyticsProperties = Record @@ -17,6 +33,53 @@ export interface OnboardingStepCompletionProperties { storeImportUsed?: boolean } +export interface OnboardingDetailsEventProperties { + field_length?: number + icon_source?: 'file' | 'store' +} + +export type OnboardingDetailsField = 'app_id' | 'app_name' | 'store_url' + +export function createOnboardingDetailsFieldDebouncer( + emit: (name: OnboardingDetailsEvent, properties: OnboardingDetailsEventProperties) => void, + delayMs = 1_000, +) { + const timers = new Map>() + const pending = new Map() + + function schedule(name: OnboardingDetailsEvent, field: OnboardingDetailsField, value: string) { + const activeTimer = timers.get(field) + if (activeTimer) + clearTimeout(activeTimer) + + const normalizedValue = value.trim() + if (!normalizedValue) { + timers.delete(field) + pending.delete(field) + return + } + + const event = { name, properties: { field_length: normalizedValue.length } } + pending.set(field, event) + timers.set(field, setTimeout(() => { + emit(event.name, event.properties) + timers.delete(field) + pending.delete(field) + }, delayMs)) + } + + function dispose() { + for (const timer of timers.values()) + clearTimeout(timer) + for (const event of pending.values()) + emit(event.name, event.properties) + timers.clear() + pending.clear() + } + + return { dispose, schedule } +} + interface CreateOnboardingProgressTrackerOptions { capture?: CaptureEvent flow: OnboardingAnalyticsFlow @@ -101,8 +164,17 @@ export function createOnboardingProgressTracker(options: CreateOnboardingProgres safelyCapture('onboarding_step_completed', properties) } + function trackDetailsEvent(name: OnboardingDetailsEvent, details: OnboardingDetailsEventProperties = {}) { + const properties = sharedProperties('details') + if (!properties) + return + + safelyCapture(name, { ...properties, ...details }) + } + return { completeStep, + trackDetailsEvent, viewStep, } } diff --git a/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts b/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts index 5731697f54..6b68531a9d 100644 --- a/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts +++ b/supabase/functions/_backend/utils/frontend_onboarding_analytics.ts @@ -113,7 +113,7 @@ export function buildFrontendOnboardingHogql(startDate: string, cohortEndDate: s FROM events WHERE event = 'onboarding_step_viewed' AND JSONExtractString(toString(properties), 'flow') = 'pre_org' - AND toIntOrZero(toString(properties.onboarding_version)) = 1 + AND toIntOrZero(toString(properties.onboarding_version)) = ${FRONTEND_ONBOARDING_VERSION} AND timestamp >= parseDateTimeBestEffort(${sqlStr(startDate)}) AND timestamp < parseDateTimeBestEffort(${sqlStr(followupEndDate)}) ) diff --git a/tests/app-onboarding-file-input.unit.test.ts b/tests/app-onboarding-file-input.unit.test.ts index 00c55ad8e0..4112e7286c 100644 --- a/tests/app-onboarding-file-input.unit.test.ts +++ b/tests/app-onboarding-file-input.unit.test.ts @@ -1,24 +1,55 @@ // @vitest-environment happy-dom -import { defaultConfig, plugin } from '@formkit/vue' import { describe, expect, it } from 'vitest' -import { createSSRApp } from 'vue' +import { createSSRApp, h } from 'vue' import { renderToString } from 'vue/server-renderer' -import { rootClasses } from '../formkit.theme' import AppOnboardingIconInput from '../src/components/dashboard/AppOnboardingIconInput.vue' describe('app onboarding file input', () => { it('keeps the rendered empty file status visible in dark mode', async () => { - const app = createSSRApp(AppOnboardingIconInput, { label: 'App icon' }) - app.use(plugin, defaultConfig({ config: { rootClasses } })) + const app = createSSRApp(AppOnboardingIconInput, { + chooseLabel: 'Choose file', + emptyLabel: 'No file selected', + label: 'App icon', + }) const html = await renderToString(app) const container = document.createElement('div') container.innerHTML = html - const noFiles = container.querySelector('.formkit-noFiles') + const noFiles = Array.from(container.querySelectorAll('span')) + .find(element => element.textContent?.trim() === 'No file selected') - expect(noFiles).not.toBeNull() - expect(noFiles?.classList.contains('text-slate-600!')).toBe(true) - expect(noFiles?.classList.contains('dark:text-slate-300!')).toBe(true) + expect(noFiles).toBeDefined() + expect(noFiles?.classList.contains('text-slate-600')).toBe(true) + expect(noFiles?.classList.contains('dark:text-slate-300')).toBe(true) + expect(noFiles?.getAttribute('aria-live')).toBe('polite') + const labelFor = container.querySelector('label')?.getAttribute('for') + const inputId = container.querySelector('input')?.id + expect(labelFor).toBeTruthy() + expect(labelFor).toBe(inputId) + }) + + it('uses a unique accessible input id for each rendered instance', async () => { + const props = { + chooseLabel: 'Choose file', + emptyLabel: 'No file selected', + label: 'App icon', + } + const app = createSSRApp({ + render: () => h('div', [ + h(AppOnboardingIconInput, props), + h(AppOnboardingIconInput, props), + ]), + }) + + const html = await renderToString(app) + const container = document.createElement('div') + container.innerHTML = html + const inputIds = Array.from(container.querySelectorAll('input'), input => input.id) + const labelTargets = Array.from(container.querySelectorAll('label'), label => label.htmlFor) + + expect(inputIds).toHaveLength(2) + expect(new Set(inputIds).size).toBe(2) + expect(labelTargets).toEqual(inputIds) }) }) diff --git a/tests/app-onboarding-progress-integration.unit.test.ts b/tests/app-onboarding-progress-integration.unit.test.ts index a1c7afa329..c76017a529 100644 --- a/tests/app-onboarding-progress-integration.unit.test.ts +++ b/tests/app-onboarding-progress-integration.unit.test.ts @@ -9,7 +9,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 { createOnboardingProgressTracker } from '~/utils/onboardingProgressAnalytics'") + expect(onboardingSource).toContain("import { createOnboardingDetailsFieldDebouncer, createOnboardingProgressTracker } from '~/utils/onboardingProgressAnalytics'") const initializer = sourceBetween('function initializeProgressTracking(', 'function whiteCardToggleButtonClass(') expect(initializer).toContain("flow: props.preOrg ? 'pre_org' : 'existing_org'") diff --git a/tests/app-onboarding-v2.unit.test.ts b/tests/app-onboarding-v2.unit.test.ts new file mode 100644 index 0000000000..b4a6602d6d --- /dev/null +++ b/tests/app-onboarding-v2.unit.test.ts @@ -0,0 +1,46 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const onboardingSource = readFileSync(new URL('../src/components/dashboard/AppOnboardingFlow.vue', import.meta.url), 'utf8') +const iconInputSource = readFileSync(new URL('../src/components/dashboard/AppOnboardingIconInput.vue', import.meta.url), 'utf8') + +describe('pre-organization onboarding v2 app details', () => { + it.concurrent('keeps store import optional and inline for pre-organization onboarding', () => { + expect(onboardingSource).toContain('data-test="app-onboarding-toggle-store-import"') + expect(onboardingSource).toContain(':aria-expanded="existingAppSetup === \'import\'"') + expect(onboardingSource).toContain('id="app-onboarding-v2-store-url"') + expect(onboardingSource).toContain('// Store publication is unrelated to whether the user already has a mobile project.') + expect(onboardingSource).toContain('existingApp.value = true') + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_shown')") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_hidden')") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_submitted'") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_succeeded'") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_store_import_failed'") + expect(onboardingSource).not.toContain("{ store_url: requestedUrl }") + }) + + it.concurrent('does not send raw onboarding field values to analytics', () => { + expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_name_entered', 'app_name'") + expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_app_id_entered', 'app_id'") + expect(onboardingSource).toContain("detailsFieldTracker.schedule('onboarding_store_url_entered', 'store_url'") + expect(onboardingSource).not.toContain('{ app_name:') + expect(onboardingSource).not.toContain('{ store_url:') + }) + + it.concurrent('tracks native app icon picker and upload outcomes', () => { + expect(iconInputSource).toContain("emit('pickerOpened')") + expect(iconInputSource).toContain("emit('pickerOpenFailed')") + expect(iconInputSource).toContain("emit('pickerClosedWithoutSelection')") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_app_icon_picked'") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_app_icon_uploaded'") + expect(onboardingSource).toContain("trackV2DetailsEvent('onboarding_app_icon_upload_failed'") + expect(onboardingSource).toContain("parsedIconUrl.protocol === 'data:' && iconSourceUrl.startsWith('data:image/')") + }) + + it.concurrent('opens App ID guidance and tracks only the open action', () => { + const appIdHelp = onboardingSource.slice(onboardingSource.indexOf('function openAppIdHelp()'), onboardingSource.indexOf('function applyAppIdSuggestion(')) + expect(appIdHelp).toContain("trackV2DetailsEvent('onboarding_app_id_help_opened')") + expect(appIdHelp).toContain('dialogStore.openDialog({') + expect(appIdHelp).not.toContain('onDialogDismiss') + }) +}) diff --git a/tests/onboarding-details-field-debouncer.unit.test.ts b/tests/onboarding-details-field-debouncer.unit.test.ts new file mode 100644 index 0000000000..edccf28427 --- /dev/null +++ b/tests/onboarding-details-field-debouncer.unit.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createOnboardingDetailsFieldDebouncer } from '../src/utils/onboardingProgressAnalytics' + +describe('onboarding details field analytics debounce', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('emits the latest non-empty value one second after the last keystroke', () => { + vi.useFakeTimers() + const emit = vi.fn() + const tracker = createOnboardingDetailsFieldDebouncer(emit) + + tracker.schedule('onboarding_app_name_entered', 'app_name', 'Example') + vi.advanceTimersByTime(750) + tracker.schedule('onboarding_app_name_entered', 'app_name', ' Example App ') + vi.advanceTimersByTime(999) + expect(emit).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(emit).toHaveBeenCalledOnce() + expect(emit).toHaveBeenCalledWith('onboarding_app_name_entered', { field_length: 11 }) + }) + + it('tracks fields independently, cancels empty values, and flushes pending values on dispose', () => { + vi.useFakeTimers() + const emit = vi.fn() + const tracker = createOnboardingDetailsFieldDebouncer(emit) + + tracker.schedule('onboarding_app_name_entered', 'app_name', 'Discard me') + tracker.schedule('onboarding_app_name_entered', 'app_name', ' ') + tracker.schedule('onboarding_app_id_entered', 'app_id', 'com.example.app') + tracker.schedule('onboarding_store_url_entered', 'store_url', 'https://apps.apple.com/example') + vi.advanceTimersByTime(1_000) + + expect(emit).toHaveBeenCalledTimes(2) + expect(emit).toHaveBeenCalledWith('onboarding_app_id_entered', { field_length: 15 }) + expect(emit).toHaveBeenCalledWith('onboarding_store_url_entered', { field_length: 30 }) + + tracker.schedule('onboarding_app_name_entered', 'app_name', 'Never emitted') + tracker.dispose() + vi.runAllTimers() + expect(emit).toHaveBeenCalledTimes(3) + expect(emit).toHaveBeenLastCalledWith('onboarding_app_name_entered', { field_length: 13 }) + }) +}) diff --git a/tests/onboarding-progress-analytics.unit.test.ts b/tests/onboarding-progress-analytics.unit.test.ts index b101c39f4d..3a17d72378 100644 --- a/tests/onboarding-progress-analytics.unit.test.ts +++ b/tests/onboarding-progress-analytics.unit.test.ts @@ -20,7 +20,7 @@ describe('onboarding progress analytics', () => { tracker.viewStep('intent') - expect(ONBOARDING_ANALYTICS_VERSION).toBe(1) + expect(ONBOARDING_ANALYTICS_VERSION).toBe(2) expect(capture).toHaveBeenCalledOnce() expect(capture).toHaveBeenCalledWith( 'onboarding_step_viewed', @@ -112,6 +112,31 @@ describe('onboarding progress analytics', () => { }) }) + it.concurrent('associates app-details interaction events with the active onboarding attempt', () => { + const capture = vi.fn() + const tracker = createOnboardingProgressTracker({ + capture, + flow: 'pre_org', + resumed: false, + steps, + supaHost: 'https://supabase.capgo.test', + }) + + tracker.trackDetailsEvent('onboarding_app_name_entered', { field_length: 11 }) + + expect(capture).toHaveBeenCalledWith( + 'onboarding_app_name_entered', + 'https://supabase.capgo.test', + expect.objectContaining({ + field_length: 11, + flow: 'pre_org', + onboarding_attempt_id: expect.any(String), + onboarding_version: 2, + step: 'details', + }), + ) + }) + it.concurrent('deduplicates completion for one visit and resets timing after back navigation', () => { let now = 10 const capture = vi.fn()