Ctrl K
ComunidadGuías…

playwright/visual-diff.config.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<void>
}

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<string, unknown> | 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