Skip to content
Closed
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
104 changes: 104 additions & 0 deletions tests/js/sentry-test/nuqsTestingAdapter.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {parseAsString, useQueryState} from 'nuqs';

import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

describe('SentryNuqsTestingAdapter', () => {
it('reads search params from router location', async () => {
function TestComponent() {
const [search] = useQueryState('query', parseAsString);
return <div>Search: {search ?? 'empty'}</div>;
}

const {router} = render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/test',
query: {query: 'hello'},
},
},
});

expect(screen.getByText('Search: hello')).toBeInTheDocument();

// Navigate to a new location with different search params
router.navigate('/test?query=world');

expect(await screen.findByText('Search: world')).toBeInTheDocument();
});

it('updates router location when nuqs state changes', async () => {
function TestComponent() {
const [search, setSearch] = useQueryState('query', parseAsString);
return (
<div>
<div>Search: {search ?? 'empty'}</div>
<button onClick={() => setSearch('updated')}>Update</button>
</div>
);
}

const {router} = render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/test',
query: {query: 'initial'},
},
},
});

expect(screen.getByText('Search: initial')).toBeInTheDocument();

// Click button to update search param via nuqs
await userEvent.click(screen.getByRole('button', {name: 'Update'}));

// Wait for navigation to complete
await screen.findByText('Search: updated');

// Verify the router location was updated
await waitFor(() => {
expect(router.location.search).toContain('query=updated');
});
});

it('handles multiple query params', () => {
function TestComponent() {
const [foo] = useQueryState('foo', parseAsString);
const [bar] = useQueryState('bar', parseAsString);
return (
<div>
<div>Foo: {foo ?? 'empty'}</div>
<div>Bar: {bar ?? 'empty'}</div>
</div>
);
}

render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/test',
query: {foo: 'value1', bar: 'value2'},
},
},
});

expect(screen.getByText('Foo: value1')).toBeInTheDocument();
expect(screen.getByText('Bar: value2')).toBeInTheDocument();
});

it('handles missing query params', () => {
function TestComponent() {
const [search] = useQueryState('query', parseAsString);
return <div>Search: {search ?? 'empty'}</div>;
}

render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/test',
},
},
});

expect(screen.getByText('Search: empty')).toBeInTheDocument();
});
});
117 changes: 117 additions & 0 deletions tests/js/sentry-test/nuqsTestingAdapter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {useCallback, useMemo, type ReactElement, type ReactNode} from 'react';
import {unstable_createAdapterProvider as createAdapterProvider} from 'nuqs/adapters/custom';
import type {unstable_AdapterInterface as AdapterInterface} from 'nuqs/adapters/custom';
import type {OnUrlUpdateFunction} from 'nuqs/adapters/testing';

import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';

type SentryNuqsTestingAdapterProps = {
children: ReactNode;
/**
* Default options to pass to nuqs
*/
defaultOptions?: {
clearOnDefault?: boolean;
scroll?: boolean;
shallow?: boolean;
};
/**
* A function that will be called whenever the URL is updated.
* Connect that to a spy in your tests to assert the URL updates.
*/
onUrlUpdate?: OnUrlUpdateFunction;
};

/**
* Custom nuqs adapter component for Sentry that reads location from our
* useLocation hook instead of maintaining its own internal state.
*
* This ensures nuqs uses the same location source as the rest of the
* application during tests.
*/
export function SentryNuqsTestingAdapter({
children,
defaultOptions,
onUrlUpdate,
}: SentryNuqsTestingAdapterProps): ReactElement {
// Create a hook that nuqs will call to get the adapter interface
// This hook needs to be defined inside a component that has access to location/navigate
const useSentryAdapter = useCallback(
(_watchKeys: string[]): AdapterInterface => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const location = useLocation();
// eslint-disable-next-line react-hooks/rules-of-hooks
const navigate = useNavigate();

// Get search params from the current location
const searchParams = new URLSearchParams(location.search || '');

const updateUrl: AdapterInterface['updateUrl'] = (search, options) => {
const newSearchParams = new URLSearchParams(search);
const queryString = newSearchParams.toString();

// Call the onUrlUpdate callback if provided
onUrlUpdate?.({
searchParams: new URLSearchParams(search), // make a copy
queryString,
options,
});

// Navigate to the new location using Sentry's navigate
// We need to construct the full path with the search string
const newPath = queryString
? `${location.pathname}?${queryString}`
: location.pathname;

// The navigate function from TestRouter already wraps this in act()
console.log({newPath});

Check failure on line 68 in tests/js/sentry-test/nuqsTestingAdapter.tsx

View workflow job for this annotation

GitHub Actions / pre-commit lint

Unexpected console statement

Check failure on line 68 in tests/js/sentry-test/nuqsTestingAdapter.tsx

View workflow job for this annotation

GitHub Actions / eslint

Unexpected console statement
navigate(newPath, {replace: options.history === 'replace'});
};

const getSearchParamsSnapshot = () => {
// Always read from the current location
return new URLSearchParams(location.search || '');
};

return {
searchParams,
updateUrl,
getSearchParamsSnapshot,
rateLimitFactor: 0, // No throttling in tests
autoResetQueueOnUpdate: true, // Reset update queue after each update
};
},
[onUrlUpdate]
);

// Create the adapter provider (memoized to prevent remounting)
const AdapterProvider = useMemo(
() => createAdapterProvider(useSentryAdapter),
[useSentryAdapter]
);

return <AdapterProvider defaultOptions={defaultOptions}>{children}</AdapterProvider>;
}

/**
* A higher order component that wraps children with the SentryNuqsTestingAdapter
*
* Usage:
* ```tsx
* render(<MyComponent />, {
* wrapper: withSentryNuqsTestingAdapter({ onUrlUpdate: spy })
* })
* ```
*/
export function withSentryNuqsTestingAdapter(
props: Omit<SentryNuqsTestingAdapterProps, 'children'> = {}
) {
return function SentryNuqsTestingAdapterWrapper({
children,
}: {
children: ReactNode;
}): ReactElement {
return <SentryNuqsTestingAdapter {...props}>{children}</SentryNuqsTestingAdapter>;
};
}
41 changes: 15 additions & 26 deletions tests/js/sentry-test/reactTestingLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
import * as rtl from '@testing-library/react'; // eslint-disable-line no-restricted-imports
import userEvent from '@testing-library/user-event'; // eslint-disable-line no-restricted-imports

import {NuqsTestingAdapter} from 'nuqs/adapters/testing';
import * as qs from 'query-string';
import {LocationFixture} from 'sentry-fixture/locationFixture';
import {ThemeFixture} from 'sentry-fixture/theme';
Expand All @@ -36,14 +35,13 @@ import {
} from 'sentry/utils/browserHistory';
import {ProvideAriaRouter} from 'sentry/utils/provideAriaRouter';
import {QueryClientProvider} from 'sentry/utils/queryClient';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import {OrganizationContext} from 'sentry/views/organizationContext';
import {TestRouteContext} from 'sentry/views/routeContext';

import {instrumentUserEvent} from '../instrumentedEnv/userEventIntegration';

import {initializeOrg} from './initializeOrg';
import {SentryNuqsTestingAdapter} from './nuqsTestingAdapter';

interface ProviderOptions {
/**
Expand All @@ -64,6 +62,9 @@ interface ProviderOptions {
* Sets the OrganizationContext. You may pass null to provide no organization
*/
organization?: Partial<Organization> | null;
/**
* Query string to initialize the nuqs adapter with
*/
query?: string;
/**
* Sets the RouterContext.
Expand Down Expand Up @@ -175,30 +176,11 @@ function patchBrowserHistoryMocksEnabled(history: MemoryHistory, router: Injecte
});
}

function NuqsTestingAdapterWithNavigate({
children,
query,
}: {
children: React.ReactNode;
query: string;
}) {
const location = useLocation();
const navigate = useNavigate();
function NuqsTestingAdapterWithNavigate({children}: {children: React.ReactNode}) {
return (
<NuqsTestingAdapter
searchParams={new URLSearchParams(query)}
defaultOptions={{shallow: false}}
onUrlUpdate={({queryString, options: nuqsOptions}) => {
// Pass navigation events to the test router
const newParams = qs.parse(queryString);
const newLocation = {...location, query: newParams};
setTimeout(() => {
navigate(newLocation, {replace: nuqsOptions.history === 'replace'});
}, 10);
}}
>
<SentryNuqsTestingAdapter defaultOptions={{shallow: false}}>
{children}
</NuqsTestingAdapter>
</SentryNuqsTestingAdapter>
);
}

Expand Down Expand Up @@ -246,7 +228,7 @@ function makeAllTheProviders(options: ProviderOptions) {
return (
<CacheProvider value={{...cache, compat: true}}>
<QueryClientProvider client={makeTestQueryClient()}>
<NuqsTestingAdapterWithNavigate query={options.query ?? ''}>
<NuqsTestingAdapterWithNavigate>
<ThemeProvider theme={ThemeFixture()}>{wrappedContent}</ThemeProvider>
</NuqsTestingAdapterWithNavigate>
</QueryClientProvider>
Expand Down Expand Up @@ -590,3 +572,10 @@ export {
fireEvent,
waitForDrawerToHide,
};

// Export custom nuqs testing adapter
export {
SentryNuqsTestingAdapter,
withSentryNuqsTestingAdapter,
} from './nuqsTestingAdapter';
export type {UrlUpdateEvent, OnUrlUpdateFunction} from 'nuqs/adapters/testing';
Loading