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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,14 +122,15 @@
"schema:diagram": "tsx scripts/generate-schema-diagram.ts",
"schema:view": "tsx scripts/generate-schema-diagram.ts --open",
"typecheck": "tsc --noEmit",
"lint": "pnpm lint:exact-versions && biome check . --max-diagnostics=500 && pnpm lint:workflow-downloads && pnpm lint:analytics-sql && pnpm lint:analytics-policy",
"lint": "pnpm lint:exact-versions && biome check . --max-diagnostics=500 && pnpm lint:workflow-downloads && pnpm lint:analytics-sql && pnpm lint:analytics-policy && pnpm lint:mobile-telemetry",
"lint:fix": "biome check --write .",
"lint:exact-versions": "tsx scripts/exact-versions.ts",
"lint:openapi": "redocly lint docs/whoop-api.openapi.yaml --extends minimal",
"lint:css": "stylelint \"**/*.css\"",
"lint:workflow-downloads": "tsx scripts/workflow-download-policy.ts .github/workflows .github/actions",
"lint:analytics-sql": "sh -c 'set -a; [ ! -f .env.local ] || . ./.env.local; set +a; cd analytics && UV_PROJECT_ENVIRONMENT=../.venv-analytics uv run --project . sqlfluff lint --ignore parsing models'",
"lint:analytics-policy": "sh -c 'files=$(find analytics src/db/clickhouse-sql -path \"analytics/target\" -prune -o -path \"analytics/logs\" -prune -o -path \"analytics/dbt_packages\" -prune -o -name \"*.sql\" -type f -print 2>/dev/null); if [ -z \"$files\" ]; then echo \"No analytics SQL files to lint\"; else tsx scripts/migration-policy.ts $files; fi'",
"lint:mobile-telemetry": "tsx scripts/mobile-catch-telemetry-policy.ts packages/mobile",
"lint:migrations": "sh -c 'files=$(git diff --diff-filter=AM --name-only origin/main...HEAD -- \"drizzle/*.sql\" \":(exclude)drizzle/_history/*.sql\"); if [ -z \"$files\" ]; then echo \"No changed migrations to lint\"; else tsx scripts/migration-policy.ts $files; fi'",
"sherif": "sherif",
"format": "biome format --write .",
Expand Down
28 changes: 27 additions & 1 deletion packages/mobile/app/ble-probe.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { writeRawMock } = vi.hoisted(() => ({ writeRawMock: vi.fn() }));
const { mockCaptureException, writeRawMock } = vi.hoisted(() => ({
mockCaptureException: vi.fn(),
writeRawMock: vi.fn(),
}));

vi.mock("../lib/telemetry", () => ({
captureException: mockCaptureException,
}));

vi.mock("../modules/ble-probe", () => ({
addNotificationListener: () => ({ remove: vi.fn() }),
Expand Down Expand Up @@ -57,6 +64,25 @@ describe("BleProbeScreen", () => {
expect(writeRawMock).toHaveBeenCalledTimes(1);
});

it("reports native command failures while keeping them in the on-screen log", async () => {
const writeError = new Error("Native write failed");
writeRawMock.mockRejectedValueOnce(writeError);
const { default: BleProbeScreen } = await import("./ble-probe");
render(<BleProbeScreen />);

fireEvent.click(
screen.getByRole("button", {
name: "Send hello and toggle inertial measurement unit (IMU) mode",
}),
);

await waitFor(() => {
expect(mockCaptureException).toHaveBeenCalledWith(writeError, {
source: "ble-probe-hello-imu",
});
});
});

it("disables Send when the command is blank", async () => {
const { default: BleProbeScreen } = await import("./ble-probe");
render(<BleProbeScreen />);
Expand Down
16 changes: 12 additions & 4 deletions packages/mobile/app/ble-probe.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export default function BleProbeScreen() {
break;
}
} catch (error) {
captureException(error, { source: "ble-probe-whoop-discovery" });
addLog(`whoop-ble module error: ${error}`, "error");
}
// Fall back to ble-probe module's own scan
Expand Down Expand Up @@ -150,14 +151,20 @@ export default function BleProbeScreen() {
await subscribe("0003");
addLog(" 0003 subscribed");
} catch (error: unknown) {
captureException(error, { context: "ble-probe-subscribe" });
captureException(error, {
source: "ble-probe-subscribe",
characteristic: "0003",
});
addLog(" 0003 failed");
}
try {
await subscribe("0005");
addLog(" 0005 subscribed");
} catch (error: unknown) {
captureException(error, { context: "ble-probe-subscribe" });
captureException(error, {
source: "ble-probe-subscribe",
characteristic: "0005",
});
addLog(" 0005 failed");
}
addLog("Ready!");
Expand Down Expand Up @@ -263,7 +270,7 @@ export default function BleProbeScreen() {
addLog(`whoop-ble isNotifying: ${stats.isNotifying}`);
addLog(`whoop-ble buffered: ${whoopBle.getBufferedSampleCount()}`);
} catch (error: unknown) {
captureException(error, { context: "ble-probe-whoop" });
captureException(error, { source: "ble-probe-whoop-status" });
addLog("whoop-ble module not available", "error");
}
break;
Expand Down Expand Up @@ -295,6 +302,7 @@ export default function BleProbeScreen() {
addLog(`Unknown command: ${cmd}. Type 'help'.`, "error");
}
} catch (error) {
captureException(error, { source: "ble-probe-command" });
addLog(`Error: ${error instanceof Error ? error.message : String(error)}`, "error");
}
},
Expand Down Expand Up @@ -329,7 +337,7 @@ export default function BleProbeScreen() {
await writeRaw("0002", "aa010c000001e74123026a01010000001cc9f7a9", false);
addLog("TOGGLE_IMU_MODE sent — watching for response...", "info");
} catch (error: unknown) {
captureException(error, { context: "ble-probe-hello-imu" });
captureException(error, { source: "ble-probe-hello-imu" });
addLog(`Error: ${error}`, "error");
} finally {
helloAndImuBusyRef.current = false;
Expand Down
23 changes: 22 additions & 1 deletion packages/mobile/app/heart-rate-visualization.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// @vitest-environment jsdom

import { act, render, screen } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { describe, expect, it, vi } from "vitest";

Expand Down Expand Up @@ -98,6 +98,27 @@ describe("HeartRateVisualizationScreen", () => {
expect(screen.getByText("Connecting to WHOOP...")).toBeTruthy();
});

it("reports connection failures while preserving the visible error", async () => {
const whoopBle = await import("../modules/whoop-ble");
const connectionError = new Error("Bluetooth connection failed");
vi.spyOn(whoopBle, "findWhoop").mockResolvedValue({
id: "whoop-1",
name: "WHOOP",
});
vi.spyOn(whoopBle, "connect").mockRejectedValue(connectionError);
const { captureException } = await import("../lib/telemetry");
const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization");

render(<HeartRateVisualizationScreen />);

await waitFor(() => {
expect(screen.getByText("Bluetooth connection failed")).toBeTruthy();
});
expect(captureException).toHaveBeenCalledWith(connectionError, {
source: "heart-rate-visualization-connect",
});
});

it("starts in streaming state when BLE is already connected", async () => {
const whoopBle = await import("../modules/whoop-ble");
vi.spyOn(whoopBle, "getConnectionState").mockReturnValue("streaming");
Expand Down
10 changes: 8 additions & 2 deletions packages/mobile/app/heart-rate-visualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export default function HeartRateVisualizationScreen() {
setSampleCount((previous) => previous + newSamples.length);
setHeartRateHistory((previous) => [...previous, ...newHeartRates].slice(-MAX_SAMPLES));
} catch (pollError) {
captureException(pollError, { context: "heart-rate-visualization-poll" });
captureException(pollError, { source: "heart-rate-visualization-poll" });
}
}, POLL_INTERVAL_MS);
}, [stopPolling]);
Expand All @@ -97,7 +97,10 @@ export default function HeartRateVisualizationScreen() {
if (isAlreadyConnected()) {
try {
await startRealtimeHr();
} catch {
} catch (startError: unknown) {
captureException(startError, {
source: "heart-rate-visualization-start-realtime",
});
// Best-effort — passive HR data may still flow
}
setStatus("streaming");
Expand All @@ -121,6 +124,9 @@ export default function HeartRateVisualizationScreen() {
setStatus("streaming");
startPolling();
} catch (connectionError) {
captureException(connectionError, {
source: "heart-rate-visualization-connect",
});
setError(
connectionError instanceof Error ? connectionError.message : String(connectionError),
);
Expand Down
109 changes: 109 additions & 0 deletions packages/mobile/app/imu-visualization.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// @vitest-environment jsdom

import { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const whoopBleMocks = vi.hoisted(() => ({
addConnectionStateListener: vi.fn(() => ({ remove: vi.fn() })),
addOrientationListener: vi.fn(() => ({ remove: vi.fn() })),
connect: vi.fn(() => Promise.resolve(true)),
findWhoop: vi.fn(() => Promise.resolve({ id: "whoop-1", name: "WHOOP" })),
getConnectionState: vi.fn(() => "idle"),
startImuStreaming: vi.fn(() => Promise.resolve(true)),
}));
const mockCaptureException = vi.hoisted(() => vi.fn());

function stripStyle({
style: _style,
contentContainerStyle: _contentStyle,
...rest
}: Record<string, unknown>) {
return rest;
}

vi.mock("react-native", () => ({
View: ({ children, ...props }: Record<string, unknown>) =>
React.createElement("div", stripStyle(props), ...(children != null ? [children] : [])),
Text: ({ children, ...props }: Record<string, unknown>) =>
React.createElement("span", stripStyle(props), ...(children != null ? [children] : [])),
ScrollView: ({ children, ...props }: Record<string, unknown>) =>
React.createElement("div", stripStyle(props), ...(children != null ? [children] : [])),
StyleSheet: {
create: <Styles extends Record<string, unknown>>(styles: Styles): Styles => styles,
},
}));

vi.mock("expo-router", () => ({
Stack: {
Screen: () => null,
},
}));

vi.mock("../components/WristModel", () => ({
WristModel: () => null,
}));

vi.mock("../modules/whoop-ble", () => whoopBleMocks);

vi.mock("../lib/telemetry", () => ({
captureException: mockCaptureException,
}));

vi.mock("../theme", () => ({
colors: {
background: "#000",
surface: "#111",
text: "#fff",
textSecondary: "#aaa",
positive: "#0f0",
danger: "#f00",
green: "#0f0",
blue: "#00f",
},
}));

vi.mock("./_layout-options", () => ({
rootStackScreenOptions: {},
}));

import ImuVisualizationScreen from "./imu-visualization";

describe("ImuVisualizationScreen", () => {
beforeEach(() => {
vi.clearAllMocks();
whoopBleMocks.getConnectionState.mockReturnValue("idle");
whoopBleMocks.findWhoop.mockResolvedValue({ id: "whoop-1", name: "WHOOP" });
whoopBleMocks.connect.mockResolvedValue(true);
whoopBleMocks.startImuStreaming.mockResolvedValue(true);
});

it("reports a best-effort streaming-start failure for an existing connection", async () => {
const streamingError = new Error("Native streaming failed");
whoopBleMocks.getConnectionState.mockReturnValue("streaming");
whoopBleMocks.startImuStreaming.mockRejectedValue(streamingError);

render(<ImuVisualizationScreen />);

await waitFor(() => {
expect(mockCaptureException).toHaveBeenCalledWith(streamingError, {
source: "imu-visualization-start-streaming",
});
});
expect(screen.getByText("streaming")).toBeTruthy();
});

it("reports connection failures while preserving the visible error", async () => {
const connectionError = new Error("Bluetooth connection failed");
whoopBleMocks.connect.mockRejectedValue(connectionError);

render(<ImuVisualizationScreen />);

await waitFor(() => {
expect(screen.getByText("Bluetooth connection failed")).toBeTruthy();
});
expect(mockCaptureException).toHaveBeenCalledWith(connectionError, {
source: "imu-visualization-connect",
});
});
});
7 changes: 6 additions & 1 deletion packages/mobile/app/imu-visualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Stack } from "expo-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollView, StyleSheet, Text, View } from "react-native";
import { WristModel } from "../components/WristModel";
import { captureException } from "../lib/telemetry";
import type { OrientationEvent } from "../modules/whoop-ble";
import {
addConnectionStateListener,
Expand Down Expand Up @@ -80,7 +81,10 @@ export default function ImuVisualizationScreen() {
// Best-effort: ensure IMU mode is on (background sync should have done this)
try {
await startImuStreaming();
} catch {
} catch (startError: unknown) {
captureException(startError, {
source: "imu-visualization-start-streaming",
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Ignore — background sync likely already started it
}
setStatus("streaming");
Expand All @@ -102,6 +106,7 @@ export default function ImuVisualizationScreen() {
await startImuStreaming();
setStatus("streaming");
} catch (connectionError) {
captureException(connectionError, { source: "imu-visualization-connect" });
setError(
connectionError instanceof Error ? connectionError.message : String(connectionError),
);
Expand Down
22 changes: 18 additions & 4 deletions packages/mobile/app/login.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const { mockCaptureException } = vi.hoisted(() => ({
mockCaptureException: vi.fn(),
}));

// Mock auth module before importing LoginScreen
const mockOnLoginSuccess = vi.fn();
const mockFetchConfiguredProviders = vi.fn();
Expand Down Expand Up @@ -48,6 +52,10 @@ vi.mock("../components/ProviderLogo", () => ({
ProviderLogo: () => null,
}));

vi.mock("../lib/telemetry", () => ({
captureException: mockCaptureException,
}));

const { default: LoginScreen } = await import("./login");

describe("LoginScreen", () => {
Expand Down Expand Up @@ -134,12 +142,16 @@ describe("LoginScreen", () => {
});

it("shows error message on fetch failure", async () => {
mockFetchConfiguredProviders.mockRejectedValue(new Error("Network error"));
const providerDiscoveryError = new Error("Network error");
mockFetchConfiguredProviders.mockRejectedValue(providerDiscoveryError);
render(<LoginScreen />);

await waitFor(() => {
expect(screen.getByText("Network error")).toBeTruthy();
});
expect(mockCaptureException).toHaveBeenCalledWith(providerDiscoveryError, {
source: "login-screen-configured-providers",
});
});

it("shows empty state when no providers configured", async () => {
Expand Down Expand Up @@ -344,9 +356,7 @@ describe("LoginScreen", () => {
data: [],
nativeApple: true,
});
const cancelError = new Error("User canceled");
Object.assign(cancelError, { code: "ERR_REQUEST_CANCELED" });
mockStartNativeAppleSignIn.mockRejectedValue(cancelError);
mockStartNativeAppleSignIn.mockResolvedValue(null);

render(<LoginScreen />);

Expand All @@ -358,6 +368,10 @@ describe("LoginScreen", () => {
});
expect(mockStartOAuthLogin).not.toHaveBeenCalled();
expect(screen.queryByText("User canceled")).toBeNull();
expect(mockCaptureException).not.toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ source: "login-screen-handle-login" }),
);
});

it("requests a password reset from sign-in mode", async () => {
Expand Down
Loading
Loading