diff --git a/.github/workflows/kilo-app-release.yml b/.github/workflows/kilo-app-release.yml index 94888be8e2..579c7ef5d6 100644 --- a/.github/workflows/kilo-app-release.yml +++ b/.github/workflows/kilo-app-release.yml @@ -52,8 +52,64 @@ jobs: validate: uses: ./.github/workflows/kilo-app-ci.yml + preflight: + needs: [check-changes] + if: needs.check-changes.outputs.should_build == 'true' && github.ref == 'refs/heads/main' + runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: useblacksmith/checkout@41cdeedae8edb2e684ba22896a5fd2a3cb85db6b # v1 + with: + lfs: true + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - name: Setup Node + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version-file: '.nvmrc' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check EXPO_TOKEN + run: | + if [ -z "${{ secrets.EXPO_TOKEN }}" ]; then + echo "::error::EXPO_TOKEN secret is required for the release preflight" + exit 1 + fi + echo "EXPO_TOKEN is present" + + - name: Verify EAS production environment + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + ENV_OUTPUT=$(pnpx eas-cli@21.8.0 env:list --environment production --format long) + MISSING="" + for NAME in API_BASE_URL WEB_BASE_URL CLOUD_AGENT_WS_URL SESSION_INGEST_WS_URL APPSFLYER_DEV_KEY APPSFLYER_APP_ID KILO_CHAT_URL EVENT_SERVICE_URL NOTIFICATIONS_URL POSTHOG_API_KEY SENTRY_AUTH_TOKEN EXPO_PUBLIC_SENTRY_ENVIRONMENT; do + if ! printf '%s\n' "$ENV_OUTPUT" | grep -qE "^Name[[:space:]]+${NAME}[[:space:]]*$"; then + MISSING="$MISSING $NAME" + fi + done + if [ -n "$MISSING" ]; then + echo "::error::Missing EAS production environment variables:$MISSING" + exit 1 + fi + echo "All required EAS production environment variables are present" + + - name: Assert production config contract + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: EAS_BUILD_PROFILE=production pnpx eas-cli@21.8.0 env:exec production --non-interactive 'pnpm assert:config' + build-and-submit: - needs: [check-changes, validate] + needs: [check-changes, validate, preflight] if: needs.check-changes.outputs.should_build == 'true' && github.ref == 'refs/heads/main' runs-on: ${{ vars.RUNNER_DEFAULT_LABEL || 'ubuntu-latest' }} timeout-minutes: 60 @@ -74,11 +130,59 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build and submit + - name: Build iOS and Android + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + mkdir -p artifacts + pnpx eas-cli@21.8.0 build --profile production --platform all --non-interactive --json --wait > build.json + node ../../scripts/inspect-mobile-artifacts.mjs --select build.json > urls.txt + echo "IOS_URL=$(sed -n '1p' urls.txt)" >> "$GITHUB_ENV" + echo "ANDROID_URL=$(sed -n '2p' urls.txt)" >> "$GITHUB_ENV" + + - name: Download artifacts + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: | + download() { + url="$1" + out="$2" + label="$3" + if curl -fL -H "Authorization: Bearer $EXPO_TOKEN" -o "$out" "$url"; then + echo "$label: downloaded with Authorization header" + elif curl -fL -o "$out" "$url"; then + echo "$label: downloaded without Authorization header (pre-signed URL rejected the header)" + else + echo "::error::$label download failed" + return 1 + fi + } + download "$IOS_URL" artifacts/app.ipa "iOS" + download "$ANDROID_URL" artifacts/app.aab "Android" + + - name: Setup Java + uses: actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3 # v4.9.1 + with: + distribution: 'temurin' + java-version: '17' + + - name: Inspect artifacts + working-directory: apps/mobile + run: node ../../scripts/inspect-mobile-artifacts.mjs artifacts/app.ipa artifacts/app.aab build.json + + - name: Submit iOS + working-directory: apps/mobile + env: + EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} + run: pnpx eas-cli@21.8.0 submit --profile production --platform ios --non-interactive --path artifacts/app.ipa + + - name: Submit Android working-directory: apps/mobile env: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} - run: pnpx eas-cli@latest build --profile production --platform all --auto-submit --non-interactive + run: pnpx eas-cli@21.8.0 submit --profile production --platform android --non-interactive --path artifacts/app.aab - name: Tag release run: | diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 5b3b7bdc90..fc34f66e3e 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -2,17 +2,50 @@ import type { ExpoConfig } from 'expo/config'; import { ENV_KEYS, OPTIONAL_ENV_KEYS } from './src/lib/env-keys'; import { SENTRY_NATIVE_OPTIONS } from './src/lib/sentry-dsn'; import { UNIVERSAL_LINK_PATH_PATTERNS } from './src/lib/universal-link-paths'; +import { + assertProductionHost, + assertUrlScheme, + PRODUCTION_HOSTS, + URL_SCHEMES, +} from './src/lib/url-contract'; +const isProductionBuild = process.env.EAS_BUILD_PROFILE === 'production'; + +// Required env is fatal by build intent: a production build must never ship +// with a missing value, so throw under EAS_BUILD_PROFILE === 'production'. +// Otherwise keep the old behavior: warn under GITHUB_ACTIONS, throw locally. const missing = Object.values(ENV_KEYS).filter(key => !process.env[key]); if (missing.length > 0) { const message = `Missing required environment variables: ${missing.join(', ')}`; - if (process.env.GITHUB_ACTIONS) { + if (isProductionBuild) { + throw new Error(message); + } else if (process.env.GITHUB_ACTIONS) { console.warn(`⚠️ ${message}`); } else { throw new Error(message); } } +// URL contract: every URL value must use its allowed scheme. Non-production +// builds additionally permit http:/ws: for local development; production +// builds also assert the host against the production allowlist. +for (const [key, schemes] of Object.entries(URL_SCHEMES)) { + const value = process.env[ENV_KEYS[key as keyof typeof ENV_KEYS]]; + if (!value) continue; + assertUrlScheme(key, value, schemes, { allowInsecure: !isProductionBuild }); + if (isProductionBuild) { + assertProductionHost(key, value, PRODUCTION_HOSTS); + } +} + +// Source-map gate: an unauthenticated production artifact must never reach the +// stores with silently missing symbolication. +if (isProductionBuild && !process.env.SENTRY_AUTH_TOKEN) { + throw new Error( + 'Missing SENTRY_AUTH_TOKEN: production builds require an authenticated Sentry source-map upload.' + ); +} + // Google OAuth client IDs are public identifiers (committed .env, all EAS // environments). The conditional below tolerates their absence so the app still builds when a // checkout lacks them; the native Google button hides itself when undefined. @@ -222,6 +255,7 @@ const config: ExpoConfig = { Object.entries(OPTIONAL_ENV_KEYS).map(([key, env]) => [key, process.env[env]]) ), router: {}, + isProductionBuild, eas: { projectId: '2cf05e39-90b5-48a5-a8a5-e0b3423cf3f4', }, diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json index b7f5d4b6dd..ba320184b1 100644 --- a/apps/mobile/eas.json +++ b/apps/mobile/eas.json @@ -1,6 +1,6 @@ { "cli": { - "version": ">= 21.1.0", + "version": "21.8.0", "appVersionSource": "remote" }, "build": { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9a757588b0..03237ef163 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -6,12 +6,13 @@ "start": "expo start --dev-client", "android": "expo run:android", "ios": "expo run:ios", - "build": "pnpx eas-cli@latest build --profile development", - "build:ios": "pnpx eas-cli@latest build -p ios --profile development", - "build:android": "pnpx eas-cli@latest build -p android --profile development", - "release:internal": "pnpx eas-cli@latest build --profile production --auto-submit", - "release:internal:ios": "pnpx eas-cli@latest build -p ios --profile production --auto-submit", - "release:internal:android": "pnpx eas-cli@latest build -p android --profile production --auto-submit", + "build": "pnpx eas-cli@21.8.0 build --profile development", + "build:ios": "pnpx eas-cli@21.8.0 build -p ios --profile development", + "build:android": "pnpx eas-cli@21.8.0 build -p android --profile development", + "release:internal": "pnpx eas-cli@21.8.0 build --profile production --auto-submit", + "release:internal:ios": "pnpx eas-cli@21.8.0 build -p ios --profile production --auto-submit", + "release:internal:android": "pnpx eas-cli@21.8.0 build -p android --profile production --auto-submit", + "assert:config": "node scripts/assert-expo-config.mjs", "typecheck": "tsgo --noEmit", "lint": "pnpm -w exec oxlint --config apps/mobile/.oxlintrc.json apps/mobile/src", "format": "oxfmt src", diff --git a/apps/mobile/scripts/assert-expo-config.mjs b/apps/mobile/scripts/assert-expo-config.mjs new file mode 100644 index 0000000000..6f68e09021 --- /dev/null +++ b/apps/mobile/scripts/assert-expo-config.mjs @@ -0,0 +1,96 @@ +import { execFileSync } from 'node:child_process'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ENV_KEYS } from '../src/lib/env-keys.js'; + +// Contract values mirrored from app.config.ts (bundle id, package, scheme, +// associated domain, blocked permissions, and Sentry plugin). ENV_KEYS is +// imported live from src/lib/env-keys.js. The script runs the full evaluated +// config, so these must match the resolved build-time output, not the raw +// app.config.ts source. +const BUNDLE_IDENTIFIER = 'com.kilocode.kiloapp'; +const ANDROID_PACKAGE = 'com.kilocode.kiloapp'; +const SCHEME = 'kiloapp'; +const ASSOCIATED_DOMAIN = 'applinks:app.kilo.ai'; +const BLOCKED_PERMISSIONS = [ + 'android.permission.READ_MEDIA_IMAGES', + 'android.permission.READ_MEDIA_VIDEO', + 'android.permission.READ_MEDIA_AUDIO', +]; +const SENTRY_PLUGIN = '@sentry/react-native/expo'; + +const mobileDir = join(dirname(fileURLToPath(import.meta.url)), '..'); + +let raw; +try { + raw = execFileSync('npx', ['expo', 'config', '--json'], { + cwd: mobileDir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} catch (error) { + console.error(`Failed to run "npx expo config --json" from ${mobileDir}: ${error.message}`); + process.exit(1); +} + +let config; +try { + config = JSON.parse(raw); +} catch (error) { + console.error(`"npx expo config --json" returned invalid JSON: ${error.message}`); + process.exit(1); +} + +const failures = []; + +function check(condition, message) { + if (!condition) { + failures.push(message); + } +} + +check( + config.ios?.bundleIdentifier === BUNDLE_IDENTIFIER, + `ios.bundleIdentifier must be "${BUNDLE_IDENTIFIER}"` +); +check(config.android?.package === ANDROID_PACKAGE, `android.package must be "${ANDROID_PACKAGE}"`); +check(config.scheme === SCHEME, `scheme must be "${SCHEME}"`); + +const associatedDomains = config.ios?.associatedDomains ?? []; +check( + associatedDomains.includes(ASSOCIATED_DOMAIN), + `ios.associatedDomains must contain "${ASSOCIATED_DOMAIN}"` +); + +const blockedPermissions = config.android?.blockedPermissions ?? []; +const blockedPermissionsMatch = + blockedPermissions.length === BLOCKED_PERMISSIONS.length && + BLOCKED_PERMISSIONS.every(permission => blockedPermissions.includes(permission)); +check( + blockedPermissionsMatch, + `android.blockedPermissions must equal exactly [${BLOCKED_PERMISSIONS.join(', ')}]` +); + +const pluginNames = (config.plugins ?? []).map(plugin => + Array.isArray(plugin) ? plugin[0] : plugin +); +check(pluginNames.includes(SENTRY_PLUGIN), `plugins must include "${SENTRY_PLUGIN}"`); + +const extra = config.extra ?? {}; +for (const key of Object.keys(ENV_KEYS)) { + const value = extra[key]; + if (value === undefined || value === null || value === '') { + failures.push(`extra.${key} must be present and non-empty`); + } +} + +if (failures.length > 0) { + console.error('Expo config contract violations:'); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); +} + +console.log('Expo config contract OK'); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.mounted.test.tsx new file mode 100644 index 0000000000..d7b10bd570 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.mounted.test.tsx @@ -0,0 +1,97 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import CodeReviewerScopeRoute from './index'; + +const useLocalSearchParamsMock = vi.hoisted(() => vi.fn()); + +vi.mock('expo-router', () => ({ + useLocalSearchParams: useLocalSearchParamsMock, +})); + +vi.mock('@/components/invalid-route-state', () => ({ + InvalidRouteState: 'InvalidRouteState', +})); + +vi.mock('@/components/code-reviewer/platform-list-screen', () => ({ + PlatformListScreen: 'PlatformListScreen', +})); + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown { + if (!instance) { + return undefined; + } + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + return instance.props[key]; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ +} + +function mountRoute(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(CodeReviewerScopeRoute)); + }); + if (!ref.current) { + throw new Error('route did not render'); + } + return ref.current; +} + +beforeEach(() => { + useLocalSearchParamsMock.mockReset(); +}); + +describe('CodeReviewerScopeRoute invalid scope', () => { + it('renders InvalidRouteState with the profile backTo when scope is undefined', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: undefined }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)'); + expect(findByType(renderer.root, 'PlatformListScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('renders InvalidRouteState with the profile backTo when scope is an array', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: ['personal', 'org'] }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)'); + expect(findByType(renderer.root, 'PlatformListScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); + +describe('CodeReviewerScopeRoute valid scope', () => { + it('renders the platform list with the parsed scope', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: 'personal' }); + const renderer = mountRoute(); + + const screen = findByType(renderer.root, 'PlatformListScreen'); + expect(screen).toHaveLength(1); + expect(propOf(screen[0], 'scope')).toBe('personal'); + expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.tsx index 0ada49eaa5..7d5feaf312 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/index.tsx @@ -1,8 +1,16 @@ -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { PlatformListScreen } from '@/components/code-reviewer/platform-list-screen'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { parseParam } from '@/lib/route-params'; export default function CodeReviewerScopeRoute() { - const { scope } = useLocalSearchParams<{ scope: string }>(); + const { scope: rawScope } = useLocalSearchParams<{ scope: string }>(); + const scope = parseParam(rawScope); + + if (!scope) { + return ; + } + return ; } diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.mounted.test.tsx new file mode 100644 index 0000000000..2523613b62 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.mounted.test.tsx @@ -0,0 +1,97 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import CodeReviewerManualReviewRoute from './manual-review'; + +const useLocalSearchParamsMock = vi.hoisted(() => vi.fn()); + +vi.mock('expo-router', () => ({ + useLocalSearchParams: useLocalSearchParamsMock, +})); + +vi.mock('@/components/invalid-route-state', () => ({ + InvalidRouteState: 'InvalidRouteState', +})); + +vi.mock('@/components/code-reviewer/manual-review-screen', () => ({ + ManualReviewScreen: 'ManualReviewScreen', +})); + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown { + if (!instance) { + return undefined; + } + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + return instance.props[key]; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ +} + +function mountRoute(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(CodeReviewerManualReviewRoute)); + }); + if (!ref.current) { + throw new Error('route did not render'); + } + return ref.current; +} + +beforeEach(() => { + useLocalSearchParamsMock.mockReset(); +}); + +describe('CodeReviewerManualReviewRoute invalid scope', () => { + it('renders InvalidRouteState with the profile backTo when scope is undefined', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: undefined }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)'); + expect(findByType(renderer.root, 'ManualReviewScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('renders InvalidRouteState with the profile backTo when scope is an array', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: ['personal', 'org'] }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)'); + expect(findByType(renderer.root, 'ManualReviewScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); + +describe('CodeReviewerManualReviewRoute valid scope', () => { + it('renders the manual review screen with the parsed scope', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: 'personal' }); + const renderer = mountRoute(); + + const screen = findByType(renderer.root, 'ManualReviewScreen'); + expect(screen).toHaveLength(1); + expect(propOf(screen[0], 'scope')).toBe('personal'); + expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.tsx index 9dca2d7031..17786d4d30 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/manual-review.tsx @@ -1,8 +1,16 @@ -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { ManualReviewScreen } from '@/components/code-reviewer/manual-review-screen'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { parseParam } from '@/lib/route-params'; export default function CodeReviewerManualReviewRoute() { - const { scope } = useLocalSearchParams<{ scope: string }>(); + const { scope: rawScope } = useLocalSearchParams<{ scope: string }>(); + const scope = parseParam(rawScope); + + if (!scope) { + return ; + } + return ; } diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].mounted.test.tsx new file mode 100644 index 0000000000..f13c106389 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].mounted.test.tsx @@ -0,0 +1,132 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import CodeReviewerReviewDetailRoute from './[id]'; + +const useLocalSearchParamsMock = vi.hoisted(() => vi.fn()); + +vi.mock('expo-router', () => ({ + useLocalSearchParams: useLocalSearchParamsMock, +})); + +vi.mock('@/components/invalid-route-state', () => ({ + InvalidRouteState: 'InvalidRouteState', +})); + +vi.mock('@/components/code-reviewer/review-detail-screen', () => ({ + ReviewDetailScreen: 'ReviewDetailScreen', +})); + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown { + if (!instance) { + return undefined; + } + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + return instance.props[key]; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ +} + +function mountRoute(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(CodeReviewerReviewDetailRoute)); + }); + if (!ref.current) { + throw new Error('route did not render'); + } + return ref.current; +} + +beforeEach(() => { + useLocalSearchParamsMock.mockReset(); +}); + +describe('CodeReviewerReviewDetailRoute invalid scope', () => { + it('renders InvalidRouteState with the profile backTo when scope is undefined', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: undefined, id: 'rev-1' }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)'); + expect(findByType(renderer.root, 'ReviewDetailScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('renders InvalidRouteState with the profile backTo when scope is an array', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: ['personal', 'org'], id: 'rev-1' }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)'); + expect(findByType(renderer.root, 'ReviewDetailScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); + +describe('CodeReviewerReviewDetailRoute invalid id', () => { + it('renders InvalidRouteState with the scope reviews backTo when id is undefined', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: 'personal', id: undefined }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews' + ); + expect(findByType(renderer.root, 'ReviewDetailScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('renders InvalidRouteState with the scope reviews backTo when id is an array', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: 'personal', id: ['rev-1', 'rev-2'] }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe( + '/(app)/(tabs)/(3_profile)/code-reviewer/personal/reviews' + ); + expect(findByType(renderer.root, 'ReviewDetailScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); + +describe('CodeReviewerReviewDetailRoute valid params', () => { + it('renders the review detail with the parsed scope and id', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: 'personal', id: 'rev-1' }); + const renderer = mountRoute(); + + const screen = findByType(renderer.root, 'ReviewDetailScreen'); + expect(screen).toHaveLength(1); + expect(propOf(screen[0], 'scope')).toBe('personal'); + expect(propOf(screen[0], 'reviewId')).toBe('rev-1'); + expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].tsx index 7e0c9e0714..104d78a912 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/[id].tsx @@ -1,8 +1,22 @@ -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { ReviewDetailScreen } from '@/components/code-reviewer/review-detail-screen'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { parseParam } from '@/lib/route-params'; export default function CodeReviewerReviewDetailRoute() { - const { scope, id } = useLocalSearchParams<{ scope: string; id: string }>(); - return ; + const { scope: rawScope, id: rawId } = useLocalSearchParams<{ scope: string; id: string }>(); + const scope = parseParam(rawScope); + const reviewId = parseParam(rawId); + + if (!scope || !reviewId) { + const backTo = ( + scope + ? `/(app)/(tabs)/(3_profile)/code-reviewer/${scope}/reviews` + : '/(app)/(tabs)/(3_profile)' + ) as Href; + return ; + } + + return ; } diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.mounted.test.tsx new file mode 100644 index 0000000000..71267742e3 --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.mounted.test.tsx @@ -0,0 +1,97 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import CodeReviewerReviewListRoute from './index'; + +const useLocalSearchParamsMock = vi.hoisted(() => vi.fn()); + +vi.mock('expo-router', () => ({ + useLocalSearchParams: useLocalSearchParamsMock, +})); + +vi.mock('@/components/invalid-route-state', () => ({ + InvalidRouteState: 'InvalidRouteState', +})); + +vi.mock('@/components/code-reviewer/review-list-screen', () => ({ + ReviewListScreen: 'ReviewListScreen', +})); + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown { + if (!instance) { + return undefined; + } + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + return instance.props[key]; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ +} + +function mountRoute(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(CodeReviewerReviewListRoute)); + }); + if (!ref.current) { + throw new Error('route did not render'); + } + return ref.current; +} + +beforeEach(() => { + useLocalSearchParamsMock.mockReset(); +}); + +describe('CodeReviewerReviewListRoute invalid scope', () => { + it('renders InvalidRouteState with the profile backTo when scope is undefined', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: undefined }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)'); + expect(findByType(renderer.root, 'ReviewListScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('renders InvalidRouteState with the profile backTo when scope is an array', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: ['personal', 'org'] }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)'); + expect(findByType(renderer.root, 'ReviewListScreen')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); + +describe('CodeReviewerReviewListRoute valid scope', () => { + it('renders the review list with the parsed scope', () => { + useLocalSearchParamsMock.mockReturnValue({ scope: 'personal' }); + const renderer = mountRoute(); + + const screen = findByType(renderer.root, 'ReviewListScreen'); + expect(screen).toHaveLength(1); + expect(propOf(screen[0], 'scope')).toBe('personal'); + expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.tsx index a8cfb61054..75e3ac3eb2 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/code-reviewer/[scope]/reviews/index.tsx @@ -1,8 +1,16 @@ -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; import { ReviewListScreen } from '@/components/code-reviewer/review-list-screen'; +import { InvalidRouteState } from '@/components/invalid-route-state'; +import { parseParam } from '@/lib/route-params'; export default function CodeReviewerReviewListRoute() { - const { scope } = useLocalSearchParams<{ scope: string }>(); + const { scope: rawScope } = useLocalSearchParams<{ scope: string }>(); + const scope = parseParam(rawScope); + + if (!scope) { + return ; + } + return ; } diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.mounted.test.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.mounted.test.tsx new file mode 100644 index 0000000000..74b155178f --- /dev/null +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.mounted.test.tsx @@ -0,0 +1,97 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import MemberLimitRoute from './member-limit'; + +const useLocalSearchParamsMock = vi.hoisted(() => vi.fn()); + +vi.mock('expo-router', () => ({ + useLocalSearchParams: useLocalSearchParamsMock, +})); + +vi.mock('@/components/invalid-route-state', () => ({ + InvalidRouteState: 'InvalidRouteState', +})); + +vi.mock('@/components/organization/member-limit-sheet', () => ({ + MemberLimitSheet: 'MemberLimitSheet', +})); + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown { + if (!instance) { + return undefined; + } + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + return instance.props[key]; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ +} + +function mountRoute(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(MemberLimitRoute)); + }); + if (!ref.current) { + throw new Error('route did not render'); + } + return ref.current; +} + +beforeEach(() => { + useLocalSearchParamsMock.mockReset(); +}); + +describe('MemberLimitRoute invalid memberId', () => { + it('renders InvalidRouteState with the organization backTo when memberId is undefined', () => { + useLocalSearchParamsMock.mockReturnValue({ memberId: undefined }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)/organization'); + expect(findByType(renderer.root, 'MemberLimitSheet')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); + + it('renders InvalidRouteState with the organization backTo when memberId is an array', () => { + useLocalSearchParamsMock.mockReturnValue({ memberId: ['m-1', 'm-2'] }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)/(tabs)/(3_profile)/organization'); + expect(findByType(renderer.root, 'MemberLimitSheet')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); + +describe('MemberLimitRoute valid memberId', () => { + it('renders the member limit sheet with the parsed memberId', () => { + useLocalSearchParamsMock.mockReturnValue({ memberId: 'm-1' }); + const renderer = mountRoute(); + + const screen = findByType(renderer.root, 'MemberLimitSheet'); + expect(screen).toHaveLength(1); + expect(propOf(screen[0], 'memberId')).toBe('m-1'); + expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx index c2f96f816a..71cdff5c60 100644 --- a/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx +++ b/apps/mobile/src/app/(app)/(tabs)/(3_profile)/organization/member-limit.tsx @@ -1,8 +1,16 @@ -import { useLocalSearchParams } from 'expo-router'; +import { type Href, useLocalSearchParams } from 'expo-router'; +import { InvalidRouteState } from '@/components/invalid-route-state'; import { MemberLimitSheet } from '@/components/organization/member-limit-sheet'; +import { parseParam } from '@/lib/route-params'; export default function MemberLimitRoute() { - const { memberId } = useLocalSearchParams<{ memberId: string }>(); + const { memberId: rawMemberId } = useLocalSearchParams<{ memberId: string }>(); + const memberId = parseParam(rawMemberId); + + if (!memberId) { + return ; + } + return ; } diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx new file mode 100644 index 0000000000..ccd15d31f5 --- /dev/null +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].mounted.test.tsx @@ -0,0 +1,201 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (node env, no jsdom). */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import SessionDetailScreen from './[session-id]'; + +const useLocalSearchParamsMock = vi.hoisted(() => vi.fn()); +const useRouterMock = vi.hoisted(() => vi.fn()); +const useQueryMock = vi.hoisted(() => vi.fn()); +const queryOptionsMock = vi.hoisted(() => vi.fn()); + +const queryState = vi.hoisted(() => ({ + isPending: false, + isError: false, + isFetching: false, + error: null as { data?: { code?: string } } | null, + data: null as { organization_id?: string } | null, + refetch: vi.fn(), +})); + +vi.mock('react-native', () => ({ + View: 'View', +})); + +vi.mock('expo-router', () => ({ + useLocalSearchParams: useLocalSearchParamsMock, + useRouter: useRouterMock, +})); + +vi.mock('@tanstack/react-query', () => ({ + useQuery: useQueryMock, +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + cliSessionsV2: { + get: { queryOptions: queryOptionsMock }, + }, + }), +})); + +vi.mock('@/components/invalid-route-state', () => ({ + InvalidRouteState: 'InvalidRouteState', +})); + +vi.mock('@/components/agents/session-detail-content', () => ({ + SessionDetailContent: 'SessionDetailContent', + SessionSkeletonMessages: 'SessionSkeletonMessages', +})); + +vi.mock('@/components/agents/session-connection-indicator', () => ({ + SessionConnectionIndicator: 'SessionConnectionIndicator', +})); + +vi.mock('@/components/agents/session-context-metrics', () => ({ + SessionContextMetrics: 'SessionContextMetrics', +})); + +vi.mock('@/components/agents/session-provider', () => ({ + AgentSessionProvider: 'AgentSessionProvider', +})); + +vi.mock('@/components/agents/session-terminal-error', () => ({ + buildTerminalErrorCopyText: () => '', +})); + +vi.mock('@/components/agents/use-message-copy', () => ({ + performCopy: vi.fn(), +})); + +vi.mock('@/components/query-error', () => ({ + QueryError: 'QueryError', +})); + +vi.mock('@/components/screen-header', () => ({ + ScreenHeader: 'ScreenHeader', +})); + +vi.mock('@/components/ui/button', () => ({ + Button: 'Button', +})); + +vi.mock('@/components/ui/text', () => ({ + Text: 'Text', +})); + +vi.mock('@/lib/spawned-not-found-retry', () => ({ + shouldRetryNotFoundOnSpawnedRoute: () => false, +})); + +function findByType( + root: TestRenderer.ReactTestInstance, + type: string +): TestRenderer.ReactTestInstance[] { + return root.findAll(node => typeof node.type === 'string' && (node.type as string) === type); +} + +function propOf(instance: TestRenderer.ReactTestInstance | undefined, key: string): unknown { + if (!instance) { + return undefined; + } + /* eslint-disable typescript-eslint/no-unsafe-member-access -- react-test-renderer props are an index signature */ + return instance.props[key]; + /* eslint-enable typescript-eslint/no-unsafe-member-access */ +} + +function mountRoute(): TestRenderer.ReactTestRenderer { + const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined }; + act(() => { + ref.current = TestRenderer.create(createElement(SessionDetailScreen)); + }); + if (!ref.current) { + throw new Error('route did not render'); + } + return ref.current; +} + +function queryEnabled(): boolean | undefined { + const options = useQueryMock.mock.calls[0]?.[0] as { enabled?: boolean } | undefined; + return options?.enabled; +} + +function queryInput(): { session_id?: string } | undefined { + return queryOptionsMock.mock.calls[0]?.[0] as { session_id?: string } | undefined; +} + +beforeEach(() => { + useLocalSearchParamsMock.mockReset(); + useRouterMock.mockReset(); + useRouterMock.mockReturnValue({ replace: vi.fn() }); + useQueryMock.mockReset(); + useQueryMock.mockImplementation((options: { enabled?: boolean } | undefined) => { + // A disabled TanStack query stays pending forever (`isPending: true` when + // `enabled` is false). Model that so the invalid-param tests exercise the + // real branch order instead of a skeleton that never resolves. + const disabled = options?.enabled === false; + return { + ...queryState, + isPending: disabled ? true : queryState.isPending, + }; + }); + queryOptionsMock.mockReset(); + queryOptionsMock.mockReturnValue({}); + queryState.isPending = false; + queryState.isError = false; + queryState.isFetching = false; + queryState.error = null; + queryState.data = null; + queryState.refetch.mockClear(); +}); + +describe('SessionDetailScreen invalid session-id', () => { + it('renders InvalidRouteState with the app backTo when session-id is undefined', () => { + useLocalSearchParamsMock.mockReturnValue({ 'session-id': undefined }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)'); + expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0); + expect(queryEnabled()).toBe(false); + + act(() => { + renderer.unmount(); + }); + }); + + it('renders InvalidRouteState with the app backTo when session-id is an array', () => { + useLocalSearchParamsMock.mockReturnValue({ 'session-id': ['sess-1', 'sess-2'] }); + const renderer = mountRoute(); + + const invalid = findByType(renderer.root, 'InvalidRouteState'); + expect(invalid).toHaveLength(1); + expect(propOf(invalid[0], 'backTo')).toBe('/(app)'); + expect(findByType(renderer.root, 'SessionDetailContent')).toHaveLength(0); + expect(queryEnabled()).toBe(false); + + act(() => { + renderer.unmount(); + }); + }); +}); + +describe('SessionDetailScreen valid session-id', () => { + it('renders the session content with the parsed session-id and enables the query', () => { + useLocalSearchParamsMock.mockReturnValue({ 'session-id': 'sess-1' }); + const renderer = mountRoute(); + + const content = findByType(renderer.root, 'SessionDetailContent'); + expect(content).toHaveLength(1); + expect(propOf(content[0], 'sessionId')).toBe('sess-1'); + expect(findByType(renderer.root, 'InvalidRouteState')).toHaveLength(0); + expect(queryEnabled()).toBe(true); + expect(queryInput()).toEqual({ session_id: 'sess-1' }); + + act(() => { + renderer.unmount(); + }); + }); +}); diff --git a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx index 3785289d63..1ef52d92c4 100644 --- a/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx +++ b/apps/mobile/src/app/(app)/agent-chat/[session-id].tsx @@ -12,16 +12,18 @@ import { SessionContextMetrics } from '@/components/agents/session-context-metri import { AgentSessionProvider } from '@/components/agents/session-provider'; import { buildTerminalErrorCopyText } from '@/components/agents/session-terminal-error'; import { performCopy } from '@/components/agents/use-message-copy'; +import { InvalidRouteState } from '@/components/invalid-route-state'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import { parseParam } from '@/lib/route-params'; import { shouldRetryNotFoundOnSpawnedRoute } from '@/lib/spawned-not-found-retry'; import { useTRPC } from '@/lib/trpc'; export default function SessionDetailScreen() { const { - 'session-id': sessionId, + 'session-id': rawSessionId, organizationId: routeOrganizationId, via, spawned, @@ -48,6 +50,10 @@ export default function SessionDetailScreen() { /** Agent mode the spawn was started with; seeds the composer before the CLI reports one. */ mode?: string; }>(); + // `session-id` is required: a malformed deep link can hand us `undefined` + // or a `string[]`, both of which parseParam rejects. Optional params keep + // the existing first-element unwrapping below. + const sessionId = parseParam(rawSessionId); // Param can be string | string[] depending on how the route was opened. const shareId = Array.isArray(shareIdParam) ? shareIdParam[0] : shareIdParam; const autoSendParam = Array.isArray(autoSendRaw) ? autoSendRaw[0] : autoSendRaw; @@ -56,7 +62,7 @@ export default function SessionDetailScreen() { const router = useRouter(); const sessionQuery = useQuery({ ...trpc.cliSessionsV2.get.queryOptions( - { session_id: sessionId }, + { session_id: sessionId ?? '' }, { retry: (failureCount, error) => shouldRetryNotFoundOnSpawnedRoute({ @@ -85,9 +91,13 @@ export default function SessionDetailScreen() { retryDelay: 1000, } ), - enabled: routeOrganizationId === undefined, + enabled: routeOrganizationId === undefined && sessionId !== null, }); + if (sessionId === null) { + return ; + } + if (routeOrganizationId === undefined && sessionQuery.isPending) { return ( diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 82fbcb7e6a..a423c51b13 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -35,6 +35,7 @@ import { AppRootProviders } from '@/components/app-root-providers'; import { BootstrapErrorScreen } from '@/components/bootstrap-error-screen'; import { announceForA11y, moveA11yFocus } from '@/lib/a11y/announce'; import { useAuth } from '@/lib/auth/auth-context'; +import { resolveBootstrapDecision } from '@/lib/bootstrap-decision'; import { consentModeForSearchParam } from '@/components/consent/consent-mode'; import { checkConsentGate } from '@/lib/consent-gate'; import { subscribeToConsentChanges } from '@/lib/consent'; @@ -474,68 +475,83 @@ function RootLayoutNav() { }, [hasShareIntent, shareIntent]); useEffect(() => { - if (isLoading) { - return; - } + const decision = resolveBootstrapDecision({ + isLoading, + updateRequired, + inForceUpdate, + inAuthGroup, + hasToken: token != null, + userIdLoading, + userIdError, + consentCheckError: consentCheckError != null, + consentChecked, + needsConsent, + onConsentRoute, + onConsentReviewRoute, + }); - if (updateRequired) { - if (!inForceUpdate) { + // Replaces the old inline if-chain (resolveBootstrapTag in + // src/lib/bootstrap-decision.ts). Remove this switch when the decision + // module owns all bootstrap routing. + switch (decision.tag) { + case 'wait-loading': + case 'wait-user-consent': { + return; + } + case 'redirect-force-update': { router.replace('/force-update'); - } else { + return; + } + case 'settle-force-update': { markStartupComplete('force-update'); setStartupFinished(true); + return; } - return; - } - - if (inForceUpdate) { - router.replace('/(app)'); - return; - } - - if (!token) { - if (inAuthGroup) { + case 'exit-force-update': { + router.replace('/(app)'); + return; + } + case 'settle-login': { markStartupComplete('login'); setStartupFinished(true); - } else { + return; + } + case 'redirect-login': { router.replace('/(auth)/login'); + return; } - } else { - if (userIdError) { + case 'settle-user-error': { markStartupComplete('user-error'); setStartupFinished(true); return; } - - if (consentCheckError) { + case 'settle-consent-error': { markStartupComplete('consent-error'); setStartupFinished(true); return; } - - if (userIdLoading || !consentChecked) { + case 'settle-consent': { + markStartupComplete('consent'); + setStartupFinished(true); return; } - - if (needsConsent) { - if (onConsentRoute) { - markStartupComplete('consent'); - setStartupFinished(true); - } else { - router.replace('/(app)/consent' as Href); - } + case 'redirect-consent': { + router.replace('/(app)/consent' as Href); return; } - - if ((onConsentRoute && !onConsentReviewRoute) || inAuthGroup) { + case 'redirect-app': { router.replace('/(app)'); return; } - - markStartupComplete('app'); - setStartupFinished(true); - // Deep-link navigation is owned by the pendingDeepLink effect below. - // Share-gate open is owned by the pendingShareId effect + isShellReadyForShare. + case 'settle-app': { + markStartupComplete('app'); + setStartupFinished(true); + // Deep-link navigation is owned by the pendingDeepLink effect below. + // Share-gate open is owned by the pendingShareId effect + isShellReadyForShare. + break; + } + default: + // Unreachable: BootstrapDecisionTag is a closed union. } }, [ token, @@ -616,30 +632,25 @@ function RootLayoutNav() { }); }, [startupFinished, token, consentChecked, needsConsent, optionalConsent, postHogReady]); - const needsForceUpdate = updateRequired && !inForceUpdate; - const showingForceUpdate = updateRequired && inForceUpdate; - const needsAuth = !token && !inAuthGroup; - const needsAppRedirect = token != null && inAuthGroup; - const hasUserBootstrapError = token != null && userIdError; - const hasConsentBootstrapError = token != null && consentCheckError !== null; - const hasBootstrapError = hasUserBootstrapError || hasConsentBootstrapError; - const consentLoading = - token != null && !consentChecked && !inAuthGroup && !inForceUpdate && !onConsentRoute; - const needsConsentRedirect = consentChecked && needsConsent && !onConsentRoute; - - const needsRedirect = - !isLoading && - (needsForceUpdate || - (!showingForceUpdate && (needsAuth || needsAppRedirect || needsConsentRedirect))); - // Always keep Slot mounted so Expo Router's navigation tree stays // initialised — returning null unmounts it and breaks router.replace. // The native splash screen covers everything during initial load, and // opacity 0 hides the wrong screen during redirects. - const hidden = - !hasUserBootstrapError && - !hasConsentBootstrapError && - (isLoading || needsRedirect || consentLoading); + const { hasUserBootstrapError, hasConsentBootstrapError, hasBootstrapError, hidden } = + resolveBootstrapDecision({ + isLoading, + updateRequired, + inForceUpdate, + inAuthGroup, + hasToken: token != null, + userIdLoading, + userIdError, + consentCheckError: consentCheckError != null, + consentChecked, + needsConsent, + onConsentRoute, + onConsentReviewRoute, + }); // Hidden root-route entry contract (D17): while `hidden`, the wrapper leaves // both accessibility trees. On the hidden → visible transition, diff --git a/apps/mobile/src/components/home/home-screen.mounted.test.tsx b/apps/mobile/src/components/home/home-screen.mounted.test.tsx index 573cadffff..2c713e04d3 100644 --- a/apps/mobile/src/components/home/home-screen.mounted.test.tsx +++ b/apps/mobile/src/components/home/home-screen.mounted.test.tsx @@ -9,6 +9,8 @@ const hasSessions = vi.hoisted(() => ({ value: true })); const activeIsError = vi.hoisted(() => ({ value: false })); const storedIsError = vi.hoisted(() => ({ value: false })); const storedIsSuccess = vi.hoisted(() => ({ value: true })); +const sessionsLoading = vi.hoisted(() => ({ value: false })); +const orgLoaded = vi.hoisted(() => ({ value: true })); vi.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }), @@ -37,6 +39,9 @@ vi.mock('@/components/home/greeting', () => ({ vi.mock('@/components/home/new-task-button', () => ({ NewTaskButton: 'NewTaskButton', })); +vi.mock('@/components/home/product-choices', () => ({ + ProductChoices: 'ProductChoices', +})); vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError', })); @@ -52,7 +57,7 @@ vi.mock('@/components/ui/skeleton', () => ({ vi.mock('@/lib/hooks/use-agent-sessions', () => ({ useAgentSessions: () => ({ activeSessions: [], - isLoading: false, + isLoading: sessionsLoading.value, storedSessions: [{}], storedIsError: storedIsError.value, storedIsSuccess: storedIsSuccess.value, @@ -61,7 +66,7 @@ vi.mock('@/lib/hooks/use-agent-sessions', () => ({ }), })); vi.mock('@/lib/organization-context', () => ({ - useOrganization: () => ({ organizationId: 'org-1', isLoaded: true }), + useOrganization: () => ({ organizationId: 'org-1', isLoaded: orgLoaded.value }), })); function nodeCount(root: TestRenderer.ReactTestInstance, type: string): number { @@ -94,9 +99,12 @@ async function mountHome(): Promise { describe('HomeScreen composition', () => { it('renders the sessions section and new-task button when sessions are present', async () => { hasSessions.value = true; + sessionsLoading.value = false; + orgLoaded.value = true; const renderer = await mountHome(); expect(nodeCount(renderer.root, 'AgentSessionsSection')).toBe(1); expect(nodeCount(renderer.root, 'NewTaskButton')).toBe(1); + expect(nodeCount(renderer.root, 'ProductChoices')).toBe(1); expect(nodeCount(renderer.root, 'Skeleton')).toBe(0); await act(async () => { @@ -110,11 +118,14 @@ describe('HomeScreen composition', () => { storedIsError.value = false; storedIsSuccess.value = true; activeIsError.value = false; + sessionsLoading.value = false; + orgLoaded.value = true; const renderer = await mountHome(); expect(nodeCount(renderer.root, 'AgentsPromoCard')).toBe(1); expect(nodeCount(renderer.root, 'QueryError')).toBe(0); expect(nodeCount(renderer.root, 'AgentSessionsSection')).toBe(0); expect(nodeCount(renderer.root, 'NewTaskButton')).toBe(0); + expect(nodeCount(renderer.root, 'ProductChoices')).toBe(1); await act(async () => { await Promise.resolve(); @@ -127,6 +138,8 @@ describe('HomeScreen composition', () => { storedIsError.value = false; storedIsSuccess.value = true; activeIsError.value = true; + sessionsLoading.value = false; + orgLoaded.value = true; const renderer = await mountHome(); const queryError = findNode(renderer.root, 'QueryError'); expect(queryError).toBeDefined(); @@ -135,6 +148,21 @@ describe('HomeScreen composition', () => { expect(nodeCount(renderer.root, 'AgentsPromoCard')).toBe(0); expect(nodeCount(renderer.root, 'AgentSessionsSection')).toBe(0); expect(nodeCount(renderer.root, 'NewTaskButton')).toBe(0); + expect(nodeCount(renderer.root, 'ProductChoices')).toBe(1); + + await act(async () => { + await Promise.resolve(); + renderer.unmount(); + }); + }); + + it('does not render product choices while loading', async () => { + sessionsLoading.value = true; + orgLoaded.value = true; + const renderer = await mountHome(); + expect(nodeCount(renderer.root, 'ProductChoices')).toBe(0); + expect(nodeCount(renderer.root, 'Skeleton')).toBeGreaterThan(0); + sessionsLoading.value = false; await act(async () => { await Promise.resolve(); diff --git a/apps/mobile/src/components/home/home-screen.test.ts b/apps/mobile/src/components/home/home-screen.test.ts index a46faf5250..f6fff4a7fe 100644 --- a/apps/mobile/src/components/home/home-screen.test.ts +++ b/apps/mobile/src/components/home/home-screen.test.ts @@ -28,6 +28,9 @@ vi.mock('@/components/home/greeting', () => ({ vi.mock('@/components/home/new-task-button', () => ({ NewTaskButton: () => null, })); +vi.mock('@/components/home/product-choices', () => ({ + ProductChoices: () => null, +})); vi.mock('@/components/query-error', () => ({ QueryError: () => null, })); diff --git a/apps/mobile/src/components/home/home-screen.tsx b/apps/mobile/src/components/home/home-screen.tsx index 80d6f7da49..1bc6a0719b 100644 --- a/apps/mobile/src/components/home/home-screen.tsx +++ b/apps/mobile/src/components/home/home-screen.tsx @@ -12,6 +12,7 @@ import { import { AgentsPromoCard } from '@/components/home/agents-promo-card'; import { buildTimedGreeting } from '@/components/home/greeting'; import { NewTaskButton } from '@/components/home/new-task-button'; +import { ProductChoices } from '@/components/home/product-choices'; import { QueryError } from '@/components/query-error'; import { ScreenHeader } from '@/components/screen-header'; import { Skeleton } from '@/components/ui/skeleton'; @@ -91,6 +92,8 @@ export function HomeScreen() { ) : null} + + )} diff --git a/apps/mobile/src/components/home/product-choices.mounted.test.tsx b/apps/mobile/src/components/home/product-choices.mounted.test.tsx new file mode 100644 index 0000000000..8493fc7ae0 --- /dev/null +++ b/apps/mobile/src/components/home/product-choices.mounted.test.tsx @@ -0,0 +1,130 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ +import { createElement } from 'react'; +import TestRenderer, { act } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; + +import { ProductChoices } from '@/components/home/product-choices'; +import { getCodeReviewerProfilePath, getPrReviewEntryPath } from '@/lib/profile-agent-navigation'; +import { getSecurityAgentPath } from '@/lib/security-agent'; + +const push = vi.hoisted(() => vi.fn()); +const prReviewEnabled = vi.hoisted(() => ({ value: true })); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push }), +})); +vi.mock('react-native', () => ({ + View: 'View', +})); +vi.mock('@/components/ui/configure-row', () => ({ + ConfigureRow: 'ConfigureRow', +})); +vi.mock('@/components/home/section-header', () => ({ + SectionHeader: 'SectionHeader', +})); +vi.mock('@/components/ui/icons', () => ({ + GitMerge: 'GitMerge', + GitPullRequest: 'GitPullRequest', + ShieldCheck: 'ShieldCheck', +})); +vi.mock('@/lib/analytics/posthog', () => ({ + FEATURE_FLAG_PR_REVIEW: 'mobile-pr-review', + useFeatureFlag: () => prReviewEnabled.value, +})); + +function rows(root: TestRenderer.ReactTestInstance): TestRenderer.ReactTestInstance[] { + return root.findAll( + node => typeof node.type === 'string' && (node.type as string) === 'ConfigureRow' + ); +} + +function rowByTitle( + root: TestRenderer.ReactTestInstance, + title: string +): TestRenderer.ReactTestInstance | undefined { + return rows(root).find(row => row.props.title === title); +} + +function pressRow(root: TestRenderer.ReactTestInstance, title: string): void { + const onPress = rowByTitle(root, title)?.props.onPress as (() => void) | undefined; + onPress?.(); +} + +async function mountProductChoices( + organizationId: string | null +): Promise { + const rendererRef: { current: TestRenderer.ReactTestRenderer | undefined } = { + current: undefined, + }; + await act(async () => { + await Promise.resolve(); + rendererRef.current = TestRenderer.create(createElement(ProductChoices, { organizationId })); + }); + const renderer = rendererRef.current; + if (!renderer) { + throw new Error('renderer was not created'); + } + return renderer; +} + +async function unmount(renderer: TestRenderer.ReactTestRenderer): Promise { + await act(async () => { + await Promise.resolve(); + renderer.unmount(); + }); +} + +describe('ProductChoices', () => { + it('renders all three product rows when PR Review is enabled', async () => { + prReviewEnabled.value = true; + const renderer = await mountProductChoices('org-1'); + expect(rows(renderer.root).map(row => row.props.title)).toEqual([ + 'Code Reviewer', + 'Security Agent', + 'PR Review', + ]); + await unmount(renderer); + }); + + it('navigates each row to its product path', async () => { + prReviewEnabled.value = true; + push.mockClear(); + const renderer = await mountProductChoices('org-1'); + + pressRow(renderer.root, 'Code Reviewer'); + expect(push).toHaveBeenCalledWith(getCodeReviewerProfilePath('org-1')); + + pressRow(renderer.root, 'Security Agent'); + expect(push).toHaveBeenCalledWith(getSecurityAgentPath('org-1')); + + pressRow(renderer.root, 'PR Review'); + expect(push).toHaveBeenCalledWith(getPrReviewEntryPath()); + + await unmount(renderer); + }); + + it('falls back to the personal scope when no organization is selected', async () => { + prReviewEnabled.value = true; + push.mockClear(); + const renderer = await mountProductChoices(null); + + pressRow(renderer.root, 'Code Reviewer'); + expect(push).toHaveBeenCalledWith(getCodeReviewerProfilePath('personal')); + + pressRow(renderer.root, 'Security Agent'); + expect(push).toHaveBeenCalledWith(getSecurityAgentPath('personal')); + + await unmount(renderer); + }); + + it('hides PR Review when the flag is false', async () => { + prReviewEnabled.value = false; + const renderer = await mountProductChoices('org-1'); + expect(rows(renderer.root).map(row => row.props.title)).toEqual([ + 'Code Reviewer', + 'Security Agent', + ]); + prReviewEnabled.value = true; + await unmount(renderer); + }); +}); diff --git a/apps/mobile/src/components/home/product-choices.tsx b/apps/mobile/src/components/home/product-choices.tsx new file mode 100644 index 0000000000..661cc8cc66 --- /dev/null +++ b/apps/mobile/src/components/home/product-choices.tsx @@ -0,0 +1,59 @@ +import { PERSONAL_SECURITY_SCOPE } from '@kilocode/app-shared/security-agent'; +import { useRouter } from 'expo-router'; +import { View } from 'react-native'; + +import { SectionHeader } from '@/components/home/section-header'; +import { ConfigureRow } from '@/components/ui/configure-row'; +import { GitMerge, GitPullRequest, ShieldCheck } from '@/components/ui/icons'; +import { FEATURE_FLAG_PR_REVIEW, useFeatureFlag } from '@/lib/analytics/posthog'; +import { getCodeReviewerProfilePath, getPrReviewEntryPath } from '@/lib/profile-agent-navigation'; +import { getSecurityAgentPath } from '@/lib/security-agent'; + +type ProductChoicesProps = { + organizationId: string | null; +}; + +export function ProductChoices({ organizationId }: Readonly) { + const router = useRouter(); + const prReviewEnabled = useFeatureFlag(FEATURE_FLAG_PR_REVIEW, true); + const scope = organizationId ?? PERSONAL_SECURITY_SCOPE; + + return ( + + + + { + router.push(getCodeReviewerProfilePath(scope)); + }} + /> + { + router.push(getSecurityAgentPath(scope)); + }} + /> + {prReviewEnabled ? ( + { + router.push(getPrReviewEntryPath()); + }} + /> + ) : null} + + + ); +} diff --git a/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx b/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx new file mode 100644 index 0000000000..b22f16ff3e --- /dev/null +++ b/apps/mobile/src/components/profile-screen.queries.mounted.test.tsx @@ -0,0 +1,335 @@ +/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */ +import { createElement } from 'react'; +import { act, type ReactTestInstance } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProfileScreen } from '@/components/profile-screen'; +import { createTestQueryClient, renderWithProviders, waitFor } from '@/test/render-with-providers'; + +// ── Hoisted mocks ────────────────────────────────────────────────────────── + +const providersQueryFn = vi.hoisted(() => vi.fn()); +const organizationsQueryFn = vi.hoisted(() => vi.fn()); +const signOutFn = vi.hoisted(() => vi.fn()); +const routerPush = vi.hoisted(() => vi.fn()); +const keys = vi.hoisted(() => ({ + providers: ['user', 'getAuthProviders'], + organizations: ['organizations', 'list'], +})); +const authState = vi.hoisted(() => ({ token: 'token-1' as string | null })); +const interactionState = vi.hoisted(() => ({ + storedCallback: undefined as (() => void) | undefined, + cancel: vi.fn(), +})); +// eslint-disable-next-line promise/prefer-await-to-callbacks -- the mock must capture the callback so the test can flush it +const captureInteraction = vi.hoisted(() => (cb: () => void) => { + interactionState.storedCallback = cb; + return { cancel: interactionState.cancel }; +}); +const getProfileAgentScopeMock = vi.hoisted(() => vi.fn()); + +vi.mock('react-native', () => ({ + Alert: { alert: vi.fn() }, + View: 'View', + InteractionManager: { + runAfterInteractions: vi.fn(captureInteraction), + }, +})); + +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + FadeIn: { duration: vi.fn() }, + FadeOut: { duration: vi.fn() }, + LinearTransition: {}, +})); + +vi.mock('expo-router', () => ({ + useRouter: () => ({ push: routerPush }), +})); + +vi.mock('expo-application', () => ({ + nativeApplicationVersion: '1.0.0', + nativeBuildVersion: '1', +})); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + user: { + getAuthProviders: { + queryOptions: () => ({ queryKey: keys.providers, queryFn: providersQueryFn }), + }, + }, + organizations: { + list: { + queryOptions: () => ({ queryKey: keys.organizations, queryFn: organizationsQueryFn }), + }, + }, + }), +})); + +vi.mock('@/lib/auth/auth-context', () => ({ + useAuth: () => ({ signOut: signOutFn, token: authState.token }), +})); + +vi.mock('@/lib/organization-context', () => ({ + useOrganization: () => ({ organizationId: 'org-1', isLoaded: true }), +})); + +vi.mock('@/lib/analytics/posthog', () => ({ + FEATURE_FLAG_PR_REVIEW: 'mobile-pr-review', + useFeatureFlag: () => true, +})); + +vi.mock('@/components/use-delete-account', () => ({ + useDeleteAccount: () => ({ + phase: 'idle', + isPending: false, + devCode: null, + beginDelete: vi.fn(), + submitCode: vi.fn(), + setCode: vi.fn(), + }), +})); + +vi.mock('@/lib/hooks/use-current-user-id', () => ({ + useCurrentUserId: () => ({ userId: 'user-1' }), +})); + +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ mutedForeground: '#000000' }), +})); + +vi.mock('@/lib/profile-agent-navigation', () => ({ + getCodeReviewerProfilePath: () => '/code-reviewer', + getProfileAgentScope: getProfileAgentScopeMock, + getPrReviewEntryPath: () => '/pr-review', +})); + +vi.mock('@/lib/security-agent', () => ({ + getSecurityAgentPath: () => '/security-agent', +})); + +vi.mock('@/lib/feedback', () => ({ + showFeedbackPrompt: vi.fn(), +})); + +vi.mock('@/components/ui/icons', () => ({ + Building2: 'Building2', + GitMerge: 'GitMerge', + GitPullRequest: 'GitPullRequest', + KeyRound: 'KeyRound', + Lock: 'Lock', + LogOut: 'LogOut', + MessageSquare: 'MessageSquare', + ShieldCheck: 'ShieldCheck', + SlidersHorizontal: 'SlidersHorizontal', + Smartphone: 'Smartphone', + Trash2: 'Trash2', +})); + +vi.mock('@/components/profile-action-tile', () => ({ ActionTile: 'ActionTile' })); +vi.mock('@/components/profile-credits-card', () => ({ CreditsCard: 'CreditsCard' })); +vi.mock('@/components/query-error', () => ({ QueryError: 'QueryError' })); +vi.mock('@/components/screen-header', () => ({ ScreenHeader: () => null })); +vi.mock('@/components/tab-screen', () => ({ TabScreenScrollView: 'ScrollView' })); +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/configure-row', () => ({ ConfigureRow: 'ConfigureRow' })); +vi.mock('@/components/ui/form-field', () => ({ FormField: 'FormField' })); +vi.mock('@/components/ui/skeleton', () => ({ Skeleton: 'Skeleton' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function nodeCount(root: ReactTestInstance, type: string): number { + return root.findAll(node => typeof node.type === 'string' && node.type === type).length; +} + +function findNode(root: ReactTestInstance, type: string): ReactTestInstance | undefined { + return root.findAll(node => typeof node.type === 'string' && node.type === type)[0]; +} + +function nodeCountWithChildren(root: ReactTestInstance, type: string, children: string): number { + return root.findAll( + node => typeof node.type === 'string' && node.type === type && node.props.children === children + ).length; +} + +function findConfigureRows(root: ReactTestInstance, title: string): ReactTestInstance[] { + return root.findAll( + node => + typeof node.type === 'string' && + (node.type as string) === 'ConfigureRow' && + node.props.title === title + ); +} + +async function mountProfile() { + const result = await renderWithProviders(createElement(ProfileScreen)); + return result; +} + +function flushInteractions() { + const run = interactionState.storedCallback; + if (!run) { + throw new Error('runAfterInteractions callback was not captured'); + } + act(() => { + run(); + }); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('ProfileScreen deferred queries', () => { + beforeEach(() => { + providersQueryFn.mockReset(); + organizationsQueryFn.mockReset(); + signOutFn.mockReset(); + routerPush.mockReset(); + authState.token = 'token-1'; + interactionState.storedCallback = undefined; + interactionState.cancel.mockReset(); + getProfileAgentScopeMock.mockReset(); + getProfileAgentScopeMock.mockReturnValue('personal'); + }); + + it('defers both queries until interactions settle, showing the skeleton first', async () => { + providersQueryFn.mockResolvedValue({ providers: [] }); + organizationsQueryFn.mockResolvedValue([]); + + const { renderer, unmount } = await mountProfile(); + + // Before the flush: neither query fired, the skeleton shows, and the agent + // rows are held disabled (refreshing argument is true). + expect(providersQueryFn).not.toHaveBeenCalled(); + expect(organizationsQueryFn).not.toHaveBeenCalled(); + expect(nodeCount(renderer.root, 'Skeleton')).toBe(1); + expect(getProfileAgentScopeMock.mock.calls.at(-1)?.[2]).toBe(true); + + flushInteractions(); + + await waitFor( + () => providersQueryFn.mock.calls.length > 0 && organizationsQueryFn.mock.calls.length > 0 + ); + expect(providersQueryFn).toHaveBeenCalledTimes(1); + expect(organizationsQueryFn).toHaveBeenCalledTimes(1); + + // Once the deferred fetch settles, the refreshing argument is false. + await waitFor(() => getProfileAgentScopeMock.mock.calls.at(-1)?.[2] === false); + + unmount(); + }); + + it('renders cached providers without the skeleton before the flush', async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(keys.providers, { + providers: [{ provider: 'github', email: 'dev@kilo.ai' }], + }); + queryClient.setQueryData(keys.organizations, [ + { organizationId: 'org-1', organizationName: 'Kilo', role: 'admin' }, + ]); + + const { renderer, unmount } = await renderWithProviders(createElement(ProfileScreen), { + queryClient, + }); + + expect(providersQueryFn).not.toHaveBeenCalled(); + expect(nodeCount(renderer.root, 'Skeleton')).toBe(0); + expect(findConfigureRows(renderer.root, 'GitHub').length).toBe(1); + + unmount(); + }); + + it('renders QueryError with retry after the deferred providers query fails', async () => { + providersQueryFn.mockRejectedValue(new Error('boom')); + organizationsQueryFn.mockResolvedValue([]); + + const { renderer, unmount } = await mountProfile(); + + flushInteractions(); + await waitFor(() => nodeCount(renderer.root, 'QueryError') > 0); + + const queryError = findNode(renderer.root, 'QueryError'); + expect(queryError?.props.title).toBe('Could not load accounts'); + expect(typeof queryError?.props.onRetry).toBe('function'); + + unmount(); + }); + + it('does not fire the queries when unauthenticated, even after the flush', async () => { + authState.token = null; + + const { unmount } = await mountProfile(); + + flushInteractions(); + await act(async () => { + await Promise.resolve(); + }); + + expect(providersQueryFn).not.toHaveBeenCalled(); + expect(organizationsQueryFn).not.toHaveBeenCalled(); + + unmount(); + }); + + it('cancels the interaction handle on unmount', async () => { + const { unmount } = await mountProfile(); + + expect(interactionState.cancel).not.toHaveBeenCalled(); + unmount(); + expect(interactionState.cancel).toHaveBeenCalledTimes(1); + }); + + it('renders a cached providers error without the skeleton or a refire before the flush', async () => { + providersQueryFn.mockRejectedValue(new Error('boom')); + organizationsQueryFn.mockResolvedValue([]); + + const queryClient = createTestQueryClient(); + const first = await renderWithProviders(createElement(ProfileScreen), { queryClient }); + + // Settle the first mount into the error state so the error is cached. + flushInteractions(); + await waitFor(() => nodeCount(first.renderer.root, 'QueryError') > 0); + + // Unmount without clearing the cache (the harness `unmount` clears it). + act(() => { + first.renderer.unmount(); + }); + + // The error is now cached; reset the call history so a refire is observable. + providersQueryFn.mockClear(); + + const second = await renderWithProviders(createElement(ProfileScreen), { queryClient }); + + // Before the flush: the cached error renders, no skeleton, and no refire. + expect(nodeCount(second.renderer.root, 'QueryError')).toBe(1); + expect(nodeCount(second.renderer.root, 'Skeleton')).toBe(0); + expect(providersQueryFn).not.toHaveBeenCalled(); + + second.unmount(); + }); + + it('hides the linked-accounts section when the deferred fetch settles empty', async () => { + providersQueryFn.mockResolvedValue({ providers: [] }); + organizationsQueryFn.mockResolvedValue([]); + + const { renderer, unmount } = await mountProfile(); + + flushInteractions(); + await waitFor( + () => providersQueryFn.mock.calls.length > 0 && organizationsQueryFn.mock.calls.length > 0 + ); + + // After the deferred fetch settles empty: no skeleton and no header. + await waitFor( + () => + nodeCountWithChildren(renderer.root, 'Text', 'Linked accounts') === 0 && + nodeCount(renderer.root, 'Skeleton') === 0 + ); + + expect(nodeCount(renderer.root, 'Skeleton')).toBe(0); + expect(nodeCountWithChildren(renderer.root, 'Text', 'Linked accounts')).toBe(0); + + unmount(); + }); +}); diff --git a/apps/mobile/src/components/profile-screen.tsx b/apps/mobile/src/components/profile-screen.tsx index 154107d563..9268b60385 100644 --- a/apps/mobile/src/components/profile-screen.tsx +++ b/apps/mobile/src/components/profile-screen.tsx @@ -32,6 +32,7 @@ import { useDeleteAccount } from '@/components/use-delete-account'; import { FEATURE_FLAG_PR_REVIEW, useFeatureFlag } from '@/lib/analytics/posthog'; import { useAuth } from '@/lib/auth/auth-context'; import { showFeedbackPrompt } from '@/lib/feedback'; +import { useAfterInteractions } from '@/lib/hooks/use-after-interactions'; import { useCurrentUserId } from '@/lib/hooks/use-current-user-id'; import { useThemeColors } from '@/lib/hooks/use-theme-colors'; import { useOrganization } from '@/lib/organization-context'; @@ -72,6 +73,7 @@ export function ProfileScreen() { const colors = useThemeColors(); const { organizationId, isLoaded: organizationContextLoaded } = useOrganization(); const isAuthenticated = token != null; + const afterInteractions = useAfterInteractions(); const prReviewEnabled = useFeatureFlag(FEATURE_FLAG_PR_REVIEW, true); const { data, @@ -81,7 +83,7 @@ export function ProfileScreen() { refetch: refetchProviders, } = useQuery({ ...trpc.user.getAuthProviders.queryOptions(), - enabled: isAuthenticated, + enabled: isAuthenticated && afterInteractions, }); const { data: orgs, @@ -90,10 +92,10 @@ export function ProfileScreen() { refetch: refetchOrganizations, } = useQuery({ ...trpc.organizations.list.queryOptions(), - enabled: isAuthenticated, + enabled: isAuthenticated && afterInteractions, }); const agentScope = organizationContextLoaded - ? getProfileAgentScope(organizationId, orgs, organizationsFetching) + ? getProfileAgentScope(organizationId, orgs, organizationsFetching || !afterInteractions) : undefined; const selectedOrg = orgs?.find(org => org.organizationId === organizationId); const orgRole = selectedOrg?.role; @@ -240,13 +242,16 @@ export function ProfileScreen() { {/* No layout animation on this section: siblings above mount/resize asynchronously; LinearTransition would animate this container's position lag as a visible header overlap. Opacity fades are safe. */} - {(isLoading || providersError || (data?.providers.length ?? 0) > 0) && ( + {(providersError || + (data?.providers.length ?? 0) > 0 || + isLoading || + (!afterInteractions && !data)) && ( Linked accounts - {isLoading && ( + {(isLoading || !afterInteractions) && !data && !providersError && ( diff --git a/apps/mobile/src/lib/bootstrap-decision.test.ts b/apps/mobile/src/lib/bootstrap-decision.test.ts new file mode 100644 index 0000000000..d6b8136198 --- /dev/null +++ b/apps/mobile/src/lib/bootstrap-decision.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveBootstrapDecision } from './bootstrap-decision'; + +// The settled, signed-in, consent-granted, app-ready state: every guard passes +// and the decision falls through to `settle-app`. +const ready = { + isLoading: false, + updateRequired: false, + inForceUpdate: false, + inAuthGroup: false, + hasToken: true, + userIdLoading: false, + userIdError: false, + consentCheckError: false, + consentChecked: true, + needsConsent: false, + onConsentRoute: false, + onConsentReviewRoute: false, +} as const; + +describe('resolveBootstrapDecision tag', () => { + it('waits while loading', () => { + expect(resolveBootstrapDecision({ ...ready, isLoading: true }).tag).toBe('wait-loading'); + }); + + it('redirects to force-update when an update is required off the route', () => { + expect(resolveBootstrapDecision({ ...ready, updateRequired: true }).tag).toBe( + 'redirect-force-update' + ); + }); + + it('settles force-update when an update is required on the route', () => { + expect( + resolveBootstrapDecision({ ...ready, updateRequired: true, inForceUpdate: true }).tag + ).toBe('settle-force-update'); + }); + + it('exits force-update when the route is stale', () => { + expect(resolveBootstrapDecision({ ...ready, inForceUpdate: true }).tag).toBe( + 'exit-force-update' + ); + }); + + it('settles login without a token in the auth group', () => { + expect(resolveBootstrapDecision({ ...ready, hasToken: false, inAuthGroup: true }).tag).toBe( + 'settle-login' + ); + }); + + it('redirects to login without a token outside the auth group', () => { + expect(resolveBootstrapDecision({ ...ready, hasToken: false }).tag).toBe('redirect-login'); + }); + + it('settles the user error when the user id failed', () => { + expect(resolveBootstrapDecision({ ...ready, userIdError: true }).tag).toBe('settle-user-error'); + }); + + it('settles the consent error when the consent check failed', () => { + expect(resolveBootstrapDecision({ ...ready, consentCheckError: true }).tag).toBe( + 'settle-consent-error' + ); + }); + + it('waits while the user id is loading', () => { + expect(resolveBootstrapDecision({ ...ready, userIdLoading: true }).tag).toBe( + 'wait-user-consent' + ); + }); + + it('waits before consent is checked', () => { + expect(resolveBootstrapDecision({ ...ready, consentChecked: false }).tag).toBe( + 'wait-user-consent' + ); + }); + + it('settles consent when needed on the consent route', () => { + expect( + resolveBootstrapDecision({ ...ready, needsConsent: true, onConsentRoute: true }).tag + ).toBe('settle-consent'); + }); + + it('redirects to consent when needed off the consent route', () => { + expect(resolveBootstrapDecision({ ...ready, needsConsent: true }).tag).toBe('redirect-consent'); + }); + + it('redirects to the app on a non-review consent route', () => { + expect( + resolveBootstrapDecision({ ...ready, onConsentRoute: true, onConsentReviewRoute: false }).tag + ).toBe('redirect-app'); + }); + + it('redirects to the app in the auth group', () => { + expect(resolveBootstrapDecision({ ...ready, inAuthGroup: true }).tag).toBe('redirect-app'); + }); + + it('settles the app on the success tail', () => { + expect(resolveBootstrapDecision(ready).tag).toBe('settle-app'); + }); +}); + +describe('resolveBootstrapDecision derivations', () => { + it('derives hasUserBootstrapError from token and user error', () => { + expect(resolveBootstrapDecision(ready).hasUserBootstrapError).toBe(false); + expect(resolveBootstrapDecision({ ...ready, userIdError: true }).hasUserBootstrapError).toBe( + true + ); + expect( + resolveBootstrapDecision({ ...ready, userIdError: true, hasToken: false }) + .hasUserBootstrapError + ).toBe(false); + }); + + it('derives hasConsentBootstrapError from token and consent error', () => { + expect(resolveBootstrapDecision(ready).hasConsentBootstrapError).toBe(false); + expect( + resolveBootstrapDecision({ ...ready, consentCheckError: true }).hasConsentBootstrapError + ).toBe(true); + expect( + resolveBootstrapDecision({ ...ready, consentCheckError: true, hasToken: false }) + .hasConsentBootstrapError + ).toBe(false); + }); + + it('derives hasBootstrapError as the union of both error flags', () => { + expect(resolveBootstrapDecision(ready).hasBootstrapError).toBe(false); + expect(resolveBootstrapDecision({ ...ready, userIdError: true }).hasBootstrapError).toBe(true); + expect(resolveBootstrapDecision({ ...ready, consentCheckError: true }).hasBootstrapError).toBe( + true + ); + }); + + it('derives hidden from loading, redirect, or consent loading, unless a bootstrap error shows', () => { + expect(resolveBootstrapDecision(ready).hidden).toBe(false); + expect(resolveBootstrapDecision({ ...ready, isLoading: true }).hidden).toBe(true); + expect(resolveBootstrapDecision({ ...ready, needsConsent: true }).hidden).toBe(true); + expect(resolveBootstrapDecision({ ...ready, consentChecked: false }).hidden).toBe(true); + expect(resolveBootstrapDecision({ ...ready, userIdError: true }).hidden).toBe(false); + expect(resolveBootstrapDecision({ ...ready, consentCheckError: true }).hidden).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/bootstrap-decision.ts b/apps/mobile/src/lib/bootstrap-decision.ts new file mode 100644 index 0000000000..fcdb19197f --- /dev/null +++ b/apps/mobile/src/lib/bootstrap-decision.ts @@ -0,0 +1,109 @@ +/** + * Pure bootstrap routing decision, extracted from the inline if-chain in + * `_layout.tsx`'s auth effect. Consumed by BOTH the routing effect and the + * render gating derivations. Keep in lockstep with that effect — do not + * refactor either in isolation. + */ + +type BootstrapDecisionTag = + | 'wait-loading' + | 'redirect-force-update' + | 'settle-force-update' + | 'exit-force-update' + | 'settle-login' + | 'redirect-login' + | 'settle-user-error' + | 'settle-consent-error' + | 'wait-user-consent' + | 'settle-consent' + | 'redirect-consent' + | 'redirect-app' + | 'settle-app'; + +export type BootstrapDecisionInput = { + isLoading: boolean; + updateRequired: boolean; + inForceUpdate: boolean; + inAuthGroup: boolean; + hasToken: boolean; + userIdLoading: boolean; + userIdError: boolean; + consentCheckError: boolean; + consentChecked: boolean; + needsConsent: boolean; + onConsentRoute: boolean; + onConsentReviewRoute: boolean; +}; + +export type BootstrapDecision = { + tag: BootstrapDecisionTag; + hasUserBootstrapError: boolean; + hasConsentBootstrapError: boolean; + hasBootstrapError: boolean; + hidden: boolean; +}; + +// Order mirrors the auth effect's if-chain exactly: earlier branches win. +function resolveBootstrapTag(input: BootstrapDecisionInput): BootstrapDecisionTag { + if (input.isLoading) { + return 'wait-loading'; + } + if (input.updateRequired) { + return input.inForceUpdate ? 'settle-force-update' : 'redirect-force-update'; + } + if (input.inForceUpdate) { + return 'exit-force-update'; + } + if (!input.hasToken) { + return input.inAuthGroup ? 'settle-login' : 'redirect-login'; + } + if (input.userIdError) { + return 'settle-user-error'; + } + if (input.consentCheckError) { + return 'settle-consent-error'; + } + if (input.userIdLoading || !input.consentChecked) { + return 'wait-user-consent'; + } + if (input.needsConsent) { + return input.onConsentRoute ? 'settle-consent' : 'redirect-consent'; + } + if ((input.onConsentRoute && !input.onConsentReviewRoute) || input.inAuthGroup) { + return 'redirect-app'; + } + return 'settle-app'; +} + +export function resolveBootstrapDecision(input: BootstrapDecisionInput): BootstrapDecision { + const hasUserBootstrapError = input.hasToken && input.userIdError; + const hasConsentBootstrapError = input.hasToken && input.consentCheckError; + const hasBootstrapError = hasUserBootstrapError || hasConsentBootstrapError; + const consentLoading = + input.hasToken && + !input.consentChecked && + !input.inAuthGroup && + !input.inForceUpdate && + !input.onConsentRoute; + const needsForceUpdate = input.updateRequired && !input.inForceUpdate; + const showingForceUpdate = input.updateRequired && input.inForceUpdate; + const needsAuth = !input.hasToken && !input.inAuthGroup; + const needsAppRedirect = input.hasToken && input.inAuthGroup; + const needsConsentRedirect = input.consentChecked && input.needsConsent && !input.onConsentRoute; + const needsRedirect = + !input.isLoading && + (needsForceUpdate || + (!showingForceUpdate && (needsAuth || needsAppRedirect || needsConsentRedirect))); + const hidden = + !hasUserBootstrapError && + !hasConsentBootstrapError && + (input.isLoading || needsRedirect || consentLoading); + + return { + tag: resolveBootstrapTag(input), + hasUserBootstrapError, + hasConsentBootstrapError, + hasBootstrapError, + hidden, + }; +} diff --git a/apps/mobile/src/lib/config.ts b/apps/mobile/src/lib/config.ts index 3c41c0f838..b4f77ba125 100644 --- a/apps/mobile/src/lib/config.ts +++ b/apps/mobile/src/lib/config.ts @@ -1,5 +1,11 @@ import expoConstants from 'expo-constants'; import { type ENV_KEYS, type OPTIONAL_ENV_KEYS } from './env-keys'; +import { + assertProductionHost, + assertUrlScheme, + PRODUCTION_HOSTS, + URL_SCHEMES, +} from '@/lib/url-contract'; const extra = expoConstants.expoConfig?.extra; @@ -37,6 +43,21 @@ export const PLAY_INTEGRITY_PROJECT_NUMBER: string | undefined = optional( ); export const SENTRY_ENVIRONMENT: string | undefined = optional('sentryEnvironment'); +// URL contract at module evaluation. The production host check keys off the +// baked `extra.isProductionBuild` flag, not the Sentry environment, so a +// preview release build never crashes on preview hosts. The `required` +// presence check above is the old presence-only check; remove it when every +// build passes through the config boundary in app.config.ts, which already +// throws on missing values. +const runProductionHostCheck = !__DEV__ && extra?.isProductionBuild === true; +for (const [key, schemes] of Object.entries(URL_SCHEMES)) { + const value = required(key as keyof typeof ENV_KEYS); + assertUrlScheme(key, value, schemes, { allowInsecure: __DEV__ }); + if (runProductionHostCheck) { + assertProductionHost(key, value, PRODUCTION_HOSTS); + } +} + function optionalLatencyMs(key: keyof typeof OPTIONAL_ENV_KEYS): number { const parsed = Number.parseInt(optional(key) ?? '', 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; diff --git a/apps/mobile/src/lib/hooks/use-after-interactions.ts b/apps/mobile/src/lib/hooks/use-after-interactions.ts new file mode 100644 index 0000000000..f9e1c08b58 --- /dev/null +++ b/apps/mobile/src/lib/hooks/use-after-interactions.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; +import { InteractionManager } from 'react-native'; + +/** + * Defer mount-time work until the current interaction frame settles. + * + * Returns false on mount, then true once `InteractionManager` runs the + * callback after the navigation transition finishes. The handle is cancelled + * on unmount so a late callback never sets state on an unmounted component. + */ +export function useAfterInteractions(): boolean { + const [afterInteractions, setAfterInteractions] = useState(false); + + useEffect(() => { + // eslint-disable-next-line typescript-eslint/no-deprecated -- InteractionManager.runAfterInteractions is the documented API for deferring work past the current interaction frame. + const handle = InteractionManager.runAfterInteractions(() => { + setAfterInteractions(true); + }); + return () => { + handle.cancel(); + }; + }, []); + + return afterInteractions; +} diff --git a/apps/mobile/src/lib/startup-order.test.ts b/apps/mobile/src/lib/startup-order.test.ts new file mode 100644 index 0000000000..6f0aa860a6 --- /dev/null +++ b/apps/mobile/src/lib/startup-order.test.ts @@ -0,0 +1,49 @@ +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { readFileSync } from 'node:fs'; +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +// Text-contract guard over the root layout source. The module-scope bootstrap +// kicks (Sentry init, notification wiring, prefetch, theme preload) must stay +// in _layout.tsx, and the deferred connections/analytics must stay out of it. +const layoutPath = fileURLToPath(new URL('../app/_layout.tsx', import.meta.url)); +const layoutSource = readFileSync(layoutPath, 'utf8'); + +const FORBIDDEN_IDENTIFIERS = [ + 'createUserWebConnection', + 'EventServiceClient', + 'useIAP', + 'initPostHog', + 'initAppsFlyer', + 'new WebSocket', +] as const; + +// Removes `//` line comments and `/* */` block comments while preserving line +// breaks, so a comment mentioning the call cannot satisfy the module-scope +// assertion. +function stripComments(source: string): string { + return source + .replaceAll(/\/\*[\s\S]*?\*\//g, match => match.replaceAll(/[^\n]/g, '')) + .replaceAll(/\/\/[^\n]*/g, ''); +} + +describe('root layout startup order (text contract)', () => { + it('calls initSentry(false) at module scope', () => { + const codeSource = stripComments(layoutSource); + const hasModuleScopeCall = codeSource + .split('\n') + .some(line => /^initSentry\(false\);$/.test(line)); + expect( + hasModuleScopeCall, + '_layout.tsx must call initSentry(false); at module scope (column 0)' + ).toBe(true); + }); + + it.each(FORBIDDEN_IDENTIFIERS)('does not reference %s', identifier => { + expect(layoutSource.includes(identifier), `_layout.tsx must not contain "${identifier}"`).toBe( + false + ); + }); +}); diff --git a/apps/mobile/src/lib/url-contract.js b/apps/mobile/src/lib/url-contract.js new file mode 100644 index 0000000000..c9e5324621 --- /dev/null +++ b/apps/mobile/src/lib/url-contract.js @@ -0,0 +1,86 @@ +/** URL scheme and production-host contract for mobile config values. + * Plain .js because the Expo config loader cannot consume workspace TS + * (same reason env-keys.js and sentry-dsn.js exist). Metro and vitest + * import .js fine, so the same file serves the config boundary + * (app.config.ts), the runtime boundary (config.ts), and the unit tests. */ + +/** Config key → allowed URL schemes. URL keys only — appsFlyerDevKey, + * appsFlyerAppId, and posthogApiKey are not URLs and get no scheme check. */ +export const URL_SCHEMES = { + apiBaseUrl: ['https:'], + webBaseUrl: ['https:'], + kiloChatUrl: ['https:'], + notificationsUrl: ['https:'], + cloudAgentWsUrl: ['wss:'], + sessionIngestWsUrl: ['wss:'], + // The event-service client accepts both https: and wss: + // (packages/event-service/src/client.ts:38-49). + eventServiceUrl: ['https:', 'wss:'], +}; + +/** Production host allowlist, seeded from the committed apps/mobile/.env + * production defaults. The .env URL values include api.kilo.ai, app.kilo.ai, + * cloud-agent-next.kilosessions.ai, ingest.kilosessions.ai, chat.kiloapps.io, + * events.kiloapps.io, and notifications.kiloapps.io. url-contract.test.ts + * asserts every committed .env URL value against this list, so a missing + * host fails the test before any build. Preflight is the runtime safety net: + * the release preflight runs assertProductionHost against the real production + * values, so an incomplete allowlist fails preflight before any build, never + * at runtime in a store build. */ +export const PRODUCTION_HOSTS = [ + 'api.kilo.ai', + 'app.kilo.ai', + 'chat.kiloapps.io', + 'cloud-agent-next.kilosessions.ai', + 'events.kiloapps.io', + 'ingest.kilosessions.ai', + 'notifications.kiloapps.io', +]; + +/** Parse a URL value, throwing a clear error for a missing or malformed URL. + * @param {string} name + * @param {string | undefined} value + * @returns {URL} + */ +function parseUrl(name, value) { + if (!value) { + throw new Error(`Missing URL for ${name}`); + } + try { + return new URL(value); + } catch { + throw new Error(`Invalid URL for ${name}: ${value}`); + } +} + +/** Throws unless the URL scheme is in `schemes`. `allowInsecure` additionally + * permits http: and ws: for local development. + * @param {string} name + * @param {string | undefined} value + * @param {string[]} schemes + * @param {{ allowInsecure?: boolean }} [options] + */ +// oxlint-disable-next-line max-params -- the options object carries the allowInsecure flag per the URL contract +export function assertUrlScheme(name, value, schemes, { allowInsecure = false } = {}) { + const parsed = parseUrl(name, value); + const allowed = allowInsecure ? [...schemes, 'http:', 'ws:'] : schemes; + if (!allowed.includes(parsed.protocol)) { + throw new Error( + `Invalid scheme for ${name}: expected ${schemes.join(' or ')}, got ${parsed.protocol}` + ); + } +} + +/** Throws when the URL host is outside the allowlist. + * @param {string} name + * @param {string | undefined} url + * @param {string[]} allowedHosts + */ +export function assertProductionHost(name, url, allowedHosts) { + const parsed = parseUrl(name, url); + if (!allowedHosts.includes(parsed.hostname)) { + throw new Error( + `Production host for ${name} (${parsed.hostname}) is outside the allowlist: ${allowedHosts.join(', ')}` + ); + } +} diff --git a/apps/mobile/src/lib/url-contract.test.ts b/apps/mobile/src/lib/url-contract.test.ts new file mode 100644 index 0000000000..5af6ac6af2 --- /dev/null +++ b/apps/mobile/src/lib/url-contract.test.ts @@ -0,0 +1,222 @@ +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { readFileSync } from 'node:fs'; +// eslint-disable-next-line import/no-nodejs-modules -- vitest-only guard, runs in node, never bundled into the app +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { + assertProductionHost, + assertUrlScheme, + PRODUCTION_HOSTS, + URL_SCHEMES, +} from '@/lib/url-contract'; +import { ENV_KEYS } from './env-keys'; + +describe('assertUrlScheme', () => { + const productionCases: { key: keyof typeof URL_SCHEMES; value: string }[] = [ + { key: 'apiBaseUrl', value: 'https://api.kilo.ai' }, + { key: 'webBaseUrl', value: 'https://app.kilo.ai' }, + { key: 'kiloChatUrl', value: 'https://chat.kilo.ai' }, + { key: 'notificationsUrl', value: 'https://notifications.kilo.ai' }, + { key: 'cloudAgentWsUrl', value: 'wss://cloud-agent.kilo.ai' }, + { key: 'sessionIngestWsUrl', value: 'wss://session-ingest.kilo.ai' }, + { key: 'eventServiceUrl', value: 'https://events.kilo.ai' }, + ]; + + it('accepts each URL key with its production scheme', () => { + for (const { key, value } of productionCases) { + expect(() => { + assertUrlScheme(key, value, URL_SCHEMES[key], { allowInsecure: false }); + }).not.toThrow(); + } + }); + + it('rejects a wrong scheme per key', () => { + expect(() => { + assertUrlScheme('apiBaseUrl', 'http://api.kilo.ai', URL_SCHEMES.apiBaseUrl, { + allowInsecure: false, + }); + }).toThrow(/scheme/); + expect(() => { + assertUrlScheme( + 'cloudAgentWsUrl', + 'https://cloud-agent.kilo.ai', + URL_SCHEMES.cloudAgentWsUrl, + { + allowInsecure: false, + } + ); + }).toThrow(/scheme/); + }); + + it('permits http: and ws: when allowInsecure is true', () => { + expect(() => { + assertUrlScheme('apiBaseUrl', 'http://localhost:3000', URL_SCHEMES.apiBaseUrl, { + allowInsecure: true, + }); + }).not.toThrow(); + expect(() => { + assertUrlScheme('cloudAgentWsUrl', 'ws://localhost:8080', URL_SCHEMES.cloudAgentWsUrl, { + allowInsecure: true, + }); + }).not.toThrow(); + }); + + it('still rejects http: and ws: when allowInsecure is false', () => { + expect(() => { + assertUrlScheme('apiBaseUrl', 'http://api.kilo.ai', URL_SCHEMES.apiBaseUrl, { + allowInsecure: false, + }); + }).toThrow(/scheme/); + expect(() => { + assertUrlScheme('cloudAgentWsUrl', 'ws://cloud-agent.kilo.ai', URL_SCHEMES.cloudAgentWsUrl, { + allowInsecure: false, + }); + }).toThrow(/scheme/); + }); + + it('rejects an unparseable URL', () => { + expect(() => { + assertUrlScheme('apiBaseUrl', 'not a url', URL_SCHEMES.apiBaseUrl, { allowInsecure: false }); + }).toThrow(/Invalid URL/); + }); + + it('throws a clear error for a missing value', () => { + expect(() => { + assertUrlScheme('apiBaseUrl', undefined, URL_SCHEMES.apiBaseUrl, { allowInsecure: false }); + }).toThrow(/Missing URL for apiBaseUrl/); + expect(() => { + assertUrlScheme('apiBaseUrl', '', URL_SCHEMES.apiBaseUrl, { allowInsecure: false }); + }).toThrow(/Missing URL for apiBaseUrl/); + }); + + it('eventServiceUrl accepts both https: and wss:', () => { + expect(() => { + assertUrlScheme('eventServiceUrl', 'https://events.kilo.ai', URL_SCHEMES.eventServiceUrl, { + allowInsecure: false, + }); + }).not.toThrow(); + expect(() => { + assertUrlScheme('eventServiceUrl', 'wss://events.kilo.ai', URL_SCHEMES.eventServiceUrl, { + allowInsecure: false, + }); + }).not.toThrow(); + expect(() => { + assertUrlScheme('eventServiceUrl', 'http://events.kilo.ai', URL_SCHEMES.eventServiceUrl, { + allowInsecure: false, + }); + }).toThrow(/scheme/); + }); +}); + +describe('assertProductionHost', () => { + it('accepts a host in the allowlist', () => { + expect(() => { + assertProductionHost('apiBaseUrl', 'https://api.kilo.ai', PRODUCTION_HOSTS); + }).not.toThrow(); + expect(() => { + assertProductionHost('webBaseUrl', 'https://app.kilo.ai', PRODUCTION_HOSTS); + }).not.toThrow(); + }); + + it('rejects a host outside the allowlist', () => { + expect(() => { + assertProductionHost('apiBaseUrl', 'https://evil.example.com', PRODUCTION_HOSTS); + }).toThrow(/outside the allowlist/); + }); + + it('rejects an unparseable URL', () => { + expect(() => { + assertProductionHost('apiBaseUrl', 'not a url', PRODUCTION_HOSTS); + }).toThrow(/Invalid URL/); + }); + + it('throws a clear error for a missing value', () => { + expect(() => { + assertProductionHost('apiBaseUrl', undefined, PRODUCTION_HOSTS); + }).toThrow(/Missing URL for apiBaseUrl/); + expect(() => { + assertProductionHost('apiBaseUrl', '', PRODUCTION_HOSTS); + }).toThrow(/Missing URL for apiBaseUrl/); + }); +}); + +// Host-contract guard over the committed apps/mobile/.env production defaults. +// Every URL value must pass the scheme check and stay inside the allowlist, so +// a missing host fails the test before any build. +const envPath = fileURLToPath(new URL('../../.env', import.meta.url)); +const envSource = readFileSync(envPath, 'utf8'); + +function parseEnv(source: string): Record { + const entries: Record = {}; + for (const rawLine of source.split('\n')) { + const match = /^([A-Z0-9_]+)=(.*)$/.exec(rawLine.trim()); + const name = match?.[1]; + const value = match?.[2]; + if (name !== undefined && value !== undefined) { + entries[name] = value; + } + } + return entries; +} + +const committedEnv = parseEnv(envSource); +const urlKeys = Object.keys(URL_SCHEMES) as (keyof typeof URL_SCHEMES)[]; + +describe('committed .env production defaults (host contract)', () => { + it('accepts every committed URL value for scheme and production host', () => { + for (const key of urlKeys) { + const envVar = ENV_KEYS[key]; + const value = committedEnv[envVar]; + expect(value, `${envVar} must be present in the committed .env`).toBeTruthy(); + expect(() => { + assertUrlScheme(key, value, URL_SCHEMES[key], { allowInsecure: false }); + }).not.toThrow(); + expect(() => { + assertProductionHost(key, value, PRODUCTION_HOSTS); + }).not.toThrow(); + } + }); +}); + +// Text-contract guard over app.config.ts. The URL-contract loop must skip +// absent values (warn-under-CI path), and the production path must keep its +// fatal-by-intent throw and Sentry source-map gate. +const configPath = fileURLToPath(new URL('../../app.config.ts', import.meta.url)); +const configSource = readFileSync(configPath, 'utf8'); +const configTsPath = fileURLToPath(new URL('config.ts', import.meta.url)); +const configTsSource = readFileSync(configTsPath, 'utf8'); + +// Removes `//` line comments and `/* */` block comments while preserving line +// breaks, so a comment mentioning a gate cannot satisfy the assertion. +function stripComments(source: string): string { + return source + .replaceAll(/\/\*[\s\S]*?\*\//g, match => match.replaceAll(/[^\n]/g, '')) + .replaceAll(/\/\/[^\n]*/g, ''); +} + +const configCodeSource = stripComments(configSource); +const configTsCodeSource = stripComments(configTsSource); + +describe('app.config.ts config boundary (text contract)', () => { + it('skips absent URL values instead of asserting them', () => { + expect(configCodeSource).toMatch(/if \(!value\)\s*continue/); + }); + + it('keeps the fatal-by-intent production gate', () => { + expect(configCodeSource).toMatch(/EAS_BUILD_PROFILE === 'production'/); + }); + + it('keeps the Sentry source-map upload gate', () => { + expect(configCodeSource).toMatch(/SENTRY_AUTH_TOKEN/); + }); + + it('bakes isProductionBuild into the extra block', () => { + expect(configCodeSource).toMatch(/extra:\s*\{[\s\S]*?isProductionBuild,/); + }); + + it('gates the runtime production host check on the baked flag', () => { + expect(configTsCodeSource).toContain('extra?.isProductionBuild === true'); + }); +}); diff --git a/scripts/inspect-mobile-artifacts.mjs b/scripts/inspect-mobile-artifacts.mjs new file mode 100644 index 0000000000..bfeee53e6e --- /dev/null +++ b/scripts/inspect-mobile-artifacts.mjs @@ -0,0 +1,289 @@ +#!/usr/bin/env node +/** + * Inspect signed kilo-app mobile artifacts before submission. + * + * Usage: + * node scripts/inspect-mobile-artifacts.mjs + * node scripts/inspect-mobile-artifacts.mjs --select + * + * The full mode unzips the IPA, parses its Info.plist, dumps the AAB manifest + * with bundletool, and checks debug symbols. The --select mode validates the + * EAS build.json (every build FINISHED, one IOS and one ANDROID entry with an + * applicationArchiveUrl) and prints the two archive URLs, one per line. + * + * Exits 1 with a clear message on any contract violation. + */ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const BUNDLE_IDENTIFIER = 'com.kilocode.kiloapp'; +const ANDROID_PACKAGE = 'com.kilocode.kiloapp'; +const SKADNETWORK_ENDPOINT = 'https://appsflyer-skadnetwork.com/'; +const INTENT_FILTER_HOST = 'app.kilo.ai'; +const BUNDLETOOL_URL = + 'https://github.com/google/bundletool/releases/download/1.18.3/bundletool-all-1.18.3.jar'; +const DEBUGSYMBOLS_PREFIX = 'BUNDLE-METADATA/com.android.tools.build.debugsymbols/'; +const REQUIRED_USAGE_DESCRIPTIONS = [ + 'NSMicrophoneUsageDescription', + 'NSSpeechRecognitionUsageDescription', + 'NSLocationWhenInUseUsageDescription', + 'NSUserTrackingUsageDescription', +]; +const BLOCKED_PERMISSIONS = [ + 'android.permission.READ_MEDIA_IMAGES', + 'android.permission.READ_MEDIA_VIDEO', + 'android.permission.READ_MEDIA_AUDIO', +]; + +const failures = []; + +function check(condition, message) { + if (!condition) { + failures.push(message); + } +} + +function run(cmd, args) { + return execFileSync(cmd, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); +} + +function reportAndExit() { + if (failures.length === 0) { + process.exit(0); + } + console.error('Mobile artifact inspection failed:'); + for (const failure of failures) { + console.error(` - ${failure}`); + } + process.exit(1); +} + +function parseBuildJson(path) { + let raw; + try { + raw = readFileSync(path, 'utf8'); + } catch (error) { + failures.push(`cannot read build.json: ${error.message}`); + reportAndExit(); + } + let builds; + try { + builds = JSON.parse(raw); + } catch (error) { + failures.push(`build.json is not valid JSON: ${error.message}`); + reportAndExit(); + } + if (!Array.isArray(builds)) { + failures.push('build.json must be a JSON array of build objects'); + reportAndExit(); + } + return builds; +} + +function assertAllFinished(builds) { + const unfinished = builds.filter(build => build && build.status !== 'FINISHED'); + if (unfinished.length > 0) { + const detail = unfinished + .map(build => `${build.platform ?? 'unknown'}=${build.status ?? 'missing'}`) + .join(', '); + failures.push(`every EAS build must be FINISHED, got: ${detail}`); + } +} + +function selectBuild(builds, platform) { + return builds.find(build => build && build.platform === platform); +} + +function artifactUrl(build) { + return build?.artifacts?.applicationArchiveUrl ?? ''; +} + +function selectMode(buildJsonPath) { + const builds = parseBuildJson(buildJsonPath); + assertAllFinished(builds); + const ios = selectBuild(builds, 'IOS'); + const android = selectBuild(builds, 'ANDROID'); + if (!ios) { + failures.push('build.json has no IOS build'); + } + if (!android) { + failures.push('build.json has no ANDROID build'); + } + const iosUrl = artifactUrl(ios); + const androidUrl = artifactUrl(android); + if (!iosUrl) { + failures.push('IOS build has no artifacts.applicationArchiveUrl'); + } + if (!androidUrl) { + failures.push('ANDROID build has no artifacts.applicationArchiveUrl'); + } + if (failures.length > 0) { + reportAndExit(); + } + process.stdout.write(`${iosUrl}\n${androidUrl}\n`); + process.exit(0); +} + +function parseInfoPlist(plistPath) { + // A signed IPA's Info.plist is binary. Python's plistlib stdlib handles both + // XML and binary formats and is preinstalled on the ubuntu-latest runner. + const script = [ + 'import plistlib, json, sys', + 'with open(sys.argv[1], "rb") as f:', + ' data = plistlib.load(f)', + 'json.dump(data, sys.stdout)', + ].join('\n'); + const out = run('python3', ['-c', script, plistPath]); + return JSON.parse(out); +} + +function inspectIos(ipaPath) { + const work = mkdtempSync(join(tmpdir(), 'kilo-inspect-ios-')); + try { + const extractDir = join(work, 'ipa'); + mkdirSync(extractDir, { recursive: true }); + try { + run('unzip', ['-q', '-o', ipaPath, '-d', extractDir]); + } catch (error) { + failures.push(`cannot unzip IPA ${ipaPath}: ${error.message}`); + return; + } + + const payloadDir = join(extractDir, 'Payload'); + let appName; + try { + appName = readdirSync(payloadDir).find(entry => entry.endsWith('.app')); + } catch { + appName = undefined; + } + if (!appName) { + failures.push(`IPA has no Payload/*.app bundle (checked ${payloadDir})`); + return; + } + const appPath = join(payloadDir, appName); + + let plist; + try { + plist = parseInfoPlist(join(appPath, 'Info.plist')); + } catch (error) { + failures.push(`cannot parse Info.plist: ${error.message}`); + return; + } + + check( + plist.CFBundleIdentifier === BUNDLE_IDENTIFIER, + `CFBundleIdentifier must be "${BUNDLE_IDENTIFIER}", got "${plist.CFBundleIdentifier}"` + ); + check( + existsSync(join(appPath, 'PrivacyInfo.xcprivacy')), + 'PrivacyInfo.xcprivacy must exist in the .app bundle' + ); + for (const key of REQUIRED_USAGE_DESCRIPTIONS) { + check( + typeof plist[key] === 'string' && plist[key].length > 0, + `Info.plist must contain a non-empty ${key}` + ); + } + check( + plist.NSAdvertisingAttributionReportEndpoint === SKADNETWORK_ENDPOINT, + `NSAdvertisingAttributionReportEndpoint must be "${SKADNETWORK_ENDPOINT}"` + ); + check( + plist.AttributionCopyEndpoint === SKADNETWORK_ENDPOINT, + `AttributionCopyEndpoint must be "${SKADNETWORK_ENDPOINT}"` + ); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +function listZipEntries(zipPath) { + try { + return run('unzip', ['-Z1', zipPath]) + .split('\n') + .filter(entry => entry.length > 0); + } catch (error) { + failures.push(`cannot list zip entries of ${zipPath}: ${error.message}`); + return []; + } +} + +function inspectAndroid(aabPath) { + const work = mkdtempSync(join(tmpdir(), 'kilo-inspect-android-')); + try { + const jarPath = join(work, 'bundletool.jar'); + try { + run('curl', ['-fsSL', BUNDLETOOL_URL, '-o', jarPath]); + } catch (error) { + failures.push(`cannot download bundletool: ${error.message}`); + return; + } + + let manifest; + try { + manifest = run('java', ['-jar', jarPath, 'dump', 'manifest', '--bundle', aabPath]); + } catch (error) { + failures.push(`bundletool dump manifest failed: ${error.message}`); + return; + } + + const packageMatch = manifest.match(/package="([^"]+)"/); + check( + packageMatch?.[1] === ANDROID_PACKAGE, + `android package must be "${ANDROID_PACKAGE}", got "${packageMatch?.[1] ?? 'none'}"` + ); + for (const permission of BLOCKED_PERMISSIONS) { + check( + !manifest.includes(`android:name="${permission}"`), + `${permission} must be absent from the manifest` + ); + } + check( + !manifest.includes('usesCleartextTraffic="true"'), + 'usesCleartextTraffic="true" must be absent from the manifest' + ); + check( + manifest.includes(`android:host="${INTENT_FILTER_HOST}"`), + `intent-filter host must be "${INTENT_FILTER_HOST}"` + ); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +function checkSymbols(aabPath) { + const entries = listZipEntries(aabPath); + const aabHasDebugSymbols = entries.some(entry => entry.startsWith(DEBUGSYMBOLS_PREFIX)); + check(aabHasDebugSymbols, `no debug symbols: the AAB has no ${DEBUGSYMBOLS_PREFIX} entries`); +} + +function main() { + const args = process.argv.slice(2); + if (args[0] === '--select') { + if (args.length !== 2) { + console.error('Usage: node inspect-mobile-artifacts.mjs --select '); + process.exit(2); + } + selectMode(args[1]); + return; + } + if (args.length !== 3) { + console.error('Usage: node inspect-mobile-artifacts.mjs '); + console.error(' node inspect-mobile-artifacts.mjs --select '); + process.exit(2); + } + const [ipaPath, aabPath, buildJsonPath] = args; + const builds = parseBuildJson(buildJsonPath); + assertAllFinished(builds); + inspectIos(ipaPath); + inspectAndroid(aabPath); + checkSymbols(aabPath); + reportAndExit(); +} + +main();