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
10 changes: 10 additions & 0 deletions .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@
"matchDatasources": ["npm"],
"minimumReleaseAge": "7 days"
},
{
"description": "TypeScript 7.0 is the native compiler and does not expose the programmatic API that `astro check` loads, so it breaks typecheck in docs/ and www/. The TS team calls this point-in-time but has named no version; 7.1 is a probe, and docs/www typecheck in CI is the test. Widen the block if 7.1 also fails. https://github.com/withastro/roadmap/discussions/1321",
"matchPackageNames": ["typescript"],
"allowedVersions": "!/^7\\.0\\./"
},
{
"description": "Babel 8 is rejected by react-native-worklets' babel plugin, which hard-requires `^7.0.0-0`, and babel-preset-expo is still Babel 7. Every app test suite that touches a worklet fails to transform. Lift when the Expo/RN toolchain ships Babel 8 support.",
"matchPackageNames": ["@babel/core", "@babel/runtime"],
"allowedVersions": "<8"
},
{
"description": "Renovate rewrites this SHA-pinned rev to the tag v3.4.0, a downgrade that drops check-shebang-scripts-are-executable. Bump by hand: `prek update --freeze` keeps the SHA form.",
"matchManagers": ["pre-commit"],
Expand Down
8 changes: 1 addition & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -343,12 +343,6 @@ jobs:
# pnpm-workspace.yaml so `just audit` enforces the same policy.
# Re-check the whole list when expo or metro moves a major version.
#
# GHSA-5p2g-fcmc-qvqq, GHSA-w3rx-r6r6-pgpr: image-size 1.2.1 arrives through
# metro/@expo/cli. Both cover every published version (<= 2.0.2) with no fix
# available, and the parsers only ever see files the developer already has
# locally: metro is build tooling, and image-size is in no shipped bundle.
# Drop once upstream ships a patched image-size.
#
# GHSA-vcc3-ghjq-m6fr: decode-uri-component 0.2.2 arrives through
# query-string@7, which expo-router depends on -- 57.0.17, the latest, still
# pins ^7.1.3, so there is no version of the app's own tree that avoids it.
Expand All @@ -357,7 +351,7 @@ jobs:
# deep link can stall the JS thread of the app that opened it, which costs
# that one user a force-quit and reaches no server and no other user. Drop
# when expo-router moves to query-string@8+ or query-string@7 backports.
allow-ghsas: GHSA-5p2g-fcmc-qvqq, GHSA-w3rx-r6r6-pgpr, GHSA-vcc3-ghjq-m6fr
allow-ghsas: GHSA-vcc3-ghjq-m6fr

audit:
name: Dependency Audit
Expand Down
3 changes: 3 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ repos:
- id: check-shebang-scripts-are-executable
- id: check-toml
- id: check-yaml
# pnpm 12 writes two YAML documents: its own packageManagerDependencies,
# then the project lockfile. pnpm validates that file itself.
exclude: ^pnpm-lock\.yaml$
- id: detect-private-key
- id: end-of-file-fixer
- id: mixed-line-ending
Expand Down
17 changes: 13 additions & 4 deletions app/config/setup.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,16 @@ jest.mock('expo-secure-store', () => ({
}));

// Mock AsyncStorage so tests never touch the native module implementation.
jest.mock('@react-native-async-storage/async-storage', () =>
require('@react-native-async-storage/async-storage/jest/async-storage-mock'),
);
// The shipped mock is a plain in-memory object, so wrap its methods: tests
// assert on calls (`expect(AsyncStorage.setItem).toHaveBeenCalledWith(...)`).
jest.mock('@react-native-async-storage/async-storage', () => {
const { default: storage } = require('@react-native-async-storage/async-storage/jest');
const spied: Record<string, unknown> = { ...storage };
for (const [key, value] of Object.entries(storage)) {
if (typeof value === 'function') spied[key] = jest.fn(value as (...args: never[]) => unknown);
}
return { __esModule: true, default: spied };
});

// Mock expo-linear-gradient
jest.mock('expo-linear-gradient', () => ({
Expand Down Expand Up @@ -327,6 +334,8 @@ try {
}

afterEach(async () => {
const AsyncStorage = require('@react-native-async-storage/async-storage');
// Not `clearAllMockStorages()`: that empties the mock's registry, leaving the
// instance already handed to importers holding its data.
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
await AsyncStorage.clear();
});
9 changes: 5 additions & 4 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"dependencies": {
"@expo/metro-runtime": "~57.0.9",
"@hookform/resolvers": "^5.2.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-async-storage/async-storage": "3.1.1",
"@rn-primitives/separator": "^1.5.2",
"@rn-primitives/slot": "^1.5.2",
"@rn-primitives/switch": "^1.5.2",
Expand Down Expand Up @@ -91,12 +91,13 @@
"@babel/core": "7.29.7",
"@babel/runtime": "7.29.7",
"@biomejs/biome": "catalog:",
"@jest/globals": "29.7.0",
"@jest/globals": "30.5.0",
"@playwright/test": "catalog:",
"@react-native/jest-preset": "^0.86.2",
"@tailwindcss/postcss": "4.3.3",
"@testing-library/react-native": "13.3.3",
"@types/jest": "29.5.14",
"@types/jest": "30.0.0",
"@types/node": "catalog:",
"@types/react": "~19.2.18",
"@typescript-eslint/parser": "8.68.0",
"babel-plugin-react-compiler": "1.0.0",
Expand All @@ -105,7 +106,7 @@
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-native-a11y": "3.5.1",
"eslint-plugin-react-refresh": "0.5.5",
"jest": "29.7.0",
"jest": "30.5.0",
"jest-expo": "~57.0.4",
"jest-junit": "17.0.0",
"msw": "2.15.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const { openExternalUrl } = jest.requireMock('@/services/externalLinks') as {

const DECLINE_IS_FREE = /Nothing changes if you decline/;

const refetch = jest.fn(async () => undefined);
const refetch = jest.fn(async (_forceRefresh?: boolean) => undefined);

function signedInWith(termsAcceptanceRequired: boolean) {
mockUseAuth.mockReturnValue({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function createVideoMock(canPlayType = '') {
addEventListener: jest.fn((event: string, handler: () => void) => {
handlers.set(event, handler);
}),
removeEventListener: jest.fn((event: string) => {
removeEventListener: jest.fn((event: string, _handler?: () => void) => {
handlers.delete(event);
}),
canPlayType: jest.fn(() => canPlayType),
Expand All @@ -38,7 +38,7 @@ function createHlsMock() {
destroy: jest.fn(),
};
const Hls = Object.assign(
jest.fn(() => hlsInstance),
jest.fn((_config?: Record<string, unknown>) => hlsInstance),
{
isSupported: jest.fn(() => true),
Events: { ERROR: 'hlsError' },
Expand Down
10 changes: 5 additions & 5 deletions app/src/context/__tests__/AuthProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('AuthProvider — sign-out cache clearing', () => {

const queryClient = new QueryClient();
const clearSpy = jest.spyOn(queryClient, 'clear');
const multiRemoveSpy = jest.spyOn(AsyncStorage, 'multiRemove');
const removeManySpy = jest.spyOn(AsyncStorage, 'removeMany');

function wrapper({ children }: { children: React.ReactNode }) {
return (
Expand All @@ -66,7 +66,7 @@ describe('AuthProvider — sign-out cache clearing', () => {

await waitFor(() => expect(result.current.user).toBeUndefined());
expect(clearSpy).toHaveBeenCalledTimes(1);
expect(multiRemoveSpy).toHaveBeenCalledWith(
expect(removeManySpy).toHaveBeenCalledWith(
expect.arrayContaining(['relab-query-cache', 'relab-recent-categories']),
);
});
Expand All @@ -77,8 +77,8 @@ describe('AuthProvider — sign-out cache clearing', () => {

const queryClient = new QueryClient();
const clearSpy = jest.spyOn(queryClient, 'clear');
const multiRemoveSpy = jest.spyOn(AsyncStorage, 'multiRemove');
multiRemoveSpy.mockClear();
const removeManySpy = jest.spyOn(AsyncStorage, 'removeMany');
removeManySpy.mockClear();

function wrapper({ children }: { children: React.ReactNode }) {
return (
Expand All @@ -98,6 +98,6 @@ describe('AuthProvider — sign-out cache clearing', () => {

await waitFor(() => expect(result.current.user?.id).toBe('u1'));
expect(clearSpy).not.toHaveBeenCalled();
expect(multiRemoveSpy).not.toHaveBeenCalled();
expect(removeManySpy).not.toHaveBeenCalled();
});
});
4 changes: 2 additions & 2 deletions app/src/features/cameras/__tests__/detailActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ describe('camera detail actions', () => {
const refetch = jest.fn();
const replace = jest.fn();
const alert = jest.fn();
const configure = jest.fn<() => Promise<void>>();
const configure = jest.fn<(baseUrl: string, apiKey: string) => Promise<void>>();
const clearLocalConnection = jest.fn<() => Promise<void> | undefined>();
const input = jest.fn<(options: DialogOptions) => void>();
const closeManualSetup = jest.fn();
const setLocalSetupSaving = jest.fn();
const setLocalSetupSaving = jest.fn<(saving: boolean) => void>();
const updateMutate =
jest.fn<
(
Expand Down
4 changes: 2 additions & 2 deletions app/src/features/cameras/rpi/__tests__/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ describe('rpi camera mutation helpers', () => {
});

it('supports optimistic stream status clearing and restore', async () => {
const cancelQueries = jest.fn(async () => undefined);
const cancelQueries = jest.fn(async (_filters?: unknown) => undefined);
const getQueryData = jest.fn(() => ({ id: 'stream-1' }));
const setQueryData = jest.fn();
const setQueryData = jest.fn<(queryKey: unknown, data: unknown) => void>();
const queryClient = {
cancelQueries,
getQueryData,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it, jest } from '@jest/globals';
import { act, renderHook } from '@testing-library/react-native';
import type { SaveProductVariables } from '@/features/products/queries';
import { useCaptureEntity } from '@/features/products/useCaptureEntity';

const mockMutateAsync = jest.fn<(args: { product: { id?: number } }) => Promise<number>>();
const mockMutateAsync = jest.fn<(args: SaveProductVariables) => Promise<number>>();
const mockToast = jest.fn();
const mockError = jest.fn();
// Plain mutable flag (not jest.fn().mockReturnValue) so the mocked hook below
Expand Down
33 changes: 17 additions & 16 deletions app/src/features/products/__tests__/useProductForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import type React from 'react';
import { useDialog } from '@/components/base/dialogContext';
import type { SaveProductVariables } from '@/features/products/queries';
import {
useBaseProductQuery,
useDeleteProductMutation,
Expand Down Expand Up @@ -83,7 +84,7 @@ describe('useProductForm', () => {
it('initializes with existing product data', async () => {
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
});

const { result } = renderHook(() => useProductForm('123', { role: 'product' }), { wrapper });
Expand All @@ -98,7 +99,7 @@ describe('useProductForm', () => {
it('handles field changes', async () => {
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
});

const { result } = renderHook(() => useProductForm('123', { role: 'product' }), { wrapper });
Expand All @@ -115,7 +116,7 @@ describe('useProductForm', () => {
});

it('triggers save mutation when saveAndExit is called with a dirty form', async () => {
const mockMutate = jest.fn(async () => 123);
const mockMutate = jest.fn(async (_vars: SaveProductVariables) => 123);
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({ mutateAsync: mockMutate });

Expand Down Expand Up @@ -149,7 +150,7 @@ describe('useProductForm', () => {
// rehydrated mutations, where minting a fresh key would rotate it and defeat
// dedup against the request the app already sent before it was interrupted.
it('generates an idempotencyKey for a new (id-less) product create', async () => {
const mockMutate = jest.fn(async () => 55);
const mockMutate = jest.fn(async (_vars: SaveProductVariables) => 55);
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: undefined, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({ mutateAsync: mockMutate });

Expand Down Expand Up @@ -217,7 +218,7 @@ describe('useProductForm', () => {
// and saveAndExit must read it before serializing — even though isDirty is
// still a stale react-hook-form snapshot from before the flush happened.
it('flushes a pending amount draft before serializing, even though isDirty is still stale', async () => {
const mockMutate = jest.fn(async () => 123);
const mockMutate = jest.fn(async (_vars: SaveProductVariables) => 123);
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({ mutateAsync: mockMutate });

Expand All @@ -241,7 +242,7 @@ describe('useProductForm', () => {
});

it('does not treat a clean form as dirty when the flush ref has no pending draft', async () => {
const mockMutate = jest.fn(async () => 123);
const mockMutate = jest.fn(async (_vars: SaveProductVariables) => 123);
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({ mutateAsync: mockMutate });

Expand Down Expand Up @@ -291,7 +292,7 @@ describe('useProductForm', () => {
const onSaveSuccess = jest.fn();
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
});

const { result } = renderHook(
Expand Down Expand Up @@ -433,7 +434,7 @@ describe('useProductForm', () => {
.mockReturnValue({ alert: jest.fn(), input: jest.fn(), toast: mockToast });
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
isPaused: true,
});

Expand All @@ -452,7 +453,7 @@ describe('useProductForm', () => {
.mockReturnValue({ alert: jest.fn(), input: jest.fn(), toast: mockToast });
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
isPaused: false,
});

Expand All @@ -464,10 +465,10 @@ describe('useProductForm', () => {
});

it('calls delete mutation and navigates to /products on success', async () => {
const mockDeleteMutate = jest.fn(async () => undefined);
const mockDeleteMutate = jest.fn(async (_vars: { id: number }) => undefined);
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
});
(useDeleteProductMutation as jest.Mock).mockReturnValue({ mutateAsync: mockDeleteMutate });

Expand All @@ -483,11 +484,11 @@ describe('useProductForm', () => {
});

it('routes delete through onDeleteSuccess when provided instead of the root list', async () => {
const mockDeleteMutate = jest.fn(async () => undefined);
const mockDeleteMutate = jest.fn(async (_vars: { id: number }) => undefined);
const onDeleteSuccess = jest.fn();
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
});
(useDeleteProductMutation as jest.Mock).mockReturnValue({ mutateAsync: mockDeleteMutate });

Expand Down Expand Up @@ -515,7 +516,7 @@ describe('useProductForm', () => {
};
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: validProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
});

const { result } = renderHook(
Expand Down Expand Up @@ -555,7 +556,7 @@ describe('useProductForm', () => {
};
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: validProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
});

const { result } = renderHook(
Expand Down Expand Up @@ -597,7 +598,7 @@ describe('useProductForm', () => {
});
(useBaseProductQuery as jest.Mock).mockReturnValue({ data: mockProduct, isLoading: false });
(useSaveProductMutation as jest.Mock).mockReturnValue({
mutateAsync: jest.fn(async () => 123),
mutateAsync: jest.fn(async (_vars: SaveProductVariables) => 123),
});
(useDeleteProductMutation as jest.Mock).mockReturnValue({ mutateAsync: deleteMutate });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('useProductSearchShortcut', () => {
listener = handler;
});
const removeEventListener = jest.fn();
const querySelector = jest.fn<() => Element | null>(() => null);
const querySelector = jest.fn<(selectors: string) => Element | null>(() => null);

function press(key: string, target: unknown) {
listener?.({ key, target, preventDefault: jest.fn() } as unknown as KeyboardEvent);
Expand Down
Loading
Loading