Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
(`<section>-e2e-verifier-<label>`), and releases it the moment its device phase ends (release also
stops the stack — a slot and a dev stack are the same resource since #4826). The orchestrator never
acquires slots, never starts dev stacks "for" a verifier, and checks `e2e-slot.sh status` before
declaring starvation; a blocked `acquire` from a real holder is correct behaviour, not a wedge.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# iOS 26.5 simulator: cannot make getCurrentPositionAsync hang (locationd always answers)

Symptom: an E2E flow needs `Location.getCurrentPositionAsync` to never resolve (e.g. to fire an
app-side 10 s GPS-timeout race), but on iOS 26.5 simulators a fix always arrives immediately.

Cause, verified on iPhone 17 Pro / iOS 26.5 (Xcode 26):
- `xcrun simctl location <udid> clear` is non-functional: after `set A` then `clear`, CoreLocation
keeps serving A; on a freshly erased device it serves a built-in default (observed: Potrero
District, SF). The clear returns exit 0 and changes nothing.
- `launchctl kill SIGSTOP system/com.apple.locationd` (via `xcrun simctl spawn`) does not produce a
position-request hang either: a warm app resolves from its in-process CLLocationManager cache
(Accuracy.Lowest accepts it), and a cold app hangs earlier — inside
`requestForegroundPermissionsAsync`, before any app-side timeout race starts. The button then
shows its busy state indefinitely (evidence that the spinner renders while awaiting).
- `killall` does not exist in the sim userland; signal system daemons with
`xcrun simctl spawn <udid> launchctl kill <SIG> system/<label>` (heeds the
`user/foreground/<label>` warning; both resolve).

Fix: drive the same outer-catch path with Location Services OFF instead:
`xcrun simctl spawn <udid> defaults write /var/mobile/Library/Preferences/com.apple.locationd.plist
LocationServicesEnabled -bool false`, then restart the daemon
(`launchctl kill SIGTERM system/com.apple.locationd`). Per-app permission stays granted, so
`getCurrentPositionAsync` rejects into the same catch a timeout would. Restore with `-bool true` +
SIGTERM. On-device evidence of the 10 s race itself is not obtainable on a simulator — cover it by
unit test and record the substitution in the report.

Related: Maestro point taps reject decimal percentages (`NumberFormatException: For input string:
"34.4"`) — use absolute coordinates ("201,301") or integer percents.
Original file line number Diff line number Diff line change
@@ -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/<anything-but-pr-review>/` — 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.
160 changes: 160 additions & 0 deletions apps/mobile/src/components/kiloclaw/onboarding/identity-step.test.tsx
Original file line number Diff line number Diff line change
@@ -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<unknown>>(),
}));

const mutationMocks = vi.hoisted(() => ({
mutateAsync: vi.fn<() => Promise<unknown>>(),
mutate: vi.fn(),
}));

vi.mock('react', async () => {
const actual = await vi.importActual<typeof React>('react');
return {
...actual,
useState: vi.fn(
<T,>(initial: T) => [initial, vi.fn() as () => void] as [T, (value: T) => void]
),
useMemo: vi.fn(<T,>(factory: () => T) => factory()),
useRef: vi.fn(<T,>(initial: T) => {
const ref: React.RefObject<T> = { current: initial };
return ref;
}),
useCallback: vi.fn(<T extends (...args: never[]) => 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<string, unknown> } | null | undefined | string | number | boolean;

function findByAccessibilityLabel(node: Node, label: string): Record<string, unknown> | 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();
}
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
71 changes: 71 additions & 0 deletions apps/mobile/src/lib/appsflyer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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');
});
});
17 changes: 9 additions & 8 deletions apps/mobile/src/lib/appsflyer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -150,5 +146,10 @@ export function trackEvent(name: string, values?: Record<string, string>): 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);
}
1 change: 1 addition & 0 deletions apps/mobile/vitest.pure.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
},
});
Loading