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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/test-frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ jobs:

# No EXPO_PUBLIC_* secrets are wired in — `jest.setup.js` defaults them.
- name: Test
# No --forceExit (DEX-241): a leaked handle hangs Jest, so fail fast.
timeout-minutes: 5
run: npm test
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Not worth writing (removed en masse in DEX-143 — don't reintroduce):
- **Don't close a mutation test on a mock's call count.** `toHaveBeenCalled()` goes true when the request *starts*, while `onSettled` → `invalidateQueries` → refetch → notify all land after the test returns. End on `settleQueries(client)` (`@/testUtils/settleQueries`) or on the hook's own terminal state — `hooks/__tests__/useRealtimeInvalidation.test.tsx` resolves its deferred inside `act`, then waits for `result.current[0].content`.
- **Never leave a mocked mutation on a promise that never settles** — it wedges the whole run instead of failing one test. The one deliberate exception says so at the call site (`useRealtimeInvalidation.test.tsx`, simulating an autosave still retrying after unmount).
- **`@shopify/flash-list` is mocked** in `jest.setup.js` as a plain view rendering every item, which is what the real one did here anyway. It schedules layout state from a `requestAnimationFrame` that always outlives a synchronous test. Its own `jestSetup` is not the fix: it only stubs `measureLayout`, and loading it globally cost the suite 5s → 90s.
- **`npm test` needs `--forceExit`** — tests build a `QueryClient` and none clears it, so react-query's gc timers hold the event loop open for minutes after the last assertion (`--detectOpenHandles` names them).
- **Don't add `--forceExit` back** — un-cleared `QueryClient` gc timers are unref'd in `jest.setup.js` instead, so a "worker failed to exit" warning now means a real leak (e.g. an animation not stopped on unmount); find it with `--detectOpenHandles`.

## Supabase (`/supabase`)

Expand Down
14 changes: 10 additions & 4 deletions src/components/HabitRing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,23 +59,29 @@ export function HabitRing({
// Seeded at the current offset so an already-partway-done ring doesn't
// sweep in on first render.
const [arc] = useState(() => new Animated.Value(dashoffset));
// Stopped on unmount: a running timing keeps scheduling frames after the
// ring is gone (and past Jest's teardown).
useEffect(() => {
Animated.timing(arc, {
const fill = Animated.timing(arc, {
toValue: dashoffset,
duration: FILL_DURATION_MS,
useNativeDriver: false,
}).start();
});
fill.start();
return () => fill.stop();
}, [arc, dashoffset]);

// Cross-fade the completed state (solid fill + checkmark) in and out so
// finishing the last step doesn't pop. Opacity supports the native driver.
const [complete] = useState(() => new Animated.Value(isComplete ? 1 : 0));
useEffect(() => {
Animated.timing(complete, {
const fade = Animated.timing(complete, {
toValue: isComplete ? 1 : 0,
duration: FILL_DURATION_MS,
useNativeDriver: true,
}).start();
});
fade.start();
return () => fade.stop();
}, [complete, isComplete]);
const emojiOpacity = complete.interpolate({
inputRange: [0, 1],
Expand Down
7 changes: 5 additions & 2 deletions src/hooks/__tests__/useLists.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native";
import { ReactNode } from "react";

import { createList, getLists, TList } from "@/api/lists";
import { settleQueries } from "@/testUtils/settleQueries";

import { listsQueryOptions, useLists } from "../useLists";

Expand Down Expand Up @@ -84,7 +85,7 @@ describe("useLists", () => {
});

it("refetches the lists query after a create mutation", async () => {
const { wrapper } = createWrapper();
const { queryClient, wrapper } = createWrapper();
mockCreateList.mockResolvedValue([makeList()]);

const { result } = await renderHook(() => useLists(), { wrapper });
Expand All @@ -94,6 +95,8 @@ describe("useLists", () => {
result.current[1].createList({ title: "Work", emoji: "💼" }),
);

await waitFor(() => expect(mockGetLists).toHaveBeenCalledTimes(2));
await settleQueries(queryClient);

expect(mockGetLists).toHaveBeenCalledTimes(2);
});
});
20 changes: 11 additions & 9 deletions src/hooks/__tests__/useNotes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,20 +68,20 @@ describe("useNotes", () => {
mockGetNote.mockResolvedValue(null);
mockUpsertNote.mockResolvedValue({ date: "2026-07-12", content: "hello" });

const { client, wrapper } = createWrapper();
const { result } = await renderHook(() => useNotes("2026-07-12"), {
wrapper: createWrapper().wrapper,
wrapper,
});

await waitFor(() => expect(result.current[1].isLoading).toBe(false));
await act(() => result.current[1].upsertNote({ content: "hello" }));
await settleQueries(client);

// Only the note's own column travels now — nothing has to be re-sent to
// protect a sibling journal column (DEX-51).
await waitFor(() =>
expect(mockUpsertNote).toHaveBeenCalledWith(
{},
{ content: "hello", date: "2026-07-12" },
),
expect(mockUpsertNote).toHaveBeenCalledWith(
{},
{ content: "hello", date: "2026-07-12" },
);
});

Expand All @@ -91,16 +91,18 @@ describe("useNotes", () => {
.mockRejectedValueOnce(new Error("blip"))
.mockResolvedValueOnce({ date: "2026-07-12", content: "typed" });

const { client, wrapper } = createWrapper();
const { result } = await renderHook(() => useNotes("2026-07-12"), {
wrapper: createWrapper().wrapper,
wrapper,
});
await waitFor(() => expect(result.current[1].isLoading).toBe(false));

await act(() => result.current[1].upsertNote({ content: "typed" }));
await settleQueries(client);

await waitFor(() => expect(mockUpsertNote).toHaveBeenCalledTimes(2));
expect(mockUpsertNote).toHaveBeenCalledTimes(2);
// The successful retry's response is written to the cache.
await waitFor(() => expect(result.current[0].content).toBe("typed"));
expect(result.current[0].content).toBe("typed");
expect(result.current[1].exists).toBe(true);
});

Expand Down
13 changes: 13 additions & 0 deletions src/jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,3 +259,16 @@ jest.mock("@expo/ui/swift-ui/modifiers", () => ({
tag: () => ({}),
tint: () => ({}),
}));

// No test clears its QueryClient, so gc timers (5 min) would hold each worker
// open past the run; unref'd, they still fire but no longer block exit.
const unref = (id) => {
id?.unref?.();
return id;
};
require("@tanstack/react-query").timeoutManager.setTimeoutProvider({
setTimeout: (callback, delay) => unref(setTimeout(callback, delay)),
clearTimeout: (id) => clearTimeout(id),
setInterval: (callback, delay) => unref(setInterval(callback, delay)),
clearInterval: (id) => clearInterval(id),
});
4 changes: 2 additions & 2 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
"format:check": "prettier --check .",
"supabase:types": "npx supabase gen types typescript --project-id \"isreileykodwkyedcewv\" --schema public > ./types/database.types.ts",
"licenses": "node utils/generate-licenses.ts",
"test": "jest --forceExit",
"test:watch": "jest --watch --forceExit",
"test": "jest",
"test:watch": "jest --watch",
"typecheck": "tsc --noEmit"
},
"expo": {
Expand Down
Loading