From cf6af33606f838bc492cd7bba75d1d5c480e1edc Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 12:07:29 -0700 Subject: [PATCH 1/5] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e6ec695aea..07b31b1c8f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ packages/mobile/certs/public-key.pem .claude/* !.claude/skills/ .context/ +.worktrees/ .DS_Store *.swp *.swo From b5e9cd66ca19f1a7265e5a29394b2aee8396d08e Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 12:56:03 -0700 Subject: [PATCH 2/5] Prevent mobile app test routes --- AGENTS.md | 1 + package.json | 5 +- packages/mobile/.rnstorybook/main.ts | 5 +- packages/mobile/.storybook/main.ts | 2 +- packages/mobile/AGENTS.md | 1 + packages/mobile/README.md | 4 +- .../(tabs)/index.stories.tsx | 2 +- .../(tabs)/strain.stories.tsx | 2 +- .../activities.stories.tsx | 2 +- .../activity/ZoneCharts.stories.tsx | 2 +- .../{app => app-stories}/login.stories.tsx | 2 +- .../providers/index.stories.tsx | 2 +- .../{app => app-stories}/settings.stories.tsx | 2 +- .../(tabs)/_layout.test.tsx | 0 .../{app => app-tests}/(tabs)/food.test.tsx | 2 +- .../{app => app-tests}/(tabs)/index.test.tsx | 16 ++-- .../(tabs)/recovery.test.tsx | 12 +-- .../{app => app-tests}/(tabs)/strain.test.tsx | 12 +-- .../{app => app-tests}/+native-intent.test.ts | 2 +- .../_layout.cleanup.test.tsx | 4 +- .../_layout.telemetry-guard.test.ts | 2 +- .../mobile/{app => app-tests}/_layout.test.ts | 2 +- .../{app => app-tests}/activity/[id].test.tsx | 14 +-- .../daily-heart-rate.test.tsx | 4 +- .../heart-rate-visualization.test.tsx | 22 +++-- .../inertial-measurement-unit.test.tsx | 10 +- .../mobile/{app => app-tests}/login.test.tsx | 2 +- .../{app => app-tests}/preview.test.tsx | 8 +- .../providers/[id].test.tsx | 42 ++++---- .../providers/index.test.tsx | 96 +++++++++---------- .../{app => app-tests}/settings.test.tsx | 26 ++--- packages/mobile/package.json | 2 +- scripts/check-mobile-app-route-files.ts | 39 ++++++++ 33 files changed, 203 insertions(+), 146 deletions(-) rename packages/mobile/{app => app-stories}/(tabs)/index.stories.tsx (98%) rename packages/mobile/{app => app-stories}/(tabs)/strain.stories.tsx (98%) rename packages/mobile/{app => app-stories}/activities.stories.tsx (97%) rename packages/mobile/{app => app-stories}/activity/ZoneCharts.stories.tsx (97%) rename packages/mobile/{app => app-stories}/login.stories.tsx (91%) rename packages/mobile/{app => app-stories}/providers/index.stories.tsx (97%) rename packages/mobile/{app => app-stories}/settings.stories.tsx (98%) rename packages/mobile/{app => app-tests}/(tabs)/_layout.test.tsx (100%) rename packages/mobile/{app => app-tests}/(tabs)/food.test.tsx (97%) rename packages/mobile/{app => app-tests}/(tabs)/index.test.tsx (90%) rename packages/mobile/{app => app-tests}/(tabs)/recovery.test.tsx (93%) rename packages/mobile/{app => app-tests}/(tabs)/strain.test.tsx (90%) rename packages/mobile/{app => app-tests}/+native-intent.test.ts (96%) rename packages/mobile/{app => app-tests}/_layout.cleanup.test.tsx (98%) rename packages/mobile/{app => app-tests}/_layout.telemetry-guard.test.ts (92%) rename packages/mobile/{app => app-tests}/_layout.test.ts (92%) rename packages/mobile/{app => app-tests}/activity/[id].test.tsx (94%) rename packages/mobile/{app => app-tests}/daily-heart-rate.test.tsx (95%) rename packages/mobile/{app => app-tests}/heart-rate-visualization.test.tsx (95%) rename packages/mobile/{app => app-tests}/inertial-measurement-unit.test.tsx (95%) rename packages/mobile/{app => app-tests}/login.test.tsx (99%) rename packages/mobile/{app => app-tests}/preview.test.tsx (86%) rename packages/mobile/{app => app-tests}/providers/[id].test.tsx (91%) rename packages/mobile/{app => app-tests}/providers/index.test.tsx (90%) rename packages/mobile/{app => app-tests}/settings.test.tsx (92%) create mode 100644 scripts/check-mobile-app-route-files.ts diff --git a/AGENTS.md b/AGENTS.md index 95ebcef753..6f7e729a13 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,7 @@ Provider-agnostic fitness/health data pipeline. Syncs data from various provider - **Server-side metric computation**: All metric values must be computed on the server — never derive, aggregate, or transform metric data in web or iOS client code. The API response should contain every value the UI needs to display. Clients are responsible only for rendering (colors, labels, formatting, layout) — not for computing the numbers they display. This prevents inconsistencies when the same metric appears on multiple screens or platforms. If a client is calling a scoring/calculation function on raw data from the API, that calculation belongs in the server router instead. - **Good architecture and modeling**: Actively look for opportunities to decouple code, model real-world concepts as proper classes/types, use common interfaces, and apply SOLID principles with domain-driven design. When you see scattered logic that represents a single concept (e.g., "is this provider connected?"), extract it into a model or interface rather than leaving it inline. Prefer domain-driven abstractions over ad-hoc checks spread across the codebase. Follow SOLID principles: single responsibility (each class/module does one thing), open/closed (extend via composition, not modification), Liskov substitution (subtypes must be substitutable), interface segregation (small, focused interfaces), and dependency inversion (depend on abstractions, not concretions). Prefer composition over inheritance — build complex behavior by combining simple, focused components rather than deep class hierarchies. Use dependency injection, strategy patterns, and mixins instead of base classes. - **Dual-platform parity (web + mobile)**: Every feature, bug fix, and UI change must be implemented on both `packages/web` and `packages/mobile`. When adding a new page, chart, or data view to one platform, implement the equivalent on the other in the same PR. Shared logic lives in domain-specific packages (`@dofek/format`, `@dofek/scoring`, `@dofek/nutrition`, `@dofek/training`, `@dofek/stats`, `@dofek/onboarding`, `@dofek/providers`) — import from there instead of duplicating. Platform-specific code (HealthKit, barcode scanning, Expo secure storage, ECharts vs react-native-svg) stays in the respective package. When reviewing PRs, check that both platforms are updated. +- **Mobile Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router treats files in `app/` as route candidates, which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/` and route stories under `packages/mobile/app-stories/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. - **Always report errors to Sentry**: Never silently swallow errors or only log them. Every `catch` block that handles an unexpected error must call `captureException()` (from `./telemetry` in mobile, or the equivalent in server code) so failures are visible in our error monitoring. Silent `catch(() => {})` blocks are banned — they hide bugs and make debugging impossible. - **Surface errors to the user by default**: When a server error occurs, send a specific, actionable error message to the client — never hide it behind a generic "Something went wrong" or "Failed to load." Use a TRPCError with an appropriate code (e.g., `PRECONDITION_FAILED`, `NOT_FOUND`) and a human-readable message that tells the user what's wrong and what to do. Clients must display `error.message` from the server, not hardcoded strings. Hiding the real error from the user makes debugging slower and generates support requests that could be self-service. - **Fail fast, never warn-and-continue**: When a required precondition is missing (env file, config, dependency), fail immediately with a clear error — never log a warning and silently continue with broken state. A deploy that proceeds with an empty `.env.prod` is worse than one that fails loudly. Warnings that don't stop execution are deceptive; they hide the real problem and cause confusing downstream failures. diff --git a/package.json b/package.json index 99c8435bdb..61b049e244 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "generate": "drizzle-kit generate && tsx scripts/generate-schema-diagram.ts", "schema:diagram": "tsx scripts/generate-schema-diagram.ts", "schema:view": "tsx scripts/generate-schema-diagram.ts --open", - "lint": "biome check . --max-diagnostics=500", + "lint": "biome check . --max-diagnostics=500 && tsx scripts/check-mobile-app-route-files.ts", "lint:fix": "biome check --write .", "sherif": "sherif", "format": "biome format --write .", @@ -116,7 +116,8 @@ "size": "size-limit", "depcruise": "depcruise --config .dependency-cruiser.cjs src/ packages/", "spellcheck": "cspell --no-progress", - "check:mobile-update": "./scripts/check-mobile-update.sh" + "check:mobile-update": "./scripts/check-mobile-update.sh", + "check:mobile-app-routes": "tsx scripts/check-mobile-app-route-files.ts" }, "dependencies": { "@aws-crypto/client-node": "4.2.2", diff --git a/packages/mobile/.rnstorybook/main.ts b/packages/mobile/.rnstorybook/main.ts index d3aea1eb02..53dfff1746 100644 --- a/packages/mobile/.rnstorybook/main.ts +++ b/packages/mobile/.rnstorybook/main.ts @@ -1,7 +1,10 @@ import type { StorybookConfig } from "@storybook/react-native"; const config: StorybookConfig = { - stories: ["../components/**/*.stories.?(ts|tsx|js|jsx)", "../app/**/*.stories.?(ts|tsx|js|jsx)"], + stories: [ + "../components/**/*.stories.?(ts|tsx|js|jsx)", + "../app-stories/**/*.stories.?(ts|tsx|js|jsx)", + ], addons: ["@storybook/addon-ondevice-actions", "@storybook/addon-ondevice-controls"], }; diff --git a/packages/mobile/.storybook/main.ts b/packages/mobile/.storybook/main.ts index 356f407470..531ef2d782 100644 --- a/packages/mobile/.storybook/main.ts +++ b/packages/mobile/.storybook/main.ts @@ -5,7 +5,7 @@ import type { StorybookConfig } from "@storybook/react-native-web-vite"; const currentDir = dirname(fileURLToPath(import.meta.url)); const config: StorybookConfig = { - stories: ["../components/**/*.stories.@(ts|tsx)", "../app/**/*.stories.@(ts|tsx)"], + stories: ["../components/**/*.stories.@(ts|tsx)", "../app-stories/**/*.stories.@(ts|tsx)"], framework: "@storybook/react-native-web-vite", docs: { autodocs: "tag", diff --git a/packages/mobile/AGENTS.md b/packages/mobile/AGENTS.md index 8285bd16f4..386f243dc3 100644 --- a/packages/mobile/AGENTS.md +++ b/packages/mobile/AGENTS.md @@ -13,6 +13,7 @@ - **Storybook**: Every component MUST have a `.stories.tsx` file (lives in `.storybook` and `.rnstorybook`). - **Charts**: Use `react-native-svg` for all chart implementations. - **Navigation**: Uses Expo Router. Screen paths map to `app/`. +- **Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router treats files in `app/` as route candidates, which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/` and route stories under `packages/mobile/app-stories/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. - **Query state handling**: Treat loading, error, and empty as separate UI states. Do not use `query.data ?? []` or similar fallbacks when `query.error` exists. Use `components/QueryStatePanel.tsx` for explicit error/empty/loading states on screens and cards. ### Native Config Consistency diff --git a/packages/mobile/README.md b/packages/mobile/README.md index b6a260288b..69cb5db5ad 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -14,7 +14,9 @@ See `../../docs/nutrition-ai-input.md` for end-to-end behavior and API flow. ## Project Structure -- `app/`: Expo Router screens (file-based routing). +- `app/`: Expo Router screens (file-based routing). Keep this route-only; tests and stories in `app/` can become accidental screens. +- `app-tests/`: Vitest tests for Expo Router screens. +- `app-stories/`: Storybook stories for Expo Router screens. - `components/`: React Native UI components (SVG-based charts). - `modules/`: Native Swift modules: - `background-refresh`: iOS background task registration. diff --git a/packages/mobile/app/(tabs)/index.stories.tsx b/packages/mobile/app-stories/(tabs)/index.stories.tsx similarity index 98% rename from packages/mobile/app/(tabs)/index.stories.tsx rename to packages/mobile/app-stories/(tabs)/index.stories.tsx index 30e4d91142..e9e1c5cd41 100644 --- a/packages/mobile/app/(tabs)/index.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/index.stories.tsx @@ -4,9 +4,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { type ReactNode, useMemo } from "react"; import { View } from "react-native"; +import TodayScreen from "../../app/(tabs)/index"; import { trpc } from "../../lib/trpc"; import { colors } from "../../theme"; -import TodayScreen from "./index"; function localDateString(dayOffset = 0): string { const date = new Date(); diff --git a/packages/mobile/app/(tabs)/strain.stories.tsx b/packages/mobile/app-stories/(tabs)/strain.stories.tsx similarity index 98% rename from packages/mobile/app/(tabs)/strain.stories.tsx rename to packages/mobile/app-stories/(tabs)/strain.stories.tsx index 874a9561cb..70fd8eb779 100644 --- a/packages/mobile/app/(tabs)/strain.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/strain.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { View } from "react-native"; -import StrainScreen from "./strain"; +import StrainScreen from "../../app/(tabs)/strain"; const mockWorkloadData = { displayedStrain: 12.5, diff --git a/packages/mobile/app/activities.stories.tsx b/packages/mobile/app-stories/activities.stories.tsx similarity index 97% rename from packages/mobile/app/activities.stories.tsx rename to packages/mobile/app-stories/activities.stories.tsx index 22fa3aa5d7..e5cd846c4b 100644 --- a/packages/mobile/app/activities.stories.tsx +++ b/packages/mobile/app-stories/activities.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { View } from "react-native"; -import ActivitiesScreen from "./activities"; +import ActivitiesScreen from "../app/activities"; function createSeededProviders() { const queryClient = new QueryClient({ diff --git a/packages/mobile/app/activity/ZoneCharts.stories.tsx b/packages/mobile/app-stories/activity/ZoneCharts.stories.tsx similarity index 97% rename from packages/mobile/app/activity/ZoneCharts.stories.tsx rename to packages/mobile/app-stories/activity/ZoneCharts.stories.tsx index 69e239d1fe..ea91e00319 100644 --- a/packages/mobile/app/activity/ZoneCharts.stories.tsx +++ b/packages/mobile/app-stories/activity/ZoneCharts.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { View } from "react-native"; -import { HrZonesChart, PowerZonesChart } from "./[id]"; +import { HrZonesChart, PowerZonesChart } from "../../app/activity/[id]"; const heartRateZones = [ { zone: 1, label: "Recovery", minPct: 50, maxPct: 60, seconds: 300 }, diff --git a/packages/mobile/app/login.stories.tsx b/packages/mobile/app-stories/login.stories.tsx similarity index 91% rename from packages/mobile/app/login.stories.tsx rename to packages/mobile/app-stories/login.stories.tsx index 090324e321..633adf72af 100644 --- a/packages/mobile/app/login.stories.tsx +++ b/packages/mobile/app-stories/login.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { View } from "react-native"; -import LoginScreen from "./login"; +import LoginScreen from "../app/login"; const meta = { title: "Pages/Login", diff --git a/packages/mobile/app/providers/index.stories.tsx b/packages/mobile/app-stories/providers/index.stories.tsx similarity index 97% rename from packages/mobile/app/providers/index.stories.tsx rename to packages/mobile/app-stories/providers/index.stories.tsx index 9f202c2560..3622001267 100644 --- a/packages/mobile/app/providers/index.stories.tsx +++ b/packages/mobile/app-stories/providers/index.stories.tsx @@ -1,8 +1,8 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import type { ComponentType } from "react"; import { View } from "react-native"; +import { ProviderCard } from "../../app/providers/provider-card.tsx"; import { AuthProvider } from "../../lib/auth-context"; -import { ProviderCard } from "./provider-card.tsx"; // ── ProviderCard ── // AuthProvider is resolved to .storybook/mocks/auth-context in web Storybook diff --git a/packages/mobile/app/settings.stories.tsx b/packages/mobile/app-stories/settings.stories.tsx similarity index 98% rename from packages/mobile/app/settings.stories.tsx rename to packages/mobile/app-stories/settings.stories.tsx index 1c97647971..5d8141ba50 100644 --- a/packages/mobile/app/settings.stories.tsx +++ b/packages/mobile/app-stories/settings.stories.tsx @@ -1,7 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { View } from "react-native"; -import SettingsScreen from "./settings"; +import SettingsScreen from "../app/settings"; function createSeededProviders() { const queryClient = new QueryClient({ diff --git a/packages/mobile/app/(tabs)/_layout.test.tsx b/packages/mobile/app-tests/(tabs)/_layout.test.tsx similarity index 100% rename from packages/mobile/app/(tabs)/_layout.test.tsx rename to packages/mobile/app-tests/(tabs)/_layout.test.tsx diff --git a/packages/mobile/app/(tabs)/food.test.tsx b/packages/mobile/app-tests/(tabs)/food.test.tsx similarity index 97% rename from packages/mobile/app/(tabs)/food.test.tsx rename to packages/mobile/app-tests/(tabs)/food.test.tsx index 424ffcea2b..51b2039f42 100644 --- a/packages/mobile/app/(tabs)/food.test.tsx +++ b/packages/mobile/app-tests/(tabs)/food.test.tsx @@ -65,7 +65,7 @@ describe("FoodScreen AI meal confirmation", () => { }); it("waits for confirmation before creating AI parsed food entries", async () => { - const { default: FoodScreen } = await import("./food"); + const { default: FoodScreen } = await import("../../app/(tabs)/food"); render(); diff --git a/packages/mobile/app/(tabs)/index.test.tsx b/packages/mobile/app-tests/(tabs)/index.test.tsx similarity index 90% rename from packages/mobile/app/(tabs)/index.test.tsx rename to packages/mobile/app-tests/(tabs)/index.test.tsx index b3fca68b5a..f013891c75 100644 --- a/packages/mobile/app/(tabs)/index.test.tsx +++ b/packages/mobile/app-tests/(tabs)/index.test.tsx @@ -136,7 +136,7 @@ describe("TodayScreen independent loading states", () => { it("shows skeleton placeholder for recovery ring while readiness is loading", async () => { mockDashboardLoading = true; - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); // In the consolidated query, everything loads together @@ -146,7 +146,7 @@ describe("TodayScreen independent loading states", () => { it("shows skeleton placeholder for strain gauge while workload is loading", async () => { mockDashboardLoading = true; - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getAllByTestId("skeleton-circle").length).toBeGreaterThanOrEqual(1); @@ -155,7 +155,7 @@ describe("TodayScreen independent loading states", () => { it("hides sleep summary section while sleep analytics is loading", async () => { mockDashboardLoading = true; - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.queryByText("LAST NIGHT")).toBeNull(); @@ -174,14 +174,14 @@ describe("TodayScreen independent loading states", () => { awakePct: 10, }; - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getByText("LAST NIGHT")).toBeTruthy(); }); it("renders all rings when no queries are loading", async () => { - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getAllByText("Recovery").length).toBeGreaterThanOrEqual(1); @@ -193,7 +193,7 @@ describe("TodayScreen independent loading states", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 2, 21, 15, 30)); - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); fireEvent.click(screen.getByText("Log Food")); @@ -204,7 +204,7 @@ describe("TodayScreen independent loading states", () => { it("shows a recovery error panel when the readiness query fails", async () => { mockDashboardError = new Error("Dashboard failed"); - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getByText("Dashboard failed")).toBeTruthy(); @@ -214,7 +214,7 @@ describe("TodayScreen independent loading states", () => { // In consolidated approach, they share the same error state mockDashboardError = new Error("Dashboard failed"); - const { default: TodayScreen } = await import("./index"); + const { default: TodayScreen } = await import("../../app/(tabs)/index"); render(); expect(screen.getByText("Dashboard failed")).toBeTruthy(); diff --git a/packages/mobile/app/(tabs)/recovery.test.tsx b/packages/mobile/app-tests/(tabs)/recovery.test.tsx similarity index 93% rename from packages/mobile/app/(tabs)/recovery.test.tsx rename to packages/mobile/app-tests/(tabs)/recovery.test.tsx index 0ec01023a1..d7822f5cb6 100644 --- a/packages/mobile/app/(tabs)/recovery.test.tsx +++ b/packages/mobile/app-tests/(tabs)/recovery.test.tsx @@ -95,7 +95,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { mockTrendsData = { latest_spo2: 97 }; mockDailyMetricsData = [{ spo2_avg: 96 }, { spo2_avg: 97 }]; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); expect(screen.getByText("Blood Oxygen")).toBeTruthy(); @@ -107,7 +107,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { mockTrendsData = { latest_skin_temp: 36.8 }; mockDailyMetricsData = [{ skin_temp_c: 36.6 }, { skin_temp_c: 36.8 }]; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); expect(screen.getByText("Skin Temperature")).toBeTruthy(); @@ -117,7 +117,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { mockTrendsData = { latest_spo2: null }; mockDailyMetricsData = []; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); expect(screen.queryByText("Blood Oxygen")).toBeNull(); @@ -127,7 +127,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { mockTrendsData = { latest_skin_temp: null }; mockDailyMetricsData = []; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); expect(screen.queryByText("Skin Temperature")).toBeNull(); @@ -159,7 +159,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { }, ]; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); // Breakdown weight labels should not be visible initially @@ -202,7 +202,7 @@ describe("RecoveryScreen SpO2 and Skin Temperature cards", () => { }, ]; - const { default: RecoveryScreen } = await import("./recovery"); + const { default: RecoveryScreen } = await import("../../app/(tabs)/recovery"); render(); const readinessSparklineCall = sparkLinePropsCalls.find((sparkLineProps) => { diff --git a/packages/mobile/app/(tabs)/strain.test.tsx b/packages/mobile/app-tests/(tabs)/strain.test.tsx similarity index 90% rename from packages/mobile/app/(tabs)/strain.test.tsx rename to packages/mobile/app-tests/(tabs)/strain.test.tsx index aa35c5fb78..63ea8cea62 100644 --- a/packages/mobile/app/(tabs)/strain.test.tsx +++ b/packages/mobile/app-tests/(tabs)/strain.test.tsx @@ -90,7 +90,7 @@ describe("StrainScreen recent activity navigation", () => { }, ]; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); fireEvent.click(screen.getByText("Morning Ride")); @@ -107,7 +107,7 @@ describe("StrainScreen recent activity navigation", () => { explanation: "Recovery is strong (78). Push for a high-strain day to build fitness.", }; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); expect(screen.getByText("Daily Strain Target")).toBeTruthy(); @@ -119,7 +119,7 @@ describe("StrainScreen recent activity navigation", () => { it("does not render strain target card when no target data", async () => { mockStrainTargetData = undefined; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); expect(screen.queryByText("Daily Strain Target")).toBeNull(); @@ -141,7 +141,7 @@ describe("StrainScreen recent activity navigation", () => { }, ]; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); fireEvent.click(screen.getByText("View all")); @@ -152,7 +152,7 @@ describe("StrainScreen recent activity navigation", () => { it("shows empty state and View all link when no activities exist", async () => { mockActivities = []; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); expect(screen.getByText("Recent Activities")).toBeTruthy(); @@ -163,7 +163,7 @@ describe("StrainScreen recent activity navigation", () => { it("navigates to activities list from View all when no activities exist", async () => { mockActivities = []; - const { default: StrainScreen } = await import("./strain"); + const { default: StrainScreen } = await import("../../app/(tabs)/strain"); render(); fireEvent.click(screen.getByText("View all")); diff --git a/packages/mobile/app/+native-intent.test.ts b/packages/mobile/app-tests/+native-intent.test.ts similarity index 96% rename from packages/mobile/app/+native-intent.test.ts rename to packages/mobile/app-tests/+native-intent.test.ts index cc9554fbaa..7a4aed79b2 100644 --- a/packages/mobile/app/+native-intent.test.ts +++ b/packages/mobile/app-tests/+native-intent.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { redirectSystemPath } from "./+native-intent"; +import { redirectSystemPath } from "../app/+native-intent"; describe("redirectSystemPath", () => { it("routes shared files to providers import flow", () => { diff --git a/packages/mobile/app/_layout.cleanup.test.tsx b/packages/mobile/app-tests/_layout.cleanup.test.tsx similarity index 98% rename from packages/mobile/app/_layout.cleanup.test.tsx rename to packages/mobile/app-tests/_layout.cleanup.test.tsx index 30c63f95b0..773e174c55 100644 --- a/packages/mobile/app/_layout.cleanup.test.tsx +++ b/packages/mobile/app-tests/_layout.cleanup.test.tsx @@ -108,7 +108,7 @@ vi.mock("../modules/whoop-ble", () => ({ stopImuStreaming: vi.fn(), })); -vi.mock("./login", () => ({ +vi.mock("../app/login", () => ({ default: () => null, })); @@ -127,7 +127,7 @@ mockCreateClient.mockImplementation(() => ({ }, })); -import RootLayout from "./_layout"; +import RootLayout from "../app/_layout"; describe("RootLayout background cleanup", () => { it("tears down background HealthKit sync on unmount", async () => { diff --git a/packages/mobile/app/_layout.telemetry-guard.test.ts b/packages/mobile/app-tests/_layout.telemetry-guard.test.ts similarity index 92% rename from packages/mobile/app/_layout.telemetry-guard.test.ts rename to packages/mobile/app-tests/_layout.telemetry-guard.test.ts index 69d0635b73..dfcb3e81de 100644 --- a/packages/mobile/app/_layout.telemetry-guard.test.ts +++ b/packages/mobile/app-tests/_layout.telemetry-guard.test.ts @@ -20,7 +20,7 @@ describe("app bootstrap telemetry guard", () => { logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); - await expect(import("./_layout")).resolves.toBeDefined(); + await expect(import("../app/_layout")).resolves.toBeDefined(); expect(captureExceptionMock).toHaveBeenCalledWith(expect.any(Error), { source: "bootstrap-telemetry-init", }); diff --git a/packages/mobile/app/_layout.test.ts b/packages/mobile/app-tests/_layout.test.ts similarity index 92% rename from packages/mobile/app/_layout.test.ts rename to packages/mobile/app-tests/_layout.test.ts index 68133b16ee..e0294b043d 100644 --- a/packages/mobile/app/_layout.test.ts +++ b/packages/mobile/app-tests/_layout.test.ts @@ -12,7 +12,7 @@ vi.mock("../lib/telemetry", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); -import { rootStackScreenOptions } from "./_layout"; +import { rootStackScreenOptions } from "../app/_layout"; describe("rootStackScreenOptions", () => { it("uses a minimal back button so route-group names are never shown", () => { diff --git a/packages/mobile/app/activity/[id].test.tsx b/packages/mobile/app-tests/activity/[id].test.tsx similarity index 94% rename from packages/mobile/app/activity/[id].test.tsx rename to packages/mobile/app-tests/activity/[id].test.tsx index a520a71006..f37916e2b2 100644 --- a/packages/mobile/app/activity/[id].test.tsx +++ b/packages/mobile/app-tests/activity/[id].test.tsx @@ -77,7 +77,7 @@ vi.mock("../../components/RouteMap", () => ({ RouteMap: () => null, })); -vi.mock("./useChartScrub", () => ({ +vi.mock("../../app/activity/useChartScrub", () => ({ useChartScrub: () => ({ touchIndex: null, panResponder: { panHandlers: {} }, @@ -220,13 +220,13 @@ beforeEach(() => { describe("ActivityDetailScreen", () => { it("renders without crashing when stream has heart rate and power data", async () => { - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Morning Ride")).toBeTruthy(); }); it("renders heart rate and power chart labels for cycling with stream data", async () => { - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Heart Rate")).toBeTruthy(); expect(screen.getByText("Power")).toBeTruthy(); @@ -243,7 +243,7 @@ describe("ActivityDetailScreen", () => { isLoading: false, }); - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Zone 1")).toBeTruthy(); @@ -263,7 +263,7 @@ describe("ActivityDetailScreen", () => { isLoading: false, }); - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Zone 1")).toBeTruthy(); @@ -292,7 +292,7 @@ describe("ActivityDetailScreen", () => { isLoading: false, }); - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText("Yoga Session")).toBeTruthy(); expect(screen.getByText("Heart Rate")).toBeTruthy(); @@ -312,7 +312,7 @@ describe("ActivityDetailScreen", () => { error: null, }); - const { default: ActivityDetailScreen } = await import("./[id]"); + const { default: ActivityDetailScreen } = await import("../../app/activity/[id]"); render(React.createElement(ActivityDetailScreen)); expect(screen.getByText(/Strong \(via Apple Health\)/)).toBeTruthy(); diff --git a/packages/mobile/app/daily-heart-rate.test.tsx b/packages/mobile/app-tests/daily-heart-rate.test.tsx similarity index 95% rename from packages/mobile/app/daily-heart-rate.test.tsx rename to packages/mobile/app-tests/daily-heart-rate.test.tsx index 4caa16ce58..5bd5516695 100644 --- a/packages/mobile/app/daily-heart-rate.test.tsx +++ b/packages/mobile/app-tests/daily-heart-rate.test.tsx @@ -72,13 +72,13 @@ vi.mock("../theme", () => ({ }, })); -vi.mock("./_layout", () => ({ +vi.mock("../app/_layout", () => ({ rootStackScreenOptions: {}, })); describe("DailyHeartRateScreen", () => { it("renders with date navigator and empty state", async () => { - const { default: DailyHeartRateScreen } = await import("./daily-heart-rate"); + const { default: DailyHeartRateScreen } = await import("../app/daily-heart-rate"); render(); diff --git a/packages/mobile/app/heart-rate-visualization.test.tsx b/packages/mobile/app-tests/heart-rate-visualization.test.tsx similarity index 95% rename from packages/mobile/app/heart-rate-visualization.test.tsx rename to packages/mobile/app-tests/heart-rate-visualization.test.tsx index 727f48d16d..bad183cffe 100644 --- a/packages/mobile/app/heart-rate-visualization.test.tsx +++ b/packages/mobile/app-tests/heart-rate-visualization.test.tsx @@ -73,13 +73,15 @@ vi.mock("../theme", () => ({ }, })); -vi.mock("./_layout", () => ({ +vi.mock("../app/_layout", () => ({ rootStackScreenOptions: {}, })); describe("HeartRateVisualizationScreen", () => { it("renders initial state and auto-connects", async () => { - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); render(); @@ -91,7 +93,9 @@ describe("HeartRateVisualizationScreen", () => { }); it("shows connecting placeholder before data arrives", async () => { - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); render(); @@ -102,7 +106,9 @@ describe("HeartRateVisualizationScreen", () => { const whoopBle = await import("../modules/whoop-ble"); vi.spyOn(whoopBle, "getConnectionState").mockReturnValue("streaming"); - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); render(); @@ -139,7 +145,9 @@ describe("HeartRateVisualizationScreen", () => { ]; const peekSpy = vi.spyOn(whoopBle, "peekBufferedRealtimeData").mockResolvedValue(samples); - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); await act(() => render()); // Flush the async ensureConnected() so startPolling() runs @@ -190,7 +198,9 @@ describe("HeartRateVisualizationScreen", () => { }, ]); - const { default: HeartRateVisualizationScreen } = await import("./heart-rate-visualization"); + const { default: HeartRateVisualizationScreen } = await import( + "../app/heart-rate-visualization" + ); await act(() => render()); await act(() => vi.advanceTimersByTimeAsync(0)); await act(() => vi.advanceTimersByTimeAsync(1000)); diff --git a/packages/mobile/app/inertial-measurement-unit.test.tsx b/packages/mobile/app-tests/inertial-measurement-unit.test.tsx similarity index 95% rename from packages/mobile/app/inertial-measurement-unit.test.tsx rename to packages/mobile/app-tests/inertial-measurement-unit.test.tsx index 6685ecaba7..1e380a2c36 100644 --- a/packages/mobile/app/inertial-measurement-unit.test.tsx +++ b/packages/mobile/app-tests/inertial-measurement-unit.test.tsx @@ -141,7 +141,7 @@ vi.mock("../theme", () => ({ }, })); -vi.mock("./_layout", () => ({ +vi.mock("../app/_layout", () => ({ rootStackScreenOptions: {}, })); @@ -159,7 +159,7 @@ describe("InertialMeasurementUnitScreen", () => { it("updates permission status when app returns to foreground", async () => { // Start with notDetermined const { unmount } = render( - React.createElement((await import("./inertial-measurement-unit")).default), + React.createElement((await import("../app/inertial-measurement-unit")).default), ); expect(screen.getByText("notDetermined")).toBeTruthy(); @@ -179,7 +179,7 @@ describe("InertialMeasurementUnitScreen", () => { it("requests permission on mount when status is notDetermined", async () => { const { unmount } = render( - React.createElement((await import("./inertial-measurement-unit")).default), + React.createElement((await import("../app/inertial-measurement-unit")).default), ); expect(mockRequestMotionPermission).toHaveBeenCalled(); @@ -192,7 +192,7 @@ describe("InertialMeasurementUnitScreen", () => { mockGetConnectionState.mockReturnValue("scanning"); const { unmount } = render( - React.createElement((await import("./inertial-measurement-unit")).default), + React.createElement((await import("../app/inertial-measurement-unit")).default), ); // Warning should appear in both the error banner and the inline warning @@ -217,7 +217,7 @@ describe("InertialMeasurementUnitScreen", () => { }); const { unmount } = render( - React.createElement((await import("./inertial-measurement-unit")).default), + React.createElement((await import("../app/inertial-measurement-unit")).default), ); // Watch shows "No" for Paired and App Installed diff --git a/packages/mobile/app/login.test.tsx b/packages/mobile/app-tests/login.test.tsx similarity index 99% rename from packages/mobile/app/login.test.tsx rename to packages/mobile/app-tests/login.test.tsx index ea341528ce..9842980df5 100644 --- a/packages/mobile/app/login.test.tsx +++ b/packages/mobile/app-tests/login.test.tsx @@ -37,7 +37,7 @@ vi.mock("../components/ProviderLogo", () => ({ ProviderLogo: () => null, })); -const { default: LoginScreen } = await import("./login"); +const { default: LoginScreen } = await import("../app/login"); describe("LoginScreen", () => { beforeEach(() => { diff --git a/packages/mobile/app/preview.test.tsx b/packages/mobile/app-tests/preview.test.tsx similarity index 86% rename from packages/mobile/app/preview.test.tsx rename to packages/mobile/app-tests/preview.test.tsx index 336a63cd30..12729fbf70 100644 --- a/packages/mobile/app/preview.test.tsx +++ b/packages/mobile/app-tests/preview.test.tsx @@ -33,7 +33,7 @@ describe("PreviewScreen", () => { it("shows loading state with PR number", async () => { mockCheckAndApply.mockImplementation(() => new Promise(() => {})); - const { default: PreviewScreen } = await import("./preview"); + const { default: PreviewScreen } = await import("../app/preview"); render(); @@ -43,7 +43,7 @@ describe("PreviewScreen", () => { it("triggers update check on mount", async () => { mockCheckAndApply.mockResolvedValue({ status: "reloading" }); - const { default: PreviewScreen } = await import("./preview"); + const { default: PreviewScreen } = await import("../app/preview"); render(); @@ -58,7 +58,7 @@ describe("PreviewScreen", () => { message: "Network error", }); - const { default: PreviewScreen } = await import("./preview"); + const { default: PreviewScreen } = await import("../app/preview"); render(); @@ -70,7 +70,7 @@ describe("PreviewScreen", () => { it("shows no-update message when already up to date", async () => { mockCheckAndApply.mockResolvedValue({ status: "no-update" }); - const { default: PreviewScreen } = await import("./preview"); + const { default: PreviewScreen } = await import("../app/preview"); render(); diff --git a/packages/mobile/app/providers/[id].test.tsx b/packages/mobile/app-tests/providers/[id].test.tsx similarity index 91% rename from packages/mobile/app/providers/[id].test.tsx rename to packages/mobile/app-tests/providers/[id].test.tsx index 5532332c5c..c0d2963353 100644 --- a/packages/mobile/app/providers/[id].test.tsx +++ b/packages/mobile/app-tests/providers/[id].test.tsx @@ -304,7 +304,7 @@ describe("ProviderDetailScreen", () => { describe("Actions", () => { it("renders Sync and Full sync actions for connected providers", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Sync")).toBeTruthy(); @@ -316,7 +316,7 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "strava" }); mockProvidersQuery.mockReturnValue({ data: [unauthorizedProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Connect")).toBeTruthy(); @@ -328,7 +328,7 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "strong-csv" }); mockProvidersQuery.mockReturnValue({ data: [importOnlyProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.queryByText("Connect")).toBeNull(); @@ -342,7 +342,7 @@ describe("ProviderDetailScreen", () => { isLoading: false, }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Re-authorize")).toBeTruthy(); @@ -356,7 +356,7 @@ describe("ProviderDetailScreen", () => { providers: { wahoo: { status: "done", message: "Done" } }, }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Sync")); @@ -377,7 +377,7 @@ describe("ProviderDetailScreen", () => { providers: { wahoo: { status: "done", message: "Done" } }, }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Full sync")); @@ -394,7 +394,7 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "strava" }); mockProvidersQuery.mockReturnValue({ data: [unauthorizedProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -416,7 +416,7 @@ describe("ProviderDetailScreen", () => { mockProviderStatsQuery.mockReturnValue({ data: [appleHealthStats], isLoading: false }); mockSyncHealthKit.mockResolvedValue({ inserted: 12, errors: [] }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -438,7 +438,7 @@ describe("ProviderDetailScreen", () => { mockProviderStatsQuery.mockReturnValue({ data: [appleHealthStats], isLoading: false }); mockSyncHealthKit.mockResolvedValue({ inserted: 12, errors: [] }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -460,7 +460,7 @@ describe("ProviderDetailScreen", () => { mockProviderStatsQuery.mockReturnValue({ data: [appleHealthStats], isLoading: false }); mockHasEverAuthorized.mockReturnValue(false); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -475,7 +475,7 @@ describe("ProviderDetailScreen", () => { mockProviderStatsQuery.mockReturnValue({ data: [appleHealthStats], isLoading: false }); mockHasEverAuthorized.mockReturnValue(false); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); await waitFor(() => { @@ -492,7 +492,7 @@ describe("ProviderDetailScreen", () => { describe("Disconnect", () => { it("renders disconnect button when provider is authorized", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Disconnect Provider")).toBeTruthy(); @@ -502,14 +502,14 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "strava" }); mockProvidersQuery.mockReturnValue({ data: [unauthorizedProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.queryByText("Disconnect Provider")).toBeNull(); }); it("shows Alert.alert with correct title when disconnect button is clicked", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Disconnect Provider")); @@ -527,7 +527,7 @@ describe("ProviderDetailScreen", () => { it("calls disconnect mutation and navigates back when confirmed", async () => { mockDisconnectMutateAsync.mockResolvedValue({}); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Disconnect Provider")); @@ -554,7 +554,7 @@ describe("ProviderDetailScreen", () => { it("invalidates providers and providerStats after successful disconnect", async () => { mockDisconnectMutateAsync.mockResolvedValue({}); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Disconnect Provider")); @@ -602,7 +602,7 @@ describe("ProviderDetailScreen", () => { }); it("renders wear location picker when providerId is whoop", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Wear Location")).toBeTruthy(); @@ -612,7 +612,7 @@ describe("ProviderDetailScreen", () => { }); it("renders all five wear location options", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.getByText("Wrist")).toBeTruthy(); @@ -626,14 +626,14 @@ describe("ProviderDetailScreen", () => { mockUseLocalSearchParams.mockReturnValue({ id: "wahoo" }); mockProvidersQuery.mockReturnValue({ data: [authorizedProvider], isLoading: false }); - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); expect(screen.queryByText("Wear Location")).toBeNull(); }); it("calls the settings mutation when a location is clicked", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Bicep / Upper Arm")); @@ -645,7 +645,7 @@ describe("ProviderDetailScreen", () => { }); it("optimistically updates the cache when a location is clicked", async () => { - const { default: ProviderDetailScreen } = await import("./[id]"); + const { default: ProviderDetailScreen } = await import("../../app/providers/[id]"); render(); fireEvent.click(screen.getByText("Chest / Torso")); diff --git a/packages/mobile/app/providers/index.test.tsx b/packages/mobile/app-tests/providers/index.test.tsx similarity index 90% rename from packages/mobile/app/providers/index.test.tsx rename to packages/mobile/app-tests/providers/index.test.tsx index 2502fe60e9..2c8c38cf2f 100644 --- a/packages/mobile/app/providers/index.test.tsx +++ b/packages/mobile/app-tests/providers/index.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { providerActionLabel } from "./provider-card.tsx"; +import { providerActionLabel } from "../../app/providers/provider-card.tsx"; const mockPush = vi.fn(); const mockReplace = vi.fn(); @@ -357,7 +357,7 @@ describe("providerActionLabel", () => { describe("ProviderCard", () => { describe("sync progress", () => { it("renders progress bar when syncing with percentage", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders progress message without percentage", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders progress bar without message when only percentage is provided", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { describe("normal metadata when not syncing", () => { it("renders auth status and last sync time when not syncing", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders 'Never synced' when provider has no lastSyncAt", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders normal metadata when syncing but syncProgress is undefined", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders 'Not connected' status for disconnected providers", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders 'Expired' status for expired providers", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { describe("progress percentage clamping", () => { it("renders without error when percentage is negative", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders without error when percentage exceeds 100", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders provider label", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { describe("import-only providers", () => { it("does not render Sync button for import-only providers", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("does not render Full sync link for import-only providers", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("shows 'Import only' instead of connection status", async () => { - const { ProviderCard } = await import("./provider-card.tsx"); + const { ProviderCard } = await import("../../app/providers/provider-card.tsx"); render( { }); it("renders Full sync link for connected providers", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -675,7 +675,7 @@ describe("ProvidersScreen", () => { error: null, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -690,7 +690,7 @@ describe("ProvidersScreen", () => { error: null, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const stravaCard = within(screen.getByTestId("provider-card-strava")); @@ -698,7 +698,7 @@ describe("ProvidersScreen", () => { }); it("renders Full Sync All button alongside Sync All", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); expect(screen.getByText("Sync All")).toBeTruthy(); @@ -712,7 +712,7 @@ describe("ProvidersScreen", () => { error: new Error("Providers failed"), }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); expect(screen.getByText("Providers failed")).toBeTruthy(); @@ -725,7 +725,7 @@ describe("ProvidersScreen", () => { error: new Error("Logs failed"), }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); expect(screen.getByText("Logs failed")).toBeTruthy(); @@ -738,7 +738,7 @@ describe("ProvidersScreen", () => { providers: { wahoo: { status: "done" } }, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const wahooCard = within(screen.getByTestId("provider-card-wahoo")); @@ -759,7 +759,7 @@ describe("ProvidersScreen", () => { providers: { wahoo: { status: "done" } }, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const wahooCard = within(screen.getByTestId("provider-card-wahoo")); @@ -780,7 +780,7 @@ describe("ProvidersScreen", () => { providers: { wahoo: { status: "done" } }, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); fireEvent.click(screen.getByText("Sync All")); @@ -797,7 +797,7 @@ describe("ProvidersScreen", () => { providers: { wahoo: { status: "done" } }, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); fireEvent.click(screen.getByText("Full Sync All")); @@ -813,7 +813,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const eightSleepCard = within(screen.getByTestId("provider-card-eight-sleep")); @@ -831,7 +831,7 @@ describe("ProvidersScreen", () => { }); mockCredentialSignIn.mockResolvedValue({}); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Open the modal @@ -863,7 +863,7 @@ describe("ProvidersScreen", () => { sharedFile: "file:///tmp/Strong%20Export.csv", }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -892,7 +892,7 @@ describe("ProvidersScreen", () => { sharedFile: "file:///tmp/Strong%20Export.csv", }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -912,7 +912,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const strongCard = within(screen.getByTestId("provider-card-strong-csv")); @@ -928,7 +928,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Sync All button should not appear when only import-only providers exist @@ -941,7 +941,7 @@ describe("ProvidersScreen", () => { sharedFile: "file:///tmp/strong.csv", }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -981,7 +981,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const polarCard = within(screen.getByTestId("provider-card-polar")); @@ -996,7 +996,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const stravaCard = within(screen.getByTestId("provider-card-strava")); @@ -1023,7 +1023,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const garminCard = within(screen.getByTestId("provider-card-garmin")); @@ -1049,7 +1049,7 @@ describe("ProvidersScreen", () => { }); mockGarminSignIn.mockResolvedValue({ success: true }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const garminCard = within(screen.getByTestId("provider-card-garmin")); @@ -1086,7 +1086,7 @@ describe("ProvidersScreen", () => { isLoading: false, }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const whoopCard = within(screen.getByTestId("provider-card-whoop")); @@ -1116,7 +1116,7 @@ describe("ProvidersScreen", () => { }); mockWhoopSaveTokens.mockResolvedValue({ success: true }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const whoopCard = within(screen.getByTestId("provider-card-whoop")); @@ -1166,7 +1166,7 @@ describe("ProvidersScreen", () => { }); mockWhoopSaveTokens.mockResolvedValue({ success: true }); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Open modal and sign in @@ -1209,7 +1209,7 @@ describe("ProvidersScreen", () => { mockIsHealthKitAvailable.mockReturnValue(false); mockHasEverAuthorized.mockReturnValue(false); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); const appleCard = within(screen.getByTestId("provider-card-apple_health")); @@ -1219,7 +1219,7 @@ describe("ProvidersScreen", () => { }); it("renders Apple Health card when HealthKit is available", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); expect(screen.getByTestId("provider-card-apple_health")).toBeTruthy(); @@ -1227,7 +1227,7 @@ describe("ProvidersScreen", () => { }); it("triggers HealthKit sync with syncRangeDays: 7 when Sync is clicked", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Wait for async permission check to resolve (connected state) @@ -1245,7 +1245,7 @@ describe("ProvidersScreen", () => { }); it("triggers HealthKit sync with syncRangeDays: null when Full sync is clicked", async () => { - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); // Wait for async permission check to resolve (connected state) @@ -1267,7 +1267,7 @@ describe("ProvidersScreen", () => { it("shows Connect button when HealthKit was never authorized", async () => { mockHasEverAuthorized.mockReturnValue(false); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -1279,7 +1279,7 @@ describe("ProvidersScreen", () => { it("calls requestPermissions when Connect is clicked on Apple Health", async () => { mockHasEverAuthorized.mockReturnValue(false); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -1304,7 +1304,7 @@ describe("ProvidersScreen", () => { mockHasEverAuthorized.mockReturnValue(false); mockRequestPermissions.mockRejectedValue(connectError); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -1326,7 +1326,7 @@ describe("ProvidersScreen", () => { mockHasEverAuthorized.mockReturnValue(false); mockRequestPermissions.mockRejectedValue(new Error("Authorization denied")); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { @@ -1352,7 +1352,7 @@ describe("ProvidersScreen", () => { new Error("Missing com.apple.developer.healthkit entitlement."), ); - const { default: ProvidersScreen } = await import("./index"); + const { default: ProvidersScreen } = await import("../../app/providers/index"); render(); await waitFor(() => { diff --git a/packages/mobile/app/settings.test.tsx b/packages/mobile/app-tests/settings.test.tsx similarity index 92% rename from packages/mobile/app/settings.test.tsx rename to packages/mobile/app-tests/settings.test.tsx index 32cea668fd..b5b715fd39 100644 --- a/packages/mobile/app/settings.test.tsx +++ b/packages/mobile/app-tests/settings.test.tsx @@ -157,7 +157,7 @@ beforeEach(() => { describe("SettingsScreen data sources", () => { it("renders Data Sources section with connected count", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -166,7 +166,7 @@ describe("SettingsScreen data sources", () => { }); it("renders provider logos for connected providers only", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -176,7 +176,7 @@ describe("SettingsScreen data sources", () => { }); it("navigates to providers screen when tapped", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -188,7 +188,7 @@ describe("SettingsScreen data sources", () => { describe("SettingsScreen billing", () => { it("renders signup-week limited access notice", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -198,7 +198,7 @@ describe("SettingsScreen billing", () => { }); it("starts checkout when the upgrade button is pressed", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -219,7 +219,7 @@ describe("SettingsScreen billing", () => { canManageBilling: true, }; - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -232,7 +232,7 @@ describe("SettingsScreen billing", () => { describe("SettingsScreen export UI rendering", () => { it("renders the Start Export button", async () => { - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -245,7 +245,7 @@ describe("SettingsScreen export UI rendering", () => { vi.fn().mockImplementation(() => new Promise(() => {})), ); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -266,7 +266,7 @@ describe("SettingsScreen OTA debug details", () => { const otaCreatedAt = new Date("2026-03-31T18:22:00.000Z"); updatesModule.createdAt = otaCreatedAt; - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -312,7 +312,7 @@ describe("SettingsScreen export flow", () => { vi.stubGlobal("fetch", mockFetch); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -355,7 +355,7 @@ describe("SettingsScreen export flow", () => { vi.stubGlobal("fetch", mockFetch); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -382,7 +382,7 @@ describe("SettingsScreen export flow", () => { }), ); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); @@ -433,7 +433,7 @@ describe("SettingsScreen export flow", () => { vi.stubGlobal("fetch", mockFetch); - const { default: SettingsScreen } = await import("./settings"); + const { default: SettingsScreen } = await import("../app/settings"); render(); diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 05062c166f..adf9d19a3e 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -12,7 +12,7 @@ "ios": "expo run:ios", "ios:device": "expo run:ios --device", "prebuild": "expo prebuild --platform ios --clean", - "lint": "biome check .", + "lint": "biome check . && cd ../.. && tsx scripts/check-mobile-app-route-files.ts", "lint:fix": "biome check --write ." }, "dependencies": { diff --git a/scripts/check-mobile-app-route-files.ts b/scripts/check-mobile-app-route-files.ts new file mode 100644 index 0000000000..b043fb2e79 --- /dev/null +++ b/scripts/check-mobile-app-route-files.ts @@ -0,0 +1,39 @@ +import { readdirSync } from "node:fs"; +import path from "node:path"; + +const appDirectory = path.resolve("packages/mobile/app"); +const forbiddenRouteFilePattern = /\.(test|stories)\.[jt]sx?$/; + +function findForbiddenFiles(directory: string): string[] { + const entries = readdirSync(directory, { withFileTypes: true }); + const forbiddenFiles: string[] = []; + + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + forbiddenFiles.push(...findForbiddenFiles(absolutePath)); + continue; + } + + if (entry.isFile() && forbiddenRouteFilePattern.test(entry.name)) { + forbiddenFiles.push(path.relative(process.cwd(), absolutePath)); + } + } + + return forbiddenFiles.sort(); +} + +const forbiddenFiles = findForbiddenFiles(appDirectory); + +if (forbiddenFiles.length > 0) { + console.error("Expo Router app directory contains non-route test/story files:"); + for (const file of forbiddenFiles) { + console.error(`- ${file}`); + } + console.error(""); + console.error( + "Move route tests to packages/mobile/app-tests/ and route stories to packages/mobile/app-stories/.", + ); + process.exit(1); +} From 9ebd8c73872e1d2d0ab2edc7ef402ead6e6ba4e4 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 15:38:36 -0700 Subject: [PATCH 3/5] fix: tighten mobile route hygiene guard --- AGENTS.md | 4 ++-- package.json | 2 +- packages/mobile/AGENTS.md | 2 +- packages/mobile/README.md | 3 ++- .../(tabs)/fixture-dates.ts} | 0 .../(tabs)/food-story-fixture.ts} | 0 .../(tabs)/processing-status-story-fixture.ts} | 0 packages/mobile/app-stories/(tabs)/food.stories.tsx | 2 +- packages/mobile/app-stories/(tabs)/index.stories.tsx | 4 ++-- .../mobile/app-stories/(tabs)/recovery.stories.tsx | 6 +++--- .../mobile/app-stories/(tabs)/strain.stories.tsx | 6 +++--- .../mobile/app-tests/(tabs)/_fixture-dates.test.ts | 2 +- .../app-tests/(tabs)/_food-story-fixture.test.ts | 2 +- .../(tabs)/_processing-status-story-fixture.test.ts | 2 +- scripts/check-mobile-app-route-files.ts | 12 ++++++++---- 15 files changed, 26 insertions(+), 21 deletions(-) rename packages/mobile/{app/(tabs)/_fixture-dates.ts => app-fixtures/(tabs)/fixture-dates.ts} (100%) rename packages/mobile/{app/(tabs)/_food-story-fixture.ts => app-fixtures/(tabs)/food-story-fixture.ts} (100%) rename packages/mobile/{app/(tabs)/_processing-status-story-fixture.ts => app-fixtures/(tabs)/processing-status-story-fixture.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index c35b6fd7c9..63c7832d9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ Provider-agnostic fitness/health data pipeline. Syncs data from various provider - **Server-side metric computation**: All metric values must be computed on the server — never derive, aggregate, or transform metric data in web or iOS client code. The API response should contain every value the UI needs to display. Clients are responsible only for rendering (colors, labels, formatting, layout) — not for computing the numbers they display. This prevents inconsistencies when the same metric appears on multiple screens or platforms. If a client is calling a scoring/calculation function on raw data from the API, that calculation belongs in the server router instead. - **Good architecture and modeling**: Actively look for opportunities to decouple code, model real-world concepts as proper classes/types, use common interfaces, and apply SOLID principles with domain-driven design. When you see scattered logic that represents a single concept (e.g., "is this provider connected?"), extract it into a model or interface rather than leaving it inline. Prefer domain-driven abstractions over ad-hoc checks spread across the codebase. Follow SOLID principles: single responsibility (each class/module does one thing), open/closed (extend via composition, not modification), Liskov substitution (subtypes must be substitutable), interface segregation (small, focused interfaces), and dependency inversion (depend on abstractions, not concretions). Prefer composition over inheritance — build complex behavior by combining simple, focused components rather than deep class hierarchies. Use dependency injection, strategy patterns, and mixins instead of base classes. - **Dual-platform parity (web + mobile)**: Every feature, bug fix, and UI change must be implemented on both `packages/web` and `packages/mobile`. When adding a new page, chart, or data view to one platform, implement the equivalent on the other in the same PR. Shared logic lives in domain-specific packages (`@dofek/format`, `@dofek/scoring`, `@dofek/nutrition`, `@dofek/training`, `@dofek/stats`, `@dofek/onboarding`, `@dofek/providers`) — import from there instead of duplicating. Platform-specific code (HealthKit, barcode scanning, Expo secure storage, ECharts vs react-native-svg) stays in the respective package. When reviewing PRs, check that both platforms are updated. -- **Mobile Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router treats files in `app/` as route candidates, which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/` and route stories under `packages/mobile/app-stories/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. +- **Mobile Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router's `app` directory is exclusively for routes and attempts to treat non-route files there as routes ([Expo Router core concepts](https://docs.expo.dev/router/basics/core-concepts/#6-non-navigation-components-live-outside-the-srcapp-directory)), which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/`, route stories under `packages/mobile/app-stories/`, and shared route fixtures under `packages/mobile/app-fixtures/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. - **Always report errors to Sentry**: Never silently swallow errors or only log them. Every `catch` block that handles an unexpected error must call `captureException()` (from `./telemetry` in mobile, or the equivalent in server code) so failures are visible in our error monitoring. Silent `catch(() => {})` blocks are banned — they hide bugs and make debugging impossible. - **Surface errors to the user by default**: When a server error occurs, send a specific, actionable error message to the client — never hide it behind a generic "Something went wrong" or "Failed to load." Use a TRPCError with an appropriate code (e.g., `PRECONDITION_FAILED`, `NOT_FOUND`) and a human-readable message that tells the user what's wrong and what to do. Clients must display `error.message` from the server, not hardcoded strings. Hiding the real error from the user makes debugging slower and generates support requests that could be self-service. - **Fail fast, never warn-and-continue**: When a required precondition is missing (env file, config, dependency), fail immediately with a clear error — never log a warning and silently continue with broken state. A deploy that proceeds with an empty `.env.prod` is worse than one that fails loudly. Warnings that don't stop execution are deceptive; they hide the real problem and cause confusing downstream failures. @@ -80,7 +80,7 @@ Provider-agnostic fitness/health data pipeline. Syncs data from various provider - **No exports just for testability**: Never export a function, class, or variable solely because a test needs to access it. Exports define the public API of a module — every export should serve a production consumer. If a test needs to verify internal behavior, test it through the public interface instead. Comments like "exported for testing" are a code smell — if it's worth exporting, it's worth exporting for production use too. - **No test/optional-only branches**: Never write a code branch that is only reachable in tests. If a dependency is always present in production, model it as a required (non-optional) dependency rather than an optional one guarded with `if (!dep) return []` — that guard is dead in production and silently hides infra failures (e.g. returning empty data instead of erroring when ClickHouse is down). Make required deps non-optional (drop the `?`, make constructor/`createApp` params required) and have test helpers always provide a stub (e.g. `makeMockSensorStore`). If a precondition genuinely can fail at runtime, fail loudly with a specific error — never return a benign empty result. - **Shared test utilities**: When multiple unit or integration tests need to share mock setups, utility functions, or test data, extract them into a local `test-helpers.ts` file within the same directory. Do not export these helpers from the source file being tested or import them from another `*.test.ts` file. -- **Colocated unit tests**: Unit test files live next to the source file they test, named `.test.ts`. Do not use `__tests__/` directories. For example, `src/db/tokens.ts` has its unit test at `src/db/tokens.test.ts`. Integration tests (`*.integration.test.ts`) can live wherever makes sense. +- **Colocated unit tests**: Unit test files live next to the source file they test, named `.test.ts`. Do not use `__tests__/` directories. For example, `src/db/tokens.ts` has its unit test at `src/db/tokens.test.ts`. Integration tests (`*.integration.test.ts`) can live wherever makes sense. Mobile Expo Router route tests under `packages/mobile/app-tests/**` are the scoped exception because files under `packages/mobile/app/` are route candidates. - **Test files map 1:1 to source files**: A unit test file should test one source file. If a test file grows too large because it covers several responsibilities, split the production source into smaller SOLID modules/classes and give each source file its own focused colocated test file. Do not split tests by arbitrary scenario while leaving the source file monolithic. - **Test separation**: Unit tests use `*.test.ts`, integration tests use `*.integration.test.ts`. Unit tests must never need access to external services (databases, APIs). Integration tests must never mock at the module level (`vi.mock`). For 3rd party services in integration tests, mock at the network level with [MSW](https://mswjs.io/) (`setupServer` from `msw/node`), not with constructor-injected fetch or `vi.spyOn(globalThis, 'fetch')`. - **Use explicit test tiers**: `pnpm test`, `pnpm test:changed`, and `pnpm test:coverage` are Docker-free unit/mobile tiers. Run database-backed tests through `pnpm test:integration`, `pnpm test:all`, `pnpm test:changed:all`, or `pnpm test:coverage:all`; these commands start the current workspace's Compose dependencies and provide `TEST_DATABASE_URL`. Keep CI's unit, mobile, integration-shard, and mutation commands explicit. See [`docs/testing.md`](docs/testing.md#integration-dependencies) and [Vitest test projects](https://vitest.dev/guide/projects). diff --git a/package.json b/package.json index 0ba957e85f..1e16498ff1 100644 --- a/package.json +++ b/package.json @@ -214,7 +214,7 @@ "depcruise": "depcruise --config .dependency-cruiser.cjs src/ packages/", "spellcheck": "cspell --no-progress", "check:mobile-update": "tsx scripts/check-ota-manifest.ts", - "check:mobile-app-routes": "tsx scripts/check-mobile-app-route-files.ts" + "check:mobile-app-routes": "pnpm tsx scripts/check-mobile-app-route-files.ts" }, "dependencies": { "@ai-sdk/otel": "1.0.47", diff --git a/packages/mobile/AGENTS.md b/packages/mobile/AGENTS.md index ec8780e42e..745d2c3648 100644 --- a/packages/mobile/AGENTS.md +++ b/packages/mobile/AGENTS.md @@ -13,7 +13,7 @@ - **Storybook**: Every component MUST have a `.stories.tsx` file (lives in `.storybook` and `.rnstorybook`). - **Charts**: Use `react-native-svg` for all chart implementations. - **Navigation**: Uses Expo Router. Screen paths map to `app/`. -- **Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router treats files in `app/` as route candidates, which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/` and route stories under `packages/mobile/app-stories/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. +- **Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router's `app` directory is exclusively for routes and attempts to treat non-route files there as routes ([Expo Router core concepts](https://docs.expo.dev/router/basics/core-concepts/#6-non-navigation-components-live-outside-the-srcapp-directory)), which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/`, route stories under `packages/mobile/app-stories/`, and shared route fixtures under `packages/mobile/app-fixtures/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. - **Query state handling**: Treat loading, error, and empty as separate UI states. Do not use `query.data ?? []` or similar fallbacks when `query.error` exists. Use `components/QueryStatePanel.tsx` for explicit error/empty/loading states on screens and cards. - **Loading performance**: Follow `../../docs/performance/loading-performance-runbook.md` for slow screens. Do not blank visible previous/cached data during background refetches; use blocking loading only when no usable data exists, preserve server error messages, and keep sync/refresh invalidation targeted to affected query families. diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 16c752c609..ef1a8e9902 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -16,9 +16,10 @@ See `../../docs/nutrition-ai-input.md` for end-to-end behavior and API flow. ## Project Structure -- `app/`: Expo Router screens (file-based routing). Keep this route-only; tests and stories in `app/` can become accidental screens. +- `app/`: Expo Router screens (file-based routing). Keep this route-only; Expo documents `app` as route-exclusive and non-route files there can be treated as routes: . - `app-tests/`: Vitest tests for Expo Router screens. - `app-stories/`: Storybook stories for Expo Router screens. +- `app-fixtures/`: Shared fixtures for route tests and route stories. - `components/`: React Native UI components (SVG-based charts). - `modules/`: Native Swift modules: - `background-refresh`: iOS background task registration. diff --git a/packages/mobile/app/(tabs)/_fixture-dates.ts b/packages/mobile/app-fixtures/(tabs)/fixture-dates.ts similarity index 100% rename from packages/mobile/app/(tabs)/_fixture-dates.ts rename to packages/mobile/app-fixtures/(tabs)/fixture-dates.ts diff --git a/packages/mobile/app/(tabs)/_food-story-fixture.ts b/packages/mobile/app-fixtures/(tabs)/food-story-fixture.ts similarity index 100% rename from packages/mobile/app/(tabs)/_food-story-fixture.ts rename to packages/mobile/app-fixtures/(tabs)/food-story-fixture.ts diff --git a/packages/mobile/app/(tabs)/_processing-status-story-fixture.ts b/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts similarity index 100% rename from packages/mobile/app/(tabs)/_processing-status-story-fixture.ts rename to packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.ts diff --git a/packages/mobile/app-stories/(tabs)/food.stories.tsx b/packages/mobile/app-stories/(tabs)/food.stories.tsx index 23abb92862..158bef393e 100644 --- a/packages/mobile/app-stories/(tabs)/food.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/food.stories.tsx @@ -3,8 +3,8 @@ import type { Meta, StoryObj } from "@storybook/react-native"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink } from "@trpc/client"; import { View } from "react-native"; -import { seedFoodStoryQuery } from "../../app/(tabs)/_food-story-fixture"; import FoodScreen from "../../app/(tabs)/food"; +import { seedFoodStoryQuery } from "../../app-fixtures/(tabs)/food-story-fixture"; import { trpc } from "../../lib/trpc"; import { colors } from "../../theme"; diff --git a/packages/mobile/app-stories/(tabs)/index.stories.tsx b/packages/mobile/app-stories/(tabs)/index.stories.tsx index 62f6e3032f..f9da6190ff 100644 --- a/packages/mobile/app-stories/(tabs)/index.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/index.stories.tsx @@ -5,11 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MISSING_PREVIOUS_NIGHT_MESSAGE } from "dofek-server/sleep-need-contract"; import { type ReactNode, useMemo } from "react"; import { View } from "react-native"; +import TodayScreen from "../../app/(tabs)/index"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app/(tabs)/_processing-status-story-fixture"; -import TodayScreen from "../../app/(tabs)/index"; +} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; import { trpc } from "../../lib/trpc"; import { colors } from "../../theme"; diff --git a/packages/mobile/app-stories/(tabs)/recovery.stories.tsx b/packages/mobile/app-stories/(tabs)/recovery.stories.tsx index c85bf13d3e..cdff69218d 100644 --- a/packages/mobile/app-stories/(tabs)/recovery.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/recovery.stories.tsx @@ -5,12 +5,12 @@ import { mobileRecoveryFixtureSchema } from "dofek-server/mobile-dashboard-contr import type { AppRouter } from "dofek-server/router"; import { useMemo } from "react"; import { View } from "react-native"; -import { createFixtureDates } from "../../app/(tabs)/_fixture-dates"; +import RecoveryScreen from "../../app/(tabs)/recovery"; +import { createFixtureDates } from "../../app-fixtures/(tabs)/fixture-dates"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app/(tabs)/_processing-status-story-fixture"; -import RecoveryScreen from "../../app/(tabs)/recovery"; +} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; import { trpc } from "../../lib/trpc"; import { colors } from "../../theme"; diff --git a/packages/mobile/app-stories/(tabs)/strain.stories.tsx b/packages/mobile/app-stories/(tabs)/strain.stories.tsx index 9f9968c20c..6878e64a05 100644 --- a/packages/mobile/app-stories/(tabs)/strain.stories.tsx +++ b/packages/mobile/app-stories/(tabs)/strain.stories.tsx @@ -5,12 +5,12 @@ import { mobileTrainingFixtureSchema } from "dofek-server/mobile-dashboard-contr import type { AppRouter } from "dofek-server/router"; import { useMemo } from "react"; import { View } from "react-native"; -import { createFixtureDates, type FixtureDates } from "../../app/(tabs)/_fixture-dates"; +import StrainScreen from "../../app/(tabs)/strain"; +import { createFixtureDates, type FixtureDates } from "../../app-fixtures/(tabs)/fixture-dates"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app/(tabs)/_processing-status-story-fixture"; -import StrainScreen from "../../app/(tabs)/strain"; +} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; import { trpc } from "../../lib/trpc"; const STRAIN_COMPANION_RESPONSES = { diff --git a/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts b/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts index 135f569ab7..586a1f08c4 100644 --- a/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts +++ b/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createFixtureDates } from "../../app/(tabs)/_fixture-dates"; +import { createFixtureDates } from "../../app-fixtures/(tabs)/fixture-dates"; describe("createFixtureDates", () => { afterEach(() => { diff --git a/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts b/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts index d5a6d279f2..94b75c9252 100644 --- a/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts +++ b/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts @@ -1,6 +1,6 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; -import { seedFoodStoryQuery } from "../../app/(tabs)/_food-story-fixture"; +import { seedFoodStoryQuery } from "../../app-fixtures/(tabs)/food-story-fixture"; import { FoodByDateV2Schema } from "../../types/api"; describe("seedFoodStoryQuery", () => { diff --git a/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts b/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts index bc6b56ebac..1a42044c34 100644 --- a/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts +++ b/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app/(tabs)/_processing-status-story-fixture"; +} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; describe("seedReadyProcessingStatus", () => { it("seeds the exact processing status query used by screenshot stories", () => { diff --git a/scripts/check-mobile-app-route-files.ts b/scripts/check-mobile-app-route-files.ts index b043fb2e79..7419445875 100644 --- a/scripts/check-mobile-app-route-files.ts +++ b/scripts/check-mobile-app-route-files.ts @@ -2,7 +2,11 @@ import { readdirSync } from "node:fs"; import path from "node:path"; const appDirectory = path.resolve("packages/mobile/app"); -const forbiddenRouteFilePattern = /\.(test|stories)\.[jt]sx?$/; +const forbiddenRouteFilePatterns = [ + /\.(test|stories)\./, + /(^|[._-])fixtures?([._-]|$)/i, + /(^|[._-])helpers?([._-]|$)/i, +]; function findForbiddenFiles(directory: string): string[] { const entries = readdirSync(directory, { withFileTypes: true }); @@ -16,7 +20,7 @@ function findForbiddenFiles(directory: string): string[] { continue; } - if (entry.isFile() && forbiddenRouteFilePattern.test(entry.name)) { + if (entry.isFile() && forbiddenRouteFilePatterns.some((pattern) => pattern.test(entry.name))) { forbiddenFiles.push(path.relative(process.cwd(), absolutePath)); } } @@ -27,13 +31,13 @@ function findForbiddenFiles(directory: string): string[] { const forbiddenFiles = findForbiddenFiles(appDirectory); if (forbiddenFiles.length > 0) { - console.error("Expo Router app directory contains non-route test/story files:"); + console.error("Expo Router app directory contains non-route files:"); for (const file of forbiddenFiles) { console.error(`- ${file}`); } console.error(""); console.error( - "Move route tests to packages/mobile/app-tests/ and route stories to packages/mobile/app-stories/.", + "Move route tests to packages/mobile/app-tests/, route stories to packages/mobile/app-stories/, and shared fixtures to packages/mobile/app-fixtures/.", ); process.exit(1); } From c4ba57d2930f779c991f3864f7d34592050bcb20 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 15:58:18 -0700 Subject: [PATCH 4/5] fix: handle no-patch audit advisories --- docs/production-incident-baseline.md | 29 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 13 +++++++------ pnpm-workspace.yaml | 6 ++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/production-incident-baseline.md b/docs/production-incident-baseline.md index a3def01bc7..e5170c0d30 100644 --- a/docs/production-incident-baseline.md +++ b/docs/production-incident-baseline.md @@ -23202,3 +23202,32 @@ Drizzle schema and runtime Zod schemas. Findings and remediations: - **Fix / mitigation:** Updated the nine Expo packages to the SDK-compatible patch versions and regenerated the lockfile. Added a scoped `@expo/xcpretty>js-yaml` override to `4.3.1`, leaving unrelated `js-yaml` 3.x and 4.1.x tooling paths unchanged. No audit suppression, retry, timeout, or warning-and-continue behavior was added. - **Validation:** Frozen install, `pnpm audit --prod --audit-level=high` (no high findings; 1 low and 8 moderate remain), Expo `install --check`, iOS Metro export (3,169 modules), mobile typecheck/lint, and root lint all pass locally. - **Remaining risk / follow-up:** Confirm the next PR CI run passes the dependency audit and Metro bundle jobs, then monitor the updated Expo packages through the subsequent mobile build. + +## 2026-08-07 — PR dependency audit hit no-patch image-size advisories + +- **Status:** Fixed in the workspace; the dependency-audit workflow needs a + fresh run from the updated commit. +- **Symptoms / impact:** PR #2446 failed [Test / Dependency Audit](https://github.com/Asherlc/dofek/actions/runs/31224536250/job/93016169639), blocking the PR gate. No production impact was observed. +- **Evidence:** The first fatal command was + `pnpm audit --prod --audit-level=high --ignore-registry-errors`. It reported + high-severity `image-size` advisories + [GHSA-w3rx-r6r6-pgpr](https://github.com/advisories/GHSA-w3rx-r6r6-pgpr) + and + [GHSA-5p2g-fcmc-qvqq](https://github.com/advisories/GHSA-5p2g-fcmc-qvqq) + through the Expo/Metro mobile build graph, plus + [GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8) + for `nanoid`. +- **Root cause:** Newly published advisories invalidated the locked mobile + production dependency graph. The `nanoid` path had a compatible patched 3.x + release, but the GitHub advisory database listed no patched `image-size` + version while npm's latest published `image-size` remained `2.0.2`. +- **Fix / mitigation:** Added a scoped `nanoid@<3.3.17` override to `3.3.17` + and regenerated the lockfile. Added only the two no-patch `image-size` GHSA + IDs to `pnpm-workspace.yaml` `audit.ignore`, preserving the high-severity + audit gate for every other advisory. No retry, timeout, or warn-and-continue + behavior was added. +- **Validation:** `pnpm audit --prod --audit-level=high --ignore-registry-errors` + passes locally after the scoped ignore and patched `nanoid` override. +- **Remaining risk / follow-up:** Remove the two `image-size` audit ignores as + soon as upstream publishes a patched release or Expo/Metro removes the + vulnerable path; rerun the hosted dependency-audit job after this PR commit. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 292cd8b17d..f85f550bd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,7 @@ overrides: lerna>minimatch: 3.1.4 minimatch@3>brace-expansion: 1.1.18 minimatch: 10.2.5 + nanoid@<3.3.17: 3.3.17 picomatch@>=4.0.0 <4.0.4: 4.0.4 postcss: 8.5.23 protobufjs: 8.7.1 @@ -13011,8 +13012,8 @@ packages: nan@2.28.0: resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.17: + resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -18908,7 +18909,7 @@ snapshots: '@gorhom/portal@1.0.14(react-native@0.86.2)(react@19.2.3)': dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 react: 19.2.3 react-native: 0.86.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.2)(@types/react@19.2.14)(react@19.2.3)(supports-color@11.0.0) @@ -27981,7 +27982,7 @@ snapshots: expo-symbols: 57.0.2(expo-font@57.0.1)(expo@57.0.11)(react-native@0.86.2)(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 - nanoid: 3.3.16 + nanoid: 3.3.17 query-string: 7.1.3 react: 19.2.3 react-fast-compare: 3.2.2 @@ -30677,7 +30678,7 @@ snapshots: nan@2.28.0: optional: true - nanoid@3.3.16: {} + nanoid@3.3.17: {} nanospinner@1.2.2: dependencies: @@ -31703,7 +31704,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.17 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e024ba50c4..63db18b76c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -32,6 +32,11 @@ packages: dedupePeers: true +audit: + ignore: + - GHSA-w3rx-r6r6-pgpr + - GHSA-5p2g-fcmc-qvqq + overrides: "@expo/dom-webview": 55.0.5 "@expo/xcpretty>js-yaml": 4.3.1 @@ -53,6 +58,7 @@ overrides: "lerna>minimatch": 3.1.4 "minimatch@3>brace-expansion": 1.1.18 minimatch: 10.2.5 + "nanoid@<3.3.17": 3.3.17 "picomatch@>=4.0.0 <4.0.4": 4.0.4 postcss: 8.5.23 protobufjs: 8.7.1 From 26a5159dcd319a2d741ed67b1f214288e475f544 Mon Sep 17 00:00:00 2001 From: Asher Cohen Date: Fri, 7 Aug 2026 16:00:08 -0700 Subject: [PATCH 5/5] fix: colocate mobile route fixture tests --- packages/mobile/AGENTS.md | 2 +- .../(tabs)/fixture-dates.test.ts} | 2 +- .../(tabs)/food-story-fixture.test.ts} | 2 +- .../(tabs)/processing-status-story-fixture.test.ts} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename packages/mobile/{app-tests/(tabs)/_fixture-dates.test.ts => app-fixtures/(tabs)/fixture-dates.test.ts} (89%) rename packages/mobile/{app-tests/(tabs)/_food-story-fixture.test.ts => app-fixtures/(tabs)/food-story-fixture.test.ts} (89%) rename packages/mobile/{app-tests/(tabs)/_processing-status-story-fixture.test.ts => app-fixtures/(tabs)/processing-status-story-fixture.test.ts} (98%) diff --git a/packages/mobile/AGENTS.md b/packages/mobile/AGENTS.md index 745d2c3648..c3118ed471 100644 --- a/packages/mobile/AGENTS.md +++ b/packages/mobile/AGENTS.md @@ -10,7 +10,7 @@ - **Native Modules**: Domain logic for BLE (`WhoopBleModule`) and HealthKit is implemented in Swift. TypeScript only provides the bridge via Expo Modules. ### UI Development -- **Storybook**: Every component MUST have a `.stories.tsx` file (lives in `.storybook` and `.rnstorybook`). +- **Storybook**: `.storybook` and `.rnstorybook` contain Storybook configuration. Route stories live in `app-stories/`; component stories live beside their component under `components/`. - **Charts**: Use `react-native-svg` for all chart implementations. - **Navigation**: Uses Expo Router. Screen paths map to `app/`. - **Expo Router route hygiene**: Never colocate tests, stories, fixtures, or helper-only files under `packages/mobile/app/`. Expo Router's `app` directory is exclusively for routes and attempts to treat non-route files there as routes ([Expo Router core concepts](https://docs.expo.dev/router/basics/core-concepts/#6-non-navigation-components-live-outside-the-srcapp-directory)), which can create extra iOS tabs/screens. Put route tests under `packages/mobile/app-tests/`, route stories under `packages/mobile/app-stories/`, and shared route fixtures under `packages/mobile/app-fixtures/`. If a file under `app/` is not a real route/layout/special Expo Router file, move it out instead of hiding it with `href: null`. diff --git a/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts b/packages/mobile/app-fixtures/(tabs)/fixture-dates.test.ts similarity index 89% rename from packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts rename to packages/mobile/app-fixtures/(tabs)/fixture-dates.test.ts index 586a1f08c4..c08fcc8540 100644 --- a/packages/mobile/app-tests/(tabs)/_fixture-dates.test.ts +++ b/packages/mobile/app-fixtures/(tabs)/fixture-dates.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { createFixtureDates } from "../../app-fixtures/(tabs)/fixture-dates"; +import { createFixtureDates } from "./fixture-dates"; describe("createFixtureDates", () => { afterEach(() => { diff --git a/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts b/packages/mobile/app-fixtures/(tabs)/food-story-fixture.test.ts similarity index 89% rename from packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts rename to packages/mobile/app-fixtures/(tabs)/food-story-fixture.test.ts index 94b75c9252..1e409a4615 100644 --- a/packages/mobile/app-tests/(tabs)/_food-story-fixture.test.ts +++ b/packages/mobile/app-fixtures/(tabs)/food-story-fixture.test.ts @@ -1,7 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; import { describe, expect, it } from "vitest"; -import { seedFoodStoryQuery } from "../../app-fixtures/(tabs)/food-story-fixture"; import { FoodByDateV2Schema } from "../../types/api"; +import { seedFoodStoryQuery } from "./food-story-fixture"; describe("seedFoodStoryQuery", () => { it("seeds runtime-valid data for the current byDateV2 procedure", () => { diff --git a/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts b/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.test.ts similarity index 98% rename from packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts rename to packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.test.ts index 1a42044c34..3f3021f5a6 100644 --- a/packages/mobile/app-tests/(tabs)/_processing-status-story-fixture.test.ts +++ b/packages/mobile/app-fixtures/(tabs)/processing-status-story-fixture.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest"; import { createProcessingStatusStoryLink, seedReadyProcessingStatus, -} from "../../app-fixtures/(tabs)/processing-status-story-fixture"; +} from "./processing-status-story-fixture"; describe("seedReadyProcessingStatus", () => { it("seeds the exact processing status query used by screenshot stories", () => {