import { devices, type Page } from '@playwright/test' import process from 'node:process' import { APP_ONBOARDING_OTA_V1_STEP_IDS } from '../src/services/appOnboarding' import { BUILDER_STEP_IDS } from '../src/services/builderOnboardingChecklist' import { consoleQueryFromResponse, consoleQueryJson, consoleQueryMethod, consoleQueryPayload, consoleQueryRows, forwardConsoleQuery, routeConsoleQuery } from './support/consoleQuery' async function dismissRemindMeLater(page: Page) { const remindLater = page.getByRole('button', { name: 'Remind me later', exact: true }) if (await remindLater.isVisible().catch(() => false)) await remindLater.click() } async function prepareSecurityUpdatesNotificationsSection(page: Page, viewport: { width: number, height: number }) { await page.setViewportSize(viewport) await dismissRemindMeLater(page) const section = page.locator('#security-updates-email') if (process.env.VISUAL_DIFF_PHASE === 'after') { await section.waitFor({ state: 'visible', timeout: 120_000 }) await section.scrollIntoViewIfNeeded() } else if (await section.isVisible().catch(() => false)) { await section.scrollIntoViewIfNeeded() } } async function prepareSecurityUpdatesObserveTab(page: Page, viewport: { width: number, height: number }) { await page.setViewportSize(viewport) await dismissRemindMeLater(page) const securityTab = page.getByRole('button', { name: /security updates/i }) if (await securityTab.isVisible().catch(() => false)) { await securityTab.scrollIntoViewIfNeeded() await securityTab.click() await page.locator('[data-testid="security-updates-feed"]').waitFor({ state: 'visible', timeout: 120_000 }) await page.locator('[data-testid="security-advisory-match-card"]').first().waitFor({ state: 'visible', timeout: 120_000 }) } } async function prepareSecurityUpdatesEmailPreferences(page: Page, viewport: { width: number, height: number }) { await page.setViewportSize(viewport) const section = page.locator('#security-updates-email-public') if (process.env.VISUAL_DIFF_PHASE === 'after') { await section.waitFor({ state: 'visible', timeout: 120_000 }) await section.scrollIntoViewIfNeeded() } else if (await section.isVisible().catch(() => false)) { await section.scrollIntoViewIfNeeded() } } export interface VisualDiffRoute { slug: string path: string /** When true, logs in as test@capgo.app before visiting the route. */ auth?: boolean /** Optional deterministic UI setup before the screenshot is captured. */ prepare?: (page: Page) => Promise } async function prepareOrgWebhooksMcpScreenshot( page: Page, viewport: { width: number, height: number }, mobile = false, ) { await page.setViewportSize(viewport) if (mobile) { const iphone = devices['iPhone 13'] await page.evaluate((ua) => { Object.defineProperty(navigator, 'userAgent', { get: () => ua }) }, iphone.userAgent) } await page.goto('/settings/organization/webhooks/') await page.getByRole('heading', { level: 2, name: 'Webhooks' }).waitFor({ timeout: 120_000 }) await page.locator('.py-12 .w-8.h-8').waitFor({ state: 'detached', timeout: 120_000 }).catch(() => {}) await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {}) if (process.env.VISUAL_DIFF_PHASE === 'after') { await page.getByText('MCP', { exact: true }).first().waitFor({ state: 'visible', timeout: 120_000 }) } } async function prepareProfileGoalScreenshot(page: Page, completed = false) { const assignments = {} let profile: Record | undefined await page.route('**/private/onboarding_ab_tests', route => route.fulfill({ json: { assignments } })) await routeConsoleQuery(page, 'get_orgs_v7', route => route.fulfill(consoleQueryJson([]))) await routeConsoleQuery(page, 'users', async (route, query) => { // Welcome persists a draft before opening About you. Keep those writes local // instead of returning an empty conditional-update result or altering seed data. const method = consoleQueryMethod(query) if (method === 'PATCH' && profile) { Object.assign(profile, consoleQueryPayload(query)) return route.fulfill(consoleQueryJson(consoleQueryRows(query, [profile]))) } if (method !== 'GET') return route.fulfill(consoleQueryJson(consoleQueryRows(query, []))) if (profile) return route.fulfill(consoleQueryJson(consoleQueryRows(query, [profile]))) const response = await route.fetch() const json = await response.json() as { data?: unknown } const row = Array.isArray(json.data) ? json.data[0] : json.data if (!row) throw new Error('The visual profile fixture requires an existing test user') profile = { ...row, first_name: completed ? 'Example' : '', last_name: completed ? 'User' : '', onboarding: { abtests: assignments, setup_mode: 'manual', version: 6, ...(completed ? { profile_completed_at: '2026-10-20T11:00:00.000Z', org_name: 'Example Company' } : {}), }, } await route.fulfill(consoleQueryJson(consoleQueryRows(query, [profile]))) }) await page.reload() const welcome = page.locator('[data-test="onboarding-welcome-continue"]') const goal = page.locator('[data-test="onboarding-intent-ota"]') const name = page.locator('[data-test="onboarding-name-step"]') await welcome.or(goal).or(name).waitFor() if (await name.isVisible()) return if (await welcome.isVisible()) await welcome.click() await name.or(goal).waitFor() if (await name.isVisible()) return await goal.click() } async function prepareProfileResumeScreenshot(page: Page) { await prepareProfileGoalScreenshot(page) const firstName = page.locator('[data-test="onboarding-first-name"]') // The base branch can still show Your goal without a separate name page. if (!await firstName.isVisible()) return const saved = page.waitForResponse((response) => { const query = consoleQueryFromResponse(response) return !!query && query.name === 'users' && consoleQueryMethod(query) === 'PATCH' && consoleQueryPayload(query)?.onboarding?.profile_draft?.first_name === 'Example' }) await firstName.fill('Example') await saved await page.reload() await page.locator('[data-test="onboarding-name-step"]').or(page.locator('[data-test="onboarding-resume-continue"]')).waitFor() } async function prepareAiOnboardingScreenshot(page: Page, setupMode: 'ai' | 'invite' | null) { await routeConsoleQuery(page, 'users', async (route, query) => { if (consoleQueryMethod(query) !== 'GET') return route.fulfill(consoleQueryJson(consoleQueryRows(query, []))) const onboarding = { profile_completed_at: '2026-10-20T10:00:00.000Z', org_name: 'Example Company', ...(setupMode ? { setup_mode: setupMode } : {}) } await forwardConsoleQuery(route, (row: any) => ({ ...row, first_name: 'Example', last_name: 'User', onboarding })) }) await page.reload() await page.locator(setupMode ? '[data-test="onboarding-ai-plan"]' : '[data-test="onboarding-setup-mode"]').waitFor() if (setupMode) { await page.locator('[data-test="onboarding-ai-plan-option"]').nth(1).click() await page.locator('[data-test="onboarding-ai-org-name"]').or(page.locator('[data-test="onboarding-company-name-summary"]')).scrollIntoViewIfNeeded() } } const nativeObserveDays = ['2026-09-24', '2026-09-25', '2026-09-26', '2026-09-27', '2026-09-28', '2026-09-29', '2026-09-30'] const nativeObserveActionRows: Array<[string, number, number, number | null, number | null, number | null, boolean]> = [ ['app_launch_ready', 18420, 5120, 820, 1650, 3100, false], ['webview_page_loaded', 18110, 5080, 540, 1180, 2400, false], ['webview_render_process_gone', 412, 301, null, null, null, true], ['webview_content_process_terminated', 268, 190, null, null, null, true], ['webview_javascript_error', 197, 88, null, null, null, true], ['app_memory_warning', 143, 120, null, null, null, true], ['webview_resource_error', 61, 40, null, null, null, true], ['app_killed_low_memory', 38, 35, null, null, null, true], ['app_crash', 9, 7, null, null, null, true], ['app_launch_timeout', 4, 4, null, null, null, true], ] async function mockNativeObserveStats(page: Page) { const emptySeries = nativeObserveDays.map(() => null) await page.route('**/private/update_delivery_stats', route => route.fulfill({ json: { scope: 'app', labels: nativeObserveDays, period: { requested_days: 7, actual_days: 7, start: '2026-09-24T00:00:00.000Z', end: '2026-09-30T23:59:59.999Z' }, overview: { samples: 0, devices: null, p50_ms: null, p75_ms: null, p95_ms: null, p99_ms: null }, daily: { samples: nativeObserveDays.map(() => 0), p50_ms: emptySeries, p75_ms: emptySeries, p95_ms: emptySeries, p99_ms: emptySeries }, }, })) await page.route('**/private/native_observe_stats', route => route.fulfill({ json: { labels: nativeObserveDays, period: { requested_days: 7, actual_days: 7, start: '2026-09-24T00:00:00.000Z', end: '2026-09-30T23:59:59.999Z' }, overview: { total_events: 37662, total_devices: 5230, issue_count: 1132, affected_devices: 612, issue_free_rate: 88.3, launch_timeout_count: 4, launch_p50_ms: 820, launch_p90_ms: 1650, webview_load_p50_ms: 540, webview_load_p90_ms: 1180, }, daily: { total_events: [5200, 5310, 5402, 5388, 5290, 5460, 5612], issue_events: [150, 162, 171, 158, 149, 166, 176], launches: [2540, 2600, 2650, 2630, 2590, 2680, 2730], webview_loads: [2500, 2570, 2610, 2590, 2560, 2620, 2660], launch_p50_ms: [810, 830, 820, 800, 815, 825, 840], launch_p90_ms: [1600, 1680, 1640, 1620, 1650, 1670, 1690], webview_load_p50_ms: [530, 540, 550, 535, 545, 540, 548], webview_load_p90_ms: [1150, 1190, 1170, 1160, 1185, 1180, 1200], }, actionBreakdown: nativeObserveActionRows.map(([action, events, devices, p50, p90, p99, isIssue]) => ({ action, events, devices, p50_ms: p50, p90_ms: p90, p99_ms: p99, is_issue: isIssue, })), version_group: 'version', versions: [ { version_name: '2.4.1', platform: null, channel_name: null, events: 21400, devices: 3100, issue_count: 610, affected_devices: 340, issue_free_rate: 89, launch_p90_ms: 1620, webview_load_p90_ms: 1150 }, { version_name: '2.4.0', platform: null, channel_name: null, events: 16262, devices: 2130, issue_count: 522, affected_devices: 272, issue_free_rate: 87.2, launch_p90_ms: 1690, webview_load_p90_ms: 1210 }, ], releaseMarkers: [ { version_name: '2.4.1', channel_name: 'production', deployed_at: '2026-09-27T10:00:00.000Z' }, ], }, })) } const updaterInsightDays = ['2026-09-24', '2026-09-25', '2026-09-26', '2026-09-27', '2026-09-28', '2026-09-29', '2026-09-30'] // Mix of updater failures and native signals. The mock honours the `actions` // filter in the request, like the real endpoint does. const updaterInsightActionRows: Array<[string, number, number, number]> = [ ['webview_render_process_gone', 412, 301, 3], ['download_fail', 286, 204, 4], ['webview_javascript_error', 197, 88, 2], ['app_killed_low_memory', 38, 35, 2], ['update_fail', 24, 21, 2], ['unzip_fail', 17, 15, 2], ['insufficient_disk_space', 11, 9, 2], ['checksum_fail', 6, 5, 1], ['app_crash', 9, 7, 1], ] async function mockUpdaterInsights(page: Page) { await page.route('**/private/stats/insights', async (route) => { const body = route.request().postDataJSON() as { actions?: string[] } | null const allowed = body?.actions?.length ? new Set(body.actions) : null const rows = updaterInsightActionRows.filter(([action]) => !allowed || allowed.has(action)) const total = rows.reduce((sum, [, events]) => sum + events, 0) const devices = rows.reduce((sum, [, , deviceCount]) => sum + deviceCount, 0) const weights = [0.12, 0.13, 0.14, 0.18, 0.15, 0.14, 0.14] await route.fulfill({ json: { summary: { total, device_count: devices, action_count: rows.length }, actions: rows.map(([action, events, deviceCount, versionCount]) => ({ action, total: events, device_count: deviceCount, version_count: versionCount, first_seen: '2026-09-24T08:12:00.000Z', last_seen: '2026-09-30T17:40:00.000Z', latest_version_name: '2.4.1', latest_device_id: '00000000-0000-4000-8000-000000000001', })), daily: rows.flatMap(([action, events]) => updaterInsightDays.map((date, index) => ({ date, action, total: Math.round(events * weights[index]), }))), versions: rows.slice(0, 6).map(([action, events, deviceCount], index) => ({ action, version_name: index % 2 ? '2.4.0' : '2.4.1', total: Math.round(events * 0.6), device_count: Math.round(deviceCount * 0.6), last_seen: '2026-09-30T17:40:00.000Z', })), devices: rows.slice(0, 5).map(([action, events], index) => ({ action, device_id: `00000000-0000-4000-8000-00000000000${index + 1}`, total: Math.max(1, Math.round(events / 40)), version_name: '2.4.1', last_seen: '2026-09-30T17:40:00.000Z', })), period: { requested_days: 7, start: '2026-09-24T00:00:00.000Z', end: '2026-09-30T23:59:59.999Z', labels: updaterInsightDays, }, }, }) }) } // Shaped like a real production rollout: a normal trickle of download // failures next to a healthy success rate. const releaseLiveInstalls = [340, 550, 660, 630, 650, 660, 570, 560, 560, 530, 480, 520, 490, 740, 660, 610, 540, 500, 460, 460, 410, 390, 365, 360, 335, 365, 305, 305, 238, 245, 190, 170, 148, 146, 98, 98, 104, 88, 106, 110, 98, 164, 205, 270, 318, 318, 330, 375, 455, 475, 465, 520, 190] const legacyCheckFailures = { excluded: 48242, plugin_versions: ['8.48.0', '8.51.16'], fixed_in: '8.53.0' } async function mockReleaseLive(page: Page, legacy = false) { const bucketMs = 30 * 60_000 const start = Date.parse('2026-10-01T13:30:00.000Z') const series = releaseLiveInstalls.map((install, index) => { const fail = Math.round(install * 0.03) return { ts: new Date(start + index * bucketMs).toISOString(), get: install + fail + 20, install, fail } }) const install = series.reduce((sum, bucket) => sum + bucket.install, 0) const fail = series.reduce((sum, bucket) => sum + bucket.fail, 0) const get = series.reduce((sum, bucket) => sum + bucket.get, 0) const deployedAt = new Date(start).toISOString() const production = { id: 1, name: 'production', is_default: true } await page.route('**/private/release_live', route => route.fulfill({ json: { release: { bundle_id: 1, version_name: '10.33.2', channel_id: 1, channel_name: 'production', deployed_at: deployedAt }, window: { start: deployedAt, end: new Date(start + series.length * bucketMs).toISOString(), bucket_minutes: 30, truncated: false }, totals: { get, install, fail, success_rate: Math.round((install / (install + fail)) * 1000) / 10 }, adoption: { devices_on_release: 20001, total_devices: 189574, percent: 10.6 }, failures: [ { action: 'download_fail', count: Math.round(fail * 0.88) }, { action: 'update_fail', count: fail - Math.round(fail * 0.88) }, ], failed_devices: { total: 484, recovered: 15, stuck: 469 }, series, channel: production, channels: [production, { id: 2, name: 'beta', is_default: false }], recent_deployments: [{ version_name: '10.33.2', channel_id: 1, channel_name: 'production', deployed_at: deployedAt }], legacy_check_failures: legacy ? legacyCheckFailures : null, generated_at: new Date().toISOString(), }, })) if (!legacy) return const bundle = { version_name: '10.33.2', install, fail, success_rate: Math.round((install / (install + fail)) * 1000) / 10, timing: { samples: 0, p50_ms: null, p70_ms: null, p90_ms: null, p95_ms: null } } await page.route('**/private/bundle_install_stats', route => route.fulfill({ json: { period: { requested_days: 7, actual_days: 7, start: deployedAt, end: new Date(start + series.length * bucketMs).toISOString() }, bundles: [bundle], totals: { install, fail, success_rate: bundle.success_rate }, legacy_check_failures: legacyCheckFailures, legacy_check_failures_by_bundle: [{ version_name: '10.33.2', excluded: legacyCheckFailures.excluded, plugin_versions: legacyCheckFailures.plugin_versions }], }, })) } /** * Console pages captured for before/after visual diffs. * Add routes here when a PR touches a new screen reviewers should compare. */ function visualTrialPlan(scheduled: boolean) { return { planName: 'Solo', productId: 'prod_LQIregjtNduh4q', amount: 1400, currency: 'usd', interval: 'month', startsAt: '2026-10-30T00:00:00.000Z', scheduled, } } function visualTrialPlanOptions() { return [ { productId: 'prod_LQIregjtNduh4q', planName: 'Solo', amount: 1400, currency: 'usd' }, { productId: 'prod_LQIs1Yucml9ChU', planName: 'Maker', amount: 3900, currency: 'usd' }, { productId: 'prod_LQIugvJcPrxhda', planName: 'Team', amount: 9900, currency: 'usd' }, ] } export const visualDiffRoutes: VisualDiffRoute[] = [ { slug: 'builds-empty', path: '/app/com.demo.app/builds', auth: true, prepare: async (page) => { await routeConsoleQuery(page, 'build_requests', route => route.fulfill(consoleQueryJson([], 0))) await page.route('**/app/com.demo.app/onboarding/builder', route => route.fulfill({ json: { canEnable: true } })) await page.reload() const introduction = page.locator('[data-test="builder-introduction"]') if (process.env.VISUAL_DIFF_PHASE === 'after') { await introduction.waitFor({ state: 'visible' }) return } // The base can already include the introduction after the Builder PR merges. await page.getByRole('heading', { name: 'Start a native build', exact: true }) .or(introduction).waitFor({ state: 'visible' }) }, }, ...[ { slug: 'onboarding-dual-goals', progress: null }, { slug: 'onboarding-dual-live-updates', progress: 'ota' }, { slug: 'onboarding-dual-builder', progress: 'builder' }, ].map(({ slug, progress }): VisualDiffRoute => ({ slug, path: '/apps', auth: true, prepare: async (page) => { const onboarding = { setup: { todo_list_version: 4, ota_todo_list_version: '1', builder_todo_list_version: '1', paths: ['ota', 'builder'], selected_path: 'builder', selected_builder_platform: progress === 'builder' ? 'android' : null, source: 'manual', outcome: 'in_progress', steps: { ota: Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: progress === 'ota' && id === 'login_cli_mcp' ? 'done' : 'pending' }])), builder: Object.fromEntries((['ios', 'android'] as const).map(platform => [platform, Object.fromEntries(BUILDER_STEP_IDS[platform].map(id => [id, { status: progress === 'builder' && platform === 'android' && id === 'start_setup' ? 'done' : 'pending' }]))])), }, } } // The production preview excludes development-only component fixtures. // Override read responses so base and head render the same assigned app. await routeConsoleQuery(page, 'apps', async (route, query) => { const matchesApp = query.operations.some(op => op.method === 'eq' && op.args[0] === 'app_id' && op.args[1] === 'com.demo.app') await forwardConsoleQuery(route, (row: any) => row?.app_id === 'com.demo.app' || (matchesApp && row?.onboarding !== undefined) ? { ...row, need_onboarding: true, onboarding } : row) }) await routeConsoleQuery(page, 'verify_getting_started', route => route.fulfill(consoleQueryJson(onboarding))) await page.route('**/private/onboarding_progress', route => route.fulfill({ json: { onboarding, hasChannel: false, checkErrors: [] } })) await page.goto('/app/com.demo.app/getting-started') await page.locator('[data-test="dual-onboarding-checklists"], [data-test="builder-checklist"]').first().waitFor() if (progress === 'builder') await page.locator('[data-test="builder-step-start_setup"][data-status="done"]').waitFor({ state: 'attached' }) }, })), // Out-of-order setup: a later step is done while an earlier one is still open. ...[ { slug: 'onboarding-ota-passed-over-step', path: 'ota' as const }, { slug: 'onboarding-builder-passed-over-step', path: 'builder' as const }, ].map(({ slug, path }): VisualDiffRoute => ({ slug, path: '/apps', auth: true, prepare: async (page) => { const onboarding = { setup: { todo_list_version: 4, ...(path === 'ota' ? { ota_todo_list_version: '1' } : { builder_todo_list_version: '1', selected_builder_platform: 'android' }), paths: [path], selected_path: path, source: 'manual', outcome: 'in_progress', steps: path === 'ota' ? { ota: Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: id === 'add_channel' ? 'done' : 'pending' }])) } : { builder: Object.fromEntries((['ios', 'android'] as const).map(platform => [platform, Object.fromEntries(BUILDER_STEP_IDS[platform].map(id => [id, { status: platform === 'android' && id === 'prepare_keystore' ? 'done' : 'pending' }]))])) }, } } await routeConsoleQuery(page, 'apps', async (route, query) => { const matchesApp = query.operations.some(op => op.method === 'eq' && op.args[0] === 'app_id' && op.args[1] === 'com.demo.app') await forwardConsoleQuery(route, (row: any) => row?.app_id === 'com.demo.app' || (matchesApp && row?.onboarding !== undefined) ? { ...row, need_onboarding: true, onboarding } : row) }) await routeConsoleQuery(page, 'verify_getting_started', route => route.fulfill(consoleQueryJson(onboarding))) await page.route('**/private/onboarding_progress', route => route.fulfill({ json: { onboarding, hasChannel: path === 'ota', checkErrors: [] } })) // Tall viewport so the passed-over banner + task panel + Skip button fit in one frame. await page.setViewportSize({ width: 1280, height: 1500 }) await page.goto('/app/com.demo.app/getting-started') await page.locator(path === 'ota' ? '[data-test="onboarding-setup-cli"]' : '[data-test="builder-step-prepare_keystore"][data-status="done"]').first().waitFor({ state: 'attached' }) }, })), { slug: 'login', path: '/login/', auth: false }, { slug: 'register', path: '/register/', auth: false }, { slug: 'register-mobile', path: '/register/', auth: false, prepare: page => page.setViewportSize({ width: 390, height: 844 }), }, { slug: 'onboarding-profile-goal', path: '/onboarding/app', auth: true, prepare: page => prepareProfileGoalScreenshot(page), }, { slug: 'onboarding-profile-goal-mobile', path: '/onboarding/app', auth: true, prepare: async (page) => { await page.setViewportSize({ width: 390, height: 844 }) await prepareProfileGoalScreenshot(page) }, }, { slug: 'onboarding-profile-resume', path: '/onboarding/app', auth: true, prepare: prepareProfileResumeScreenshot, }, { slug: 'onboarding-profile-resume-mobile', path: '/onboarding/app', auth: true, prepare: async (page) => { await page.setViewportSize({ width: 390, height: 844 }) await prepareProfileResumeScreenshot(page) }, }, { slug: 'onboarding-setup-mode', path: '/onboarding/app', auth: true, prepare: page => prepareAiOnboardingScreenshot(page, null), }, { slug: 'onboarding-ai-plan', path: '/onboarding/app', auth: true, prepare: page => prepareAiOnboardingScreenshot(page, 'ai'), }, { // Same goal and plan form, worded for the teammate who will finish the setup. slug: 'onboarding-invite-plan', path: '/onboarding/app', auth: true, prepare: page => prepareAiOnboardingScreenshot(page, 'invite'), }, { slug: 'onboarding-ai-prompt', path: '/onboarding/ai', auth: true, prepare: async (page) => { // Show the waiting state: the seeded org already owns apps. // Match only the prompt page's app projection; other app reads keep their data. await routeConsoleQuery(page, 'apps', (route, query) => query.operations.some(op => op.method === 'select' && /^app_id,\s*name$/.test(op.args[0] ?? '')) ? route.fulfill(consoleQueryJson([])) : route.fallback()) // The first load may already have found the seeded app and moved on, so open the page again. await page.goto('/onboarding/ai') if (process.env.VISUAL_DIFF_PHASE === 'after') await page.locator('[data-test="onboarding-ai-waiting"]').waitFor() }, }, { slug: 'onboarding-profile-intent', path: '/onboarding/app', auth: true, prepare: page => prepareProfileGoalScreenshot(page, true), }, { slug: 'onboarding-profile-intent-mobile', path: '/onboarding/app', auth: true, prepare: async (page) => { await page.setViewportSize({ width: 390, height: 844 }) await prepareProfileGoalScreenshot(page, true) }, }, { slug: 'onboarding-organization-profile-legacy', path: '/onboarding/organization?source=org-switcher', auth: true, prepare: async (page) => { await routeConsoleQuery(page, 'users', route => forwardConsoleQuery(route, (row: any) => ({ ...row, first_name: '', last_name: '', onboarding: { ...row.onboarding, version: 6 } }))) await page.reload() await page.locator('[data-test="onboarding-intent-ota"]').click() await page.locator('[data-test="onboarding-mode-name"]').click() await page.locator('[data-test="onboarding-org-name"]').scrollIntoViewIfNeeded() }, }, { slug: 'onboarding-organization-profile', path: '/onboarding/app', auth: true, prepare: async (page) => { await routeConsoleQuery(page, 'users', async (route, query) => { if (consoleQueryMethod(query) !== 'GET') return route.fulfill(consoleQueryJson(consoleQueryRows(query, []))) await forwardConsoleQuery(route, (row: any) => ({ ...row, first_name: 'Example', last_name: 'User', onboarding: { profile_completed_at: '2026-10-20T10:00:00.000Z', org_name: 'Example Company', setup_mode: 'manual' } })) }) await page.reload() // Base captures predate the required name step and still open on the legacy welcome canvas. const welcome = page.locator('[data-test="onboarding-welcome-continue"]') const otaIntent = page.locator('[data-test="onboarding-intent-ota"]') await welcome.or(otaIntent).waitFor() if (await welcome.isVisible()) await welcome.click() await otaIntent.click() await page.locator('[data-test="app-onboarding-continue-intent"]').click() const assistant = page.locator('[data-test="onboarding-development-environment-ai_assistant"]') const appName = page.locator('[data-test="app-onboarding-name"]') await assistant.or(appName).waitFor() if (await assistant.isVisible()) { await assistant.click() await page.locator('[data-test="app-onboarding-continue-development-environment"]').click() } await appName.fill('Example App') await page.locator('[data-test="app-onboarding-continue"]').click() await page.locator('[data-test="app-onboarding-continue"]').click() await page.locator('[data-test="onboarding-org-name"]').or(page.locator('[data-test="onboarding-company-name-summary"]')).waitFor() }, }, { slug: 'dashboard', path: '/dashboard', auth: true }, { slug: 'dashboard-trial-card-prompt', path: '/dashboard', auth: true, prepare: async (page) => { // Trial org whose first device just installed an update, with no card on file yet. await page.route('**/private/config', route => route.fulfill({ json: { stripeEnabled: true } })) await routeConsoleQuery(page, 'get_orgs_v7', route => forwardConsoleQuery(route, (org: any) => ({ ...org, paying: false, trial_left: 9 }))) await routeConsoleQuery(page, 'apps', route => forwardConsoleQuery(route, (row: any) => row?.app_id === 'com.demo.app' && row?.onboarding !== undefined ? { ...row, onboarding: { ...row.onboarding, features: { ...row.onboarding?.features, ota: { started_at: '2026-10-20T09:00:00.000Z', succeeded_at: '2026-10-20T10:00:00.000Z' } } } } : row)) let promptRequested = false await page.route('**/private/stripe_billing/trial-plan', (route) => { promptRequested = true return route.fulfill({ json: { card: null, plan: visualTrialPlan(false), plans: visualTrialPlanOptions() } }) }) await page.reload() const prompt = page.locator('[data-test="trial-card-prompt-add"]') // The base branch has no prompt: capture the plain dashboard there. The head (after) // capture, or any build that asked for the trial plan, must render it or fail. if (process.env.VISUAL_DIFF_PHASE === 'after') { await prompt.waitFor({ timeout: 15_000 }) return } await prompt.waitFor({ timeout: 15_000 }).catch(() => {}) if (promptRequested) await prompt.waitFor({ timeout: 5_000 }) }, }, { slug: 'org-billing-trial-plan-offer', path: '/settings/organization/billing', auth: true, prepare: async (page) => { await page.route('**/private/stripe_billing/overview', route => route.fulfill({ json: { publishableKey: '', card: { id: 'pm_visual', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2030 }, subscription: null, invoices: [], openInvoice: null, trialPlan: visualTrialPlan(false), } })) await page.reload() await page.locator('[data-test="billing-subscription"]').waitFor() }, }, { slug: 'org-billing-trial-plan-scheduled', path: '/settings/organization/billing', auth: true, prepare: async (page) => { await page.route('**/private/stripe_billing/overview', route => route.fulfill({ json: { publishableKey: '', card: { id: 'pm_visual', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2030 }, subscription: null, invoices: [], openInvoice: null, trialPlan: visualTrialPlan(true), } })) await page.reload() await page.locator('[data-test="billing-subscription"]').waitFor() }, }, { slug: 'org-billing-trial-plan-start-now', path: '/settings/organization/billing', auth: true, prepare: async (page) => { await page.route('**/private/stripe_billing/overview', route => route.fulfill({ json: { publishableKey: '', card: { id: 'pm_visual', brand: 'visa', last4: '4242', expMonth: 12, expYear: 2030 }, subscription: null, invoices: [], openInvoice: null, trialPlan: visualTrialPlan(true), } })) await page.reload() // The base revision has no Start now button: capture Billing there instead. if (process.env.VISUAL_DIFF_PHASE === 'after') { await page.locator('[data-test="billing-trial-plan-start-now"]').click() await page.locator('[data-test="trial-plan-start-now-confirm"]').waitFor() } else { await page.locator('[data-test="billing-subscription"]').waitFor() } }, }, { slug: 'org-billing-cancel-reason', path: '/settings/organization/billing', auth: true, prepare: async (page) => { await page.route('**/private/sso/check-enforcement', route => route.fulfill({ json: { allowed: true } })) await page.route('**/private/stripe_past_due', route => route.fulfill({ json: { past_due: false, invoice: null } })) await page.route('**/private/stripe_billing/overview', route => route.fulfill({ json: { publishableKey: '', card: { id: 'pm_visual', brand: 'visa', last4: '4242', expMonth: 4, expYear: 2030 }, backupCard: null, subscription: { status: 'active', cancelAtPeriodEnd: false, cancelAt: null, currentPeriodEnd: new Date(Date.now() + 18 * 86400000).toISOString() }, invoices: [], openInvoice: null, trialPlan: null, } })) await page.goto('/settings/organization/billing') await page.locator('[data-test="billing-cancel"]').click() await page.locator('[data-test="cancel-reason-too_expensive"]').click() await page.locator('[data-test="cancel-plan-form"]').waitFor() }, }, { slug: 'org-billing-cancel-loss', path: '/settings/organization/billing', auth: true, prepare: async (page) => { await page.route('**/private/sso/check-enforcement', route => route.fulfill({ json: { allowed: true } })) await page.route('**/private/stripe_past_due', route => route.fulfill({ json: { past_due: false, invoice: null } })) await page.route('**/private/stripe_billing/overview', route => route.fulfill({ json: { publishableKey: '', card: { id: 'pm_visual', brand: 'visa', last4: '4242', expMonth: 4, expYear: 2030 }, backupCard: null, subscription: { status: 'active', cancelAtPeriodEnd: false, cancelAt: null, currentPeriodEnd: new Date(Date.now() + 18 * 86400000).toISOString() }, invoices: [], openInvoice: null, trialPlan: null, } })) await page.route('**/private/stripe_billing/cancel-offer', route => route.fulfill({ json: { offer: { id: '00000000-0000-4000-8000-000000000001', percentOff: 50, durationMonths: 3, monthlyAmount: 2900, discountedAmount: 1450, currency: 'usd' }, } })) await page.goto('/settings/organization/billing') await page.locator('[data-test="billing-cancel"]').click() await page.locator('[data-test="cancel-reason-too_expensive"]').click() await page.locator('[data-test="cancel-plan-confirm"]').click() if (process.env.VISUAL_DIFF_PHASE === 'after') await page.locator('[data-test="cancel-loss-reminder"]').waitFor() else await page.locator('[data-test="cancel-loss-reminder"]').or(page.locator('[data-test="cancel-save-offer"]')).first().waitFor() }, }, { slug: 'org-billing-cancel-offer', path: '/settings/organization/billing', auth: true, prepare: async (page) => { await page.route('**/private/sso/check-enforcement', route => route.fulfill({ json: { allowed: true } })) await page.route('**/private/stripe_past_due', route => route.fulfill({ json: { past_due: false, invoice: null } })) await page.route('**/private/stripe_billing/overview', route => route.fulfill({ json: { publishableKey: '', card: { id: 'pm_visual', brand: 'visa', last4: '4242', expMonth: 4, expYear: 2030 }, backupCard: null, subscription: { status: 'active', cancelAtPeriodEnd: false, cancelAt: null, currentPeriodEnd: new Date(Date.now() + 18 * 86400000).toISOString() }, invoices: [], openInvoice: null, trialPlan: null, } })) await page.route('**/private/stripe_billing/cancel-offer', route => route.fulfill({ json: { offer: { id: '00000000-0000-4000-8000-000000000001', percentOff: 50, durationMonths: 3, monthlyAmount: 2900, discountedAmount: 1450, currency: 'usd' }, } })) await page.goto('/settings/organization/billing') await page.locator('[data-test="billing-cancel"]').click() await page.locator('[data-test="cancel-reason-too_expensive"]').click() await page.locator('[data-test="cancel-plan-confirm"]').click() await page.locator('[data-test="cancel-loss-reminder"]') .or(page.locator('[data-test="cancel-save-offer"]')) .first() .waitFor() if (await page.locator('[data-test="cancel-loss-reminder"]').isVisible()) await page.locator('[data-test="cancel-loss-continue"]').click() await page.locator('[data-test="cancel-save-offer"]').waitFor() }, }, { slug: 'account-settings', path: '/settings/account', auth: true }, { slug: 'account-notifications', path: '/settings/account/notifications', auth: true }, { slug: 'security-updates-notifications-desktop', path: '/settings/account/notifications', auth: true, prepare: async (page) => { await prepareSecurityUpdatesNotificationsSection(page, { width: 1440, height: 900 }) }, }, { slug: 'security-updates-notifications-mobile', path: '/settings/account/notifications', auth: true, prepare: async (page) => { await prepareSecurityUpdatesNotificationsSection(page, { width: 390, height: 844 }) }, }, { slug: 'security-updates-observe-desktop', path: '/app/com.demo.app/observe/errors', auth: true, prepare: async (page) => { await prepareSecurityUpdatesObserveTab(page, { width: 1440, height: 900 }) }, }, { slug: 'security-updates-email-preferences-desktop', path: '/email-preferences', prepare: async (page) => { await prepareSecurityUpdatesEmailPreferences(page, { width: 1440, height: 900 }) }, }, { slug: 'security-updates-email-preferences-mobile', path: '/email-preferences', prepare: async (page) => { await prepareSecurityUpdatesEmailPreferences(page, { width: 390, height: 844 }) }, }, { slug: 'security-updates-observe-mobile', path: '/app/com.demo.app/observe/errors', auth: true, prepare: async (page) => { await prepareSecurityUpdatesObserveTab(page, { width: 390, height: 844 }) }, }, { slug: 'email-verification-send', path: '/resend_email', auth: true, prepare: async (page) => { await routeConsoleQuery(page, 'user_security', route => route.fulfill(consoleQueryJson({ email_otp_verified_at: null }))) await page.goto('/resend_email?reason=email_not_verified&return_to=/settings/account') await page.getByRole('button', { name: 'Send verification code', exact: true }).waitFor() }, }, { slug: 'email-verification-code', path: '/resend_email', auth: true, prepare: async (page) => { // Keep screenshots deterministic and never send a real verification email. await routeConsoleQuery(page, 'user_security', route => route.fulfill(consoleQueryJson({ email_otp_verified_at: null }))) await page.route('**/private/console/auth/otp', route => route.fulfill({ json: { user: null, session: null } })) await page.goto('/resend_email?reason=email_not_verified&return_to=/settings/account') await page.getByRole('button', { name: 'Send verification code', exact: true }).click() await page.getByLabel('Enter the verification code', { exact: true }).waitFor() }, }, { slug: 'organization-credits', path: '/settings/organization/credits', auth: true }, { slug: 'apps', path: '/apps', auth: true }, { slug: 'apps-sidebar-collapsed', path: '/apps', auth: true, prepare: async (page) => { const toggle = page.locator('[data-test="sidebar-collapse-toggle"]') if (!(await toggle.count())) return const sidebar = page.locator('#sidebar') if (await sidebar.isVisible()) { await toggle.click() // Base may fully hide the sidebar (width 0). Head keeps a ~48px icon rail. await page.waitForFunction(() => { const el = document.querySelector('#sidebar') return !!el && el.getBoundingClientRect().width < 56 }) } }, }, { slug: 'app-overview', path: '/app/com.demo.app', auth: true }, { slug: 'app-settings-usage', path: '/app/com.demo.app/settings/usage', auth: true }, { slug: 'app-dashboard-native', path: '/app/com.demo.app/native', auth: true }, { slug: 'app-dashboard-installs', path: '/app/com.demo.app/installs', auth: true }, { slug: 'app-dashboard-active-bundle', path: '/app/com.demo.app/active-bundle', auth: true }, { slug: 'app-dashboard-live-release', path: '/app/com.demo.app/live', auth: true, prepare: async (page) => { // Seed data has no recent rollout, so fixture the live release stats. await mockReleaseLive(page) await page.goto('/app/com.demo.app/live') await page.getByText('10.33.2', { exact: true }).waitFor() }, }, { slug: 'app-dashboard-live-release-legacy-plugin', path: '/app/com.demo.app/live', auth: true, prepare: async (page) => { // Devices on plugins older than 8.53.0 get a notice about ignored update-check failures. await mockReleaseLive(page, true) await page.goto('/app/com.demo.app/live') await page.getByText('10.33.2', { exact: true }).first().waitFor() if (process.env.VISUAL_DIFF_PHASE === 'after') await page.locator('[data-testid="release-live-legacy-plugin"]').waitFor() }, }, { slug: 'onboarding-setup-v3', path: '/apps', auth: true, prepare: async (page) => { // Read-only response fixtures let both base and head render the same app. // The base ignores version 3; the head shows the experiment treatment. const onboarding = { setup: { todo_list_version: 3, source: 'manual', outcome: 'in_progress', steps: {} } } await routeConsoleQuery(page, 'apps', route => forwardConsoleQuery(route, (row: any) => row?.app_id === 'com.demo.app' ? { ...row, need_onboarding: true, onboarding } : row)) await routeConsoleQuery(page, 'verify_getting_started', route => route.fulfill(consoleQueryJson(onboarding))) await page.route('**/private/onboarding_progress', route => route.fulfill({ json: { onboarding, hasChannel: false, checkErrors: [] } })) await page.goto('/app/com.demo.app/getting-started') await page.getByRole('heading', { name: /Start guided setup|Finish setup in your app/ }).waitFor() }, }, { slug: 'app-getting-started', path: '/app/com.demo.app/getting-started', auth: true }, { slug: 'app-settings', path: '/app/com.demo.app/settings', auth: true, prepare: async (page) => { const fieldset = page.locator('[data-test="device-data-collection"]') try { await fieldset.waitFor({ state: 'visible', timeout: 5000 }) await fieldset.scrollIntoViewIfNeeded() } catch (error) { if (error instanceof Error && error.name === 'TimeoutError') return throw error } }, }, { slug: 'app-settings-device-data-collection', path: '/app/com.demo.app/settings', auth: true, prepare: async (page) => { const fieldset = page.locator('[data-test="device-data-collection"]') try { await fieldset.waitFor({ state: 'visible', timeout: 5000 }) await fieldset.locator('summary').click() await fieldset.scrollIntoViewIfNeeded() } catch (error) { if (error instanceof Error && error.name === 'TimeoutError') return throw error } }, }, { slug: 'app-settings-access', path: '/app/com.demo.app/settings/access', auth: true }, { slug: 'org-settings', path: '/settings/organization', auth: true }, { slug: 'org-settings-team', path: '/settings/organization/members', auth: true }, { slug: 'org-settings-billing', path: '/settings/organization/plans', auth: true }, { slug: 'channels', path: '/app/com.demo.app/channels', auth: true }, { // The create dialog offers the AI agent prompt under the name field. slug: 'channels-create-dialog', path: '/app/com.demo.app/channels', auth: true, prepare: async (page) => { await page.getByRole('button', { name: 'Add', exact: true }).first().click() await page.locator('#dialog-v2-content input[type="text"]').waitFor() if (process.env.VISUAL_DIFF_PHASE === 'after') await page.locator('[data-test="ai-task-create-channel"]').waitFor() }, }, { // Bundle upload steps, with the AI agent card above the teammate invite. slug: 'bundles-new', path: '/app/com.demo.app/bundles/new', auth: true, prepare: async (page) => { if (process.env.VISUAL_DIFF_PHASE === 'after') await page.locator('[data-test="ai-task-upload-bundle"]').waitFor() }, }, { // Add app asks for the AI agent or the manual wizard first. slug: 'add-app-setup-mode', path: '/app/new', auth: true, prepare: async (page) => { if (process.env.VISUAL_DIFF_PHASE === 'after') await page.locator('[data-test="add-app-setup-mode"]').waitFor() }, }, { slug: 'add-app-ai', path: '/app/new', auth: true, prepare: async (page) => { if (process.env.VISUAL_DIFF_PHASE !== 'after') return await page.locator('[data-test="add-app-setup-mode-ai"]').click() await page.locator('[data-test="ai-task-add-app"]').waitFor() }, }, { slug: 'channels-empty', path: '/app/com.demo.app/channels', auth: true, prepare: async (page) => { await page.emulateMedia({ reducedMotion: 'reduce' }) await routeConsoleQuery(page, 'channels', route => route.fulfill(consoleQueryJson([], 0))) await page.reload() const introduction = page.locator('[data-test="channel-introduction"]') if (process.env.VISUAL_DIFF_PHASE === 'after') { await introduction.waitFor({ state: 'visible' }) return } // The base can leave the loaded empty table visible while create permission resolves. await introduction .or(page.getByRole('cell', { name: 'No elements found', exact: true })).waitFor({ state: 'visible' }) }, }, { slug: 'devices', path: '/app/com.demo.app/devices', auth: true, prepare: async (page) => { // Open Filters so reviewers see platform/bundle controls on head. // Base still uses the legacy dropdown, so fall back only when modal is absent. await page.getByRole('button', { name: /filters/i }).click() const modal = page.locator('[data-test="data-table-filters-modal"]') try { await modal.waitFor({ state: 'visible', timeout: 3000 }) } catch { await page.getByText('Override', { exact: true }).waitFor({ state: 'visible' }) return } await page.locator('[data-test="device-platform-filter"]').waitFor({ state: 'visible' }) // Head has OS range filters; base from #2792 only has platform/bundle. const osFilter = page.locator('[data-test="device-os-version-filter"]') try { await osFilter.waitFor({ state: 'visible', timeout: 3000 }) } catch { // Keep the platform/bundle modal screenshot on older bases. } }, }, { slug: 'observe', path: '/app/com.demo.app/observe/updater', auth: true }, { slug: 'observe-updater-failures', path: '/app/com.demo.app/observe/updater', auth: true, prepare: async (page) => { // Seed data has no updater failures, so fixture the insights to show the populated layout. await mockUpdaterInsights(page) await page.goto('/app/com.demo.app/observe/updater?days=7') await page.getByRole('heading', { name: /Error categories|Failure types/ }).waitFor() }, }, { slug: 'observe-updater-failures-details', path: '/app/com.demo.app/observe/updater', auth: true, prepare: async (page) => { await mockUpdaterInsights(page) await page.goto('/app/com.demo.app/observe/updater?days=7') const heading = page.getByRole('heading', { name: /Error categories|Failure types/ }) await heading.waitFor() await heading.evaluate(el => el.scrollIntoView({ block: 'start' })) }, }, { slug: 'observe-logs', path: '/app/com.demo.app/observe/logs', auth: true, prepare: async (page) => { // Open Actions so reviewers see the filter modal on head. // Base still uses the legacy dropdown, so fall back when modal is absent. const openButton = page.locator('[data-test="log-table-filters-open"]') if (await openButton.count()) { await openButton.click() await page.locator('[data-test="log-table-filters-modal"]').waitFor({ state: 'visible' }) return } await page.getByRole('button', { name: /actions/i }).click() await page.getByText('All failures', { exact: true }).waitFor({ state: 'visible' }) }, }, { slug: 'observe-native', path: '/app/com.demo.app/observe/native', auth: true, prepare: async (page) => { // Seed data has no native observe events, so fixture the stats to show the populated layout. await mockNativeObserveStats(page) await page.goto('/app/com.demo.app/observe/native') // Base renders "Action breakdown" as a heading, head as a detail tab. await page.getByRole('heading', { name: 'Action breakdown' }).or(page.getByRole('button', { name: 'Action breakdown', exact: true })).first().waitFor() }, }, { slug: 'observe-native-actions', path: '/app/com.demo.app/observe/native', auth: true, prepare: async (page) => { await mockNativeObserveStats(page) await page.goto('/app/com.demo.app/observe/native') const heading = page.getByRole('heading', { name: 'Action breakdown' }) const tab = page.getByRole('button', { name: 'Action breakdown', exact: true }) await heading.or(tab).first().waitFor() // Head shows one detail table at a time behind third-level tabs; base stacks them. if (await tab.isVisible()) await tab.click() else await heading.evaluate(el => el.scrollIntoView({ block: 'start' })) }, }, { slug: 'observe-compatibility', path: '/app/com.demo.app/observe/compatibility', auth: true }, { slug: 'observe-plugins', path: '/app/com.demo.app/observe/plugins', auth: true }, { slug: 'channel-statistics', path: '/app/com.demo.app/channel/1/statistics', auth: true, }, { slug: 'api-keys-app-preview', path: '/apikeys', auth: true, prepare: async (page) => { const createKey = page.locator('[data-test="create-key"]') await createKey.waitFor({ state: 'visible', timeout: 30_000 }) await createKey.click() const appOnlyScope = page.locator('[data-test="create-key-app-only-scope"]') if (await appOnlyScope.count()) await appOnlyScope.check() }, }, { slug: 'api-keys-secure-column', path: '/apikeys', auth: true, prepare: async (page) => { const createKey = page.locator('[data-test="create-key"]') await createKey.waitFor({ state: 'visible', timeout: 30_000 }) // Seed includes hashed + plain keys for test@capgo.app — wait for the table body. await page.locator('table tbody tr').first().waitFor({ state: 'visible', timeout: 30_000 }) const hashed = page.getByLabel('Secure key (hidden)').or(page.getByText('Secure key (hidden)')) await hashed.first().waitFor({ state: 'visible', timeout: 30_000 }) }, }, { slug: 'org-webhooks-mcp-desktop', path: '/settings/organization/webhooks/', auth: true, prepare: async (page) => { await prepareOrgWebhooksMcpScreenshot(page, { width: 1440, height: 900 }) }, }, { slug: 'org-webhooks-mcp-mobile', path: '/settings/organization/webhooks/', auth: true, prepare: async (page) => { await prepareOrgWebhooksMcpScreenshot(page, { width: 390, height: 844 }, true) }, }, { slug: 'mcp-connect', path: '/mcp', auth: true, prepare: async (page) => { // /mcp does not exist on the base commit yet: the "before" capture shows the 404 page. if (process.env.VISUAL_DIFF_PHASE !== 'after') return await page.locator('[data-test="mcp-connect-card"]').waitFor({ state: 'visible', timeout: 30_000 }) }, }, ] export const visualDiffViewport = { width: 1280, height: 720, } as const