From 25e83c34aac704cee17be779374c02cd0c7e4abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:40:56 +0200 Subject: [PATCH 1/5] chore(workflow): learn that mobile vitest project includes can silently skip new tests A test file added in a directory no include glob covers passes CI without ever running: vitest.config.ts composes two projects with enumerated includes, and a path outside every glob matches no project and is skipped with no warning. Symptom/cause/fix recorded; the fix side lands with the identity-step test in this section. --- .../vitest-mobile-project-includes-skip-new-tests.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .kilo_workflow/learnings/vitest-mobile-project-includes-skip-new-tests.md diff --git a/.kilo_workflow/learnings/vitest-mobile-project-includes-skip-new-tests.md b/.kilo_workflow/learnings/vitest-mobile-project-includes-skip-new-tests.md new file mode 100644 index 0000000000..e4e20b1d75 --- /dev/null +++ b/.kilo_workflow/learnings/vitest-mobile-project-includes-skip-new-tests.md @@ -0,0 +1,7 @@ +# New apps/mobile test file passes CI without ever running — check the vitest project includes + +Symptom: a newly added `apps/mobile` test file passes CI without ever executing; `pnpm test` reports no new suite and the suite count does not grow. Green is not proof the file ran. + +Cause: `apps/mobile/vitest.config.ts` composes two projects with **enumerated** include globs. `vitest.pure.config.ts` lists specific directories (`src/lib/*.test.ts`, `src/components/**/*.test.ts`, `src/components/pr-review/**/*.test.tsx`, …) and `vitest.mounted.config.ts` matches only `src/**/*.mounted.test.tsx`. A path outside every listed glob — for example a `.test.tsx` under `src/components//` — matches no project and is silently skipped, with no warning. + +Fix: before adding a mobile test in a new directory, check both config files and add the narrowest matching include (e.g. `src/components/kiloclaw/**/*.test.tsx` — not `src/components/**/*.test.tsx`, which would double-own `*.mounted.test.tsx` files with the mounted project). Confirm the suite count actually grew in the `pnpm test` output. From 8b8ea58c6b8770b00d8a69392369a6680d754e38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:41:14 +0200 Subject: [PATCH 2/5] fix(mobile): stop reporting AppsFlyer event delivery failures to Sentry A failed logEvent delivery is a transport failure (offline, DNS-blocked, ad-blocked, proxied) that the AppsFlyer SDK queues and retries itself; the JS error callback carries a bare platform message string with no structured discriminator, so no developer action is possible and every report was noise (KILO-APP-1Z). Both logEvent error callbacks - the trackEvent site and the pending-event drain - are now the existing noop. Actionable integration failures still report: initSdk's error callback ("AppsFlyer init failed") and the purchase connector's are untouched. Tests: transport failure not reported, queued-drain failure not reported, and init failure still reported with its message intact. --- apps/mobile/src/lib/appsflyer.test.ts | 71 +++++++++++++++++++++++++++ apps/mobile/src/lib/appsflyer.ts | 17 ++++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/appsflyer.test.ts b/apps/mobile/src/lib/appsflyer.test.ts index ff63f4d7b2..ea0ac96f64 100644 --- a/apps/mobile/src/lib/appsflyer.test.ts +++ b/apps/mobile/src/lib/appsflyer.test.ts @@ -40,6 +40,12 @@ async function loadInit() { return module.initAppsFlyer; } +async function loadModule() { + vi.resetModules(); + const module = await import('./appsflyer'); + return module; +} + describe('initAppsFlyer purchase connector', () => { beforeEach(() => { vi.clearAllMocks(); @@ -145,3 +151,68 @@ describe('initAppsFlyer purchase connector', () => { expect((captured as Error).message).toContain('native bridge down'); }); }); + +// AppsFlyer's logEvent takes (name, values, onSuccess, onError); the error +// callback is the fourth argument, read positionally to stay under max-params. +function failLogEventTransport() { + mockedAppsFlyer.logEvent.mockImplementation((...args: unknown[]) => { + const onError = args[3] as (details: unknown) => void; + onError('Failed to connect to fxvuzl.inapps.appsflyersdk.com/[::]:443'); + }); +} + +describe('AppsFlyer event reporting', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedPlatform.OS = 'ios'; + mockedAppsFlyer.create.mockResolvedValue(undefined); + mockedAppsFlyer.initSdk.mockImplementation( + (_options: unknown, onSuccess: (result: string) => void) => { + onSuccess('ok'); + } + ); + }); + + it('does not report a logEvent transport failure to Sentry', async () => { + const Sentry = await import('@sentry/react-native'); + failLogEventTransport(); + + const { initAppsFlyer, trackEvent } = await loadModule(); + initAppsFlyer(); + trackEvent('access-required-shown'); + + expect(mockedAppsFlyer.logEvent).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('does not report a queued-event delivery failure when the queue drains', async () => { + const Sentry = await import('@sentry/react-native'); + failLogEventTransport(); + + const { initAppsFlyer, trackEvent } = await loadModule(); + trackEvent('access-required-shown'); + expect(mockedAppsFlyer.logEvent).not.toHaveBeenCalled(); + + initAppsFlyer(); + + expect(mockedAppsFlyer.logEvent).toHaveBeenCalledTimes(1); + expect(Sentry.captureException).not.toHaveBeenCalled(); + }); + + it('still reports an SDK init failure to Sentry', async () => { + const Sentry = await import('@sentry/react-native'); + mockedAppsFlyer.initSdk.mockImplementation( + (_options: unknown, _onSuccess: (result: string) => void, onError: (d: unknown) => void) => { + onError('Invalid dev key'); + } + ); + + const { initAppsFlyer } = await loadModule(); + initAppsFlyer(); + + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + const captured = vi.mocked(Sentry.captureException).mock.calls[0]?.[0]; + expect((captured as Error).message).toContain('AppsFlyer init failed'); + expect((captured as Error).message).toContain('Invalid dev key'); + }); +}); diff --git a/apps/mobile/src/lib/appsflyer.ts b/apps/mobile/src/lib/appsflyer.ts index 4acf4593e1..fc18b9a9d1 100644 --- a/apps/mobile/src/lib/appsflyer.ts +++ b/apps/mobile/src/lib/appsflyer.ts @@ -76,17 +76,13 @@ async function settlePurchaseConnectorCreate( } } -// eslint-disable-next-line @typescript-eslint/no-empty-function -- AppsFlyer SDK requires a success callback +// eslint-disable-next-line @typescript-eslint/no-empty-function -- AppsFlyer SDK callbacks are required arguments function noop() {} function drainPendingEvents() { for (const event of pendingEvents) { - appsFlyer.logEvent( - event.name, - event.values, - noop, - handleError(`AppsFlyer event "${event.name}" failed`) - ); + // Error callback is `noop` for the same reason as in trackEvent below. + appsFlyer.logEvent(event.name, event.values, noop, noop); } pendingEvents.length = 0; } @@ -150,5 +146,10 @@ export function trackEvent(name: string, values?: Record): void return; } - appsFlyer.logEvent(name, eventValues, noop, handleError(`AppsFlyer event "${name}" failed`)); + // A logEvent delivery failure is a transport failure (offline, DNS-blocked, + // ad-blocker, corporate proxy) that the SDK retries itself and no developer + // can act on, so it is not reported. Actionable AppsFlyer failures — a bad + // dev key or app id, or a broken purchase connector — still reach Sentry + // through initSdk's and the connector's error callbacks. + appsFlyer.logEvent(name, eventValues, noop, noop); } From c0ce40145efea879d5a357ace75a5e4f24da9ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 16:41:37 +0200 Subject: [PATCH 3/5] fix(mobile): stop reporting identity-step GPS expected conditions to Sentry Everything reaching the outer catch of handleGpsPress is an expected user-environment condition - the app's own 10s GPS timeout (KILO-APP-27), location services off, unsatisfied device settings (KILO-APP-2Z) - and the UI already tells the user exactly what happened via locationFeedback ("Could not get your location. Enter it manually."). Nothing there is developer-actionable, so the captureException is removed; the feedback message, status, and loading handling are byte-identical. The inner catch's validateWeatherLocation capture stays: that procedure is first-party, so its failures are actionable and must keep reporting. Tests: positive control (validateWeatherLocation rejection still reports the exact error object) plus both production expected-condition messages asserting no report, no backend mutation, and that the handler ran. The new suite needs a mobile-pure include for src/components/kiloclaw - added as the narrowest matching glob (see the committed learning). --- .../onboarding/identity-step.test.tsx | 160 ++++++++++++++++++ .../kiloclaw/onboarding/identity-step.tsx | 6 +- apps/mobile/vitest.pure.config.ts | 1 + 3 files changed, 165 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx diff --git a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx new file mode 100644 index 0000000000..44d9d900c9 --- /dev/null +++ b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx @@ -0,0 +1,160 @@ +import * as React from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { IdentityStep } from './identity-step'; + +const locationMocks = vi.hoisted(() => ({ + requestForegroundPermissionsAsync: vi.fn<() => Promise<{ status: string }>>(), + getCurrentPositionAsync: vi.fn<() => Promise>(), +})); + +const mutationMocks = vi.hoisted(() => ({ + mutateAsync: vi.fn<() => Promise>(), + mutate: vi.fn(), +})); + +vi.mock('react', async () => { + const actual = await vi.importActual('react'); + return { + ...actual, + useState: vi.fn( + (initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void] + ), + useMemo: vi.fn((factory: () => T) => factory()), + useRef: vi.fn((initial: T) => { + const ref: React.RefObject = { current: initial }; + return ref; + }), + useCallback: vi.fn( unknown>(fn: T) => fn), + }; +}); + +vi.mock('@sentry/react-native', () => ({ captureException: vi.fn() })); + +vi.mock('expo-location', () => ({ + requestForegroundPermissionsAsync: locationMocks.requestForegroundPermissionsAsync, + getCurrentPositionAsync: locationMocks.getCurrentPositionAsync, + PermissionStatus: { GRANTED: 'granted' }, + Accuracy: { Lowest: 1 }, +})); + +vi.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ + mutateAsync: mutationMocks.mutateAsync, + mutate: mutationMocks.mutate, + isPending: false, + }), +})); + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + Alert: { alert: vi.fn() }, + Pressable: 'Pressable', + ScrollView: 'ScrollView', + TextInput: 'TextInput', + View: 'View', +})); + +vi.mock('react-native-reanimated', () => ({ + default: { View: 'Animated.View' }, + LinearTransition: 'LinearTransition', +})); + +vi.mock('lucide-react-native', () => ({ + ChevronDown: () => null, + ChevronRight: () => null, + ChevronUp: () => null, + MapPin: () => null, +})); + +vi.mock('@/components/ui/button', () => ({ Button: 'Button' })); +vi.mock('@/components/ui/text', () => ({ Text: 'Text' })); +vi.mock('@/components/kiloclaw/bot-avatar', () => ({ BotAvatar: () => null })); +vi.mock('@/components/kiloclaw/bot-avatar-options', () => ({ botAvatarName: () => 'bot' })); + +vi.mock('@/lib/trpc', () => ({ + useTRPC: () => ({ + kiloclaw: { validateWeatherLocation: { mutationOptions: () => ({}) } }, + }), +})); + +vi.mock('@/lib/hooks/use-theme-colors', () => ({ + useThemeColors: () => ({ + foreground: '#000000', + mutedForeground: '#666666', + primaryForeground: '#ffffff', + }), +})); + +type Node = { props?: Record } | null | undefined | string | number | boolean; + +function findByAccessibilityLabel(node: Node, label: string): Record | null { + if (node === null || typeof node !== 'object') { + return null; + } + const props = node.props ?? {}; + if (props.accessibilityLabel === label) { + return props; + } + const children = props.children; + for (const child of Array.isArray(children) ? children : [children]) { + const found = findByAccessibilityLabel(child as Node, label); + if (found) { + return found; + } + } + return null; +} + +function pressGpsButton() { + // eslint-disable-next-line new-cap -- called as a plain function, matching pr-review-screen.test.tsx + const element = IdentityStep({ onContinue: vi.fn<() => void>() }) as Node; + const props = findByAccessibilityLabel(element, 'Use current location'); + if (!props) { + throw new Error('GPS button not found in the rendered tree'); + } + (props.onPress as () => void)(); +} + +describe('IdentityStep GPS error reporting', () => { + beforeEach(() => { + vi.clearAllMocks(); + locationMocks.requestForegroundPermissionsAsync.mockResolvedValue({ status: 'granted' }); + }); + + it('reports a weather-location validation failure to Sentry', async () => { + const Sentry = await import('@sentry/react-native'); + const validateError = new Error('weather backend down'); + locationMocks.getCurrentPositionAsync.mockResolvedValue({ + coords: { latitude: 52.37, longitude: 4.9 }, + }); + mutationMocks.mutateAsync.mockRejectedValue(validateError); + + pressGpsButton(); + + await vi.waitFor(() => { + expect(Sentry.captureException).toHaveBeenCalledTimes(1); + }); + expect(vi.mocked(Sentry.captureException).mock.calls[0]?.[0]).toBe(validateError); + }); + + it.each(['timeout', 'Location request failed due to unsatisfied device settings'])( + 'does not report an expected location failure (%s) to Sentry', + async message => { + const Sentry = await import('@sentry/react-native'); + locationMocks.getCurrentPositionAsync.mockRejectedValue(new Error(message)); + + pressGpsButton(); + + await vi.waitFor(() => { + expect(locationMocks.getCurrentPositionAsync).toHaveBeenCalledTimes(1); + }); + await new Promise(resolve => { + setTimeout(resolve, 0); + }); + + expect(Sentry.captureException).not.toHaveBeenCalled(); + expect(mutationMocks.mutateAsync).not.toHaveBeenCalled(); + } + ); +}); diff --git a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx index 91ab1155da..a0d766c128 100644 --- a/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx +++ b/apps/mobile/src/components/kiloclaw/onboarding/identity-step.tsx @@ -206,8 +206,10 @@ export function IdentityStep({ status: 'error', }); } - } catch (error) { - Sentry.captureException(error); + } catch { + // Expected user-environment outcomes (GPS timeout, location services off, + // permission failure). The user is told via locationFeedback below, and + // there is nothing a developer could act on, so nothing is reported. setLocationFeedback({ message: 'Could not get your location. Enter it manually.', status: 'error', diff --git a/apps/mobile/vitest.pure.config.ts b/apps/mobile/vitest.pure.config.ts index 05a476f3c4..930a338d99 100644 --- a/apps/mobile/vitest.pure.config.ts +++ b/apps/mobile/vitest.pure.config.ts @@ -29,6 +29,7 @@ export default defineProject({ 'src/lib/voice-input/**/*.test.ts', 'src/components/**/*.test.ts', 'src/components/pr-review/**/*.test.tsx', + 'src/components/kiloclaw/**/*.test.tsx', ], }, }); From 50ba74da76a02cd5fdbed04fc7b8d565702d0162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Tue, 28 Jul 2026 18:20:15 +0200 Subject: [PATCH 4/5] chore(workflow): learn that E2E slots must be owned by a dedicated verifier session --- .../e2e-slot-owner-must-be-dedicated-session.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .kilo_workflow/learnings/e2e-slot-owner-must-be-dedicated-session.md diff --git a/.kilo_workflow/learnings/e2e-slot-owner-must-be-dedicated-session.md b/.kilo_workflow/learnings/e2e-slot-owner-must-be-dedicated-session.md new file mode 100644 index 0000000000..b50a52cc2f --- /dev/null +++ b/.kilo_workflow/learnings/e2e-slot-owner-must-be-dedicated-session.md @@ -0,0 +1,17 @@ +# E2E slot acquired under a shared/long-lived tmux session name leaks the slot + +Symptom: an E2E slot stays held for tens of minutes with no verifier running; `e2e-slot.sh status` +shows the holder as a long-lived session name (e.g. the planner/starter session) while other +sections starve on a 3-slot machine. The slot is never auto-reclaimed because the holding session +never dies. + +Cause: an orchestrator (not a verifier) ran `e2e-slot.sh acquire` itself, under its window's shared +tmux session name instead of a dedicated per-round verifier session. Slot reclamation keys on the +holder's tmux session being gone; a shared session outlives every round, so the hold is effectively +permanent until someone releases it by hand. + +Fix: only the e2e-verifier acquires a slot, always under its own dedicated session name +(`
-e2e-verifier-