From 37e8628be2eea62cbab37cd044c9a75528f861fe Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 21 Sep 2026 15:33:28 -0700 Subject: [PATCH 01/47] meta(refactor-tasks): Add no-custom-render-helper convention Test files keep growing local `renderFoo()` helpers that only forward their arguments into the shared `render()` or `renderHookWithProviders()` helper. Each file invents its own name and argument shape, so the JSX under test is hidden behind an indirection the reader has to look up first. The rule asks for an `ExampleFoo` component instead, so call sites stay in the shape used everywhere else: `render()`. The detect section lists the helpers that should stay as they are, since a helper doing mock setup or installing a spy through a provider is not the same thing as a render wrapper. --- .../conventions/no-custom-render-helper.yaml | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .sentry-refactor-tasks/conventions/no-custom-render-helper.yaml diff --git a/.sentry-refactor-tasks/conventions/no-custom-render-helper.yaml b/.sentry-refactor-tasks/conventions/no-custom-render-helper.yaml new file mode 100644 index 000000000000..4e79c22e6814 --- /dev/null +++ b/.sentry-refactor-tasks/conventions/no-custom-render-helper.yaml @@ -0,0 +1,161 @@ +name: no-custom-render-helper +severity: warning +tags: [testing, react, jest] + +why: | + Test files often grow a local `renderFoo()` helper that only forwards its + arguments into the shared `render()` / `renderHookWithProviders()` helper from + `sentry-test/reactTestingLibrary`. Every file invents its own name and its own + argument shape, so a reader must first go read the helper before any `it()` + block makes sense, and the standard RTL return value (`rerender`, `router`, + `unmount`) sometimes gets dropped or renamed on the way out. + + When a test needs fixed props, wrapper providers or a bit of glue, put that in + a component instead of in a function. A small `ExampleFoo` component keeps + every call site in the shape the rest of the codebase already uses — + `render()` — so the JSX is visible in the test, extra props can + be passed per test, and `rerender()` works the way RTL + documents it. + + See: .agents/skills/react-testing/SKILL.md + +detect: | + In a test file (`*.spec.ts` / `*.spec.tsx`), look for a locally declared + function whose only job is to call the shared render helper: + + - A `function renderX(...)` declaration, or a `const renderX = (...) => ...` + arrow, declared in the test file or inside a `describe()` block. + - Its body returns (or directly calls) `render(...)`, `renderGlobalModal(...)` + or `renderHookWithProviders(...)` imported from + `sentry-test/reactTestingLibrary`. + - The JSX / hook callback it renders is essentially fixed: the helper's + parameters are spread into props, used to pick between a few literal + prop values, or not used at all. + + Flag the helper declaration itself. + + Do NOT flag: + - Helpers that do real test setup beyond rendering — registering + `MockApiClient.addMockResponse()` mocks, seeding stores, building fixtures, + advancing timers — and never call `render()` (e.g. `renderMockRequests()`). + - Helpers that return extra handles the test needs alongside the render + result, such as a `jest.fn()` spy installed through a context provider + (`return {tracking, ...render(ui, {additionalWrapper})}`). + - Helpers that take the element to render as a parameter and only supply + providers or options (`renderWithX(ui)`), since there is no fixed JSX to + move into a component. + - Render-prop callbacks and test components that return JSX without calling + `render()` at all. + - `render` / `renderHookWithProviders` themselves in + `tests/js/sentry-test/reactTestingLibrary.tsx`, and any other shared helper + under `tests/js/`. + +fix: | + Replace the helper function with a component, then call the shared `render()` + at each call site. + + 1. Name the component `Example` — `ExampleLoadingContainer`, + `ExampleWidgetModal`. (Older tests in the repo use `Test`; either + name is fine, be consistent inside a file.) + 2. Give it the props the helper took as arguments, so each test can vary only + what it cares about. + 3. Move provider wrappers into the component's JSX; move `render()` options + (`organization`, `initialRouterConfig`, `additionalWrapper`) to the call + sites that need them. + 4. Delete the helper and call `render()` in each test. + + Example before: + function renderComponent(props: LoadingContainerProps = {}) { + return render( + +
hello!
+
+ ); + } + + it('handles loading', () => { + const {rerender} = renderComponent({isLoading: true}); + }); + + Example after: + function ExampleLoadingContainer(props: LoadingContainerProps) { + return ( + +
hello!
+
+ ); + } + + it('handles loading', () => { + const {rerender} = render(); + rerender(); + }); + + For a hook helper, the same move applies to `renderHookWithProviders`: + + // Before + function renderProviders() { + return renderHookWithProviders(() => useScmMessagingProviders(), {organization}); + } + // After — inline the hook call, keep the options at the call site + const {result} = renderHookWithProviders(() => useScmMessagingProviders(), { + organization, + }); + + Gotchas: + 1. Do not re-export the render result through a renamed object; tests expect + the standard `{rerender, unmount, router}` shape from `render()`. + 2. A helper that also returns a spy or a mock handle is doing two jobs — + move the JSX into the component and leave the spy setup in the test, or + leave the helper alone if the spy must be installed through a provider. + 3. Components declared inside `describe()` re-create their identity on every + call; declare `ExampleFoo` at module scope so `rerender` keeps the same + component type and preserves state. + 4. Helpers that are `async` usually await something after rendering (a + `findBy` query, a router navigation) — that awaited part belongs in the + test body, not in the component. + +examples: + bad: + - | + function renderComponent(props: LoadingContainerProps = {}) { + return render( + +
hello!
+
+ ); + } + - | + const renderAvatars = ({users, teams}: Props) => + render(); + - | + function renderProviders() { + return renderHookWithProviders(() => useScmMessagingProviders(), {organization}); + } + good: + - | + function ExampleLoadingContainer(props: LoadingContainerProps) { + return ( + +
hello!
+
+ ); + } + // in the test: render(); + - | + // Setup helper that registers mocks and never renders + function mockProjectRequests({firstIssue}: {firstIssue?: string} = {}) { + MockApiClient.addMockResponse({url: '/projects/org-slug/project-slug/', body: {}}); + } + - | + // Wrapper that installs a spy through a provider and takes the element + function renderWithTracking(ui: React.ReactElement) { + const tracking = jest.fn(); + return {tracking, ...render(ui, {additionalWrapper: TrackingWrapper})}; + } + +include: + - 'static/**/*.spec.tsx' + - 'static/**/*.spec.ts' + +prefilter: "grep -rlE '^[[:space:]]*(export )?(async )?function render[A-Z]|^[[:space:]]*(const|let) render[A-Z][A-Za-z0-9]*[[:space:]]*=' --include='*.spec.tsx' --include='*.spec.ts' {repo_path}/static/" From 34cca56eba2594236c31681fab9201cf840eda70 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:20:51 -0700 Subject: [PATCH 02/47] test(loading): Replace renderComponent helper with ExampleLoadingContainer The helper only forwarded props into render(), so each test had to be read against a definition further up the file. An ExampleLoadingContainer component puts the JSX at the call site and lets rerender take an element, which is what the two rerender assertions here already depend on. --- .../app/components/loading/loadingContainer.spec.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/static/app/components/loading/loadingContainer.spec.tsx b/static/app/components/loading/loadingContainer.spec.tsx index 3b0bbbae8cc0..68d089622e13 100644 --- a/static/app/components/loading/loadingContainer.spec.tsx +++ b/static/app/components/loading/loadingContainer.spec.tsx @@ -3,8 +3,8 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import type {LoadingContainerProps} from 'sentry/components/loading/loadingContainer'; import {LoadingContainer} from 'sentry/components/loading/loadingContainer'; -function renderComponent(props: LoadingContainerProps = {}) { - return render( +function ExampleLoadingContainer(props: LoadingContainerProps) { + return (
hello!
@@ -13,13 +13,13 @@ function renderComponent(props: LoadingContainerProps = {}) { describe('LoadingContainer', () => { it('handles normal state', () => { - renderComponent(); + render(); expect(screen.getByText('hello!')).toBeInTheDocument(); expect(() => screen.getByTestId('loading-indicator')).toThrow(); }); it('handles loading state', () => { - const {rerender} = renderComponent({isLoading: true}); + const {rerender} = render(); expect(screen.getByText('hello!')).toBeInTheDocument(); expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); rerender(); @@ -28,7 +28,7 @@ describe('LoadingContainer', () => { }); it('handles reloading state', () => { - const {rerender} = renderComponent({isReloading: true}); + const {rerender} = render(); expect(screen.getByText('hello!')).toBeInTheDocument(); expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); rerender(); From 221fdce793b55118efb49ed61a4b7cd6b1651d19 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:22:10 -0700 Subject: [PATCH 03/47] test(avatar): Drop the renderComponent wrapper in avatarList specs The helper added nothing over AvatarList's own props, so it was pure indirection. Calling render() directly shows each test what it renders without a second type annotation to maintain. --- .../core/avatar/avatarList.spec.tsx | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/static/app/components/core/avatar/avatarList.spec.tsx b/static/app/components/core/avatar/avatarList.spec.tsx index 9ab1dc10df8b..9e3d130f9c8e 100644 --- a/static/app/components/core/avatar/avatarList.spec.tsx +++ b/static/app/components/core/avatar/avatarList.spec.tsx @@ -5,16 +5,6 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {AvatarList} from '@sentry/scraps/avatar'; -function renderComponent({ - users, - teams, -}: { - users: React.ComponentProps['users']; - teams?: React.ComponentProps['teams']; -}) { - return render(); -} - describe('AvatarList', () => { const user = UserFixture(); const team = TeamFixture(); @@ -25,7 +15,7 @@ describe('AvatarList', () => { {...user, id: '2', name: 'BC'}, ]; - renderComponent({users}); + render(); expect(screen.getByText('A')).toBeInTheDocument(); expect(screen.getByText('B')).toBeInTheDocument(); expect(screen.queryByTestId('avatarList-collapsedavatars')).not.toBeInTheDocument(); @@ -41,7 +31,7 @@ describe('AvatarList', () => { {...user, id: '6', name: 'FG'}, ]; - renderComponent({users}); + render(); expect(screen.getByText(users[0]!.name.charAt(0))).toBeInTheDocument(); expect(screen.getByText(users[1]!.name.charAt(0))).toBeInTheDocument(); expect(screen.getByText(users[2]!.name.charAt(0))).toBeInTheDocument(); @@ -62,7 +52,7 @@ describe('AvatarList', () => { {...user, id: '7', name: 'GH'}, ]; - renderComponent({users}); + render(); expect(screen.getByText(users[0]!.name.charAt(0))).toBeInTheDocument(); expect(screen.getByText(users[1]!.name.charAt(0))).toBeInTheDocument(); expect(screen.getByText(users[2]!.name.charAt(0))).toBeInTheDocument(); @@ -82,7 +72,7 @@ describe('AvatarList', () => { {...team, id: '2', name: 'B', slug: 'B', type: 'team'}, ]; - renderComponent({users, teams}); + render(); expect(screen.getByText('A')).toBeInTheDocument(); expect(screen.getByText('B')).toBeInTheDocument(); expect(screen.getByText('C')).toBeInTheDocument(); @@ -96,7 +86,7 @@ describe('AvatarList', () => { {...team, id: '2', name: 'B', slug: 'B', type: 'team'}, ]; - renderComponent({users: [], teams}); + render(); expect(screen.getByText('A')).toBeInTheDocument(); expect(screen.getByText('B')).toBeInTheDocument(); expect(screen.queryByTestId('avatarList-collapsedavatars')).not.toBeInTheDocument(); From 716e56851a04400f86316f11eb387f4dc3e3b340 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:22:23 -0700 Subject: [PATCH 04/47] test(modals): Drop the renderComponent wrapper in teamAccessRequestModal specs The helper took no arguments and rendered a fixed element built from modalRenderProps, which is already a named value in the file. Calling render on it directly removes the indirection without losing anything. --- .../components/modals/teamAccessRequestModal.spec.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/static/app/components/modals/teamAccessRequestModal.spec.tsx b/static/app/components/modals/teamAccessRequestModal.spec.tsx index 24fbe6cc722e..7e2b14257442 100644 --- a/static/app/components/modals/teamAccessRequestModal.spec.tsx +++ b/static/app/components/modals/teamAccessRequestModal.spec.tsx @@ -32,10 +32,6 @@ describe('TeamAccessRequestModal', () => { api: new MockApiClient(), }; - function renderComponent() { - return render(); - } - beforeEach(() => { MockApiClient.clearMockResponses(); createMock = MockApiClient.addMockResponse({ @@ -45,7 +41,7 @@ describe('TeamAccessRequestModal', () => { }); it('renders', () => { - const {container} = renderComponent(); + const {container} = render(); expect(container).toHaveTextContent( `You do not have permission to add members to the #${teamId} team, but we will send a request to your organization admins for approval.` @@ -53,14 +49,14 @@ describe('TeamAccessRequestModal', () => { }); it('creates access request on continue', async () => { - renderComponent(); + render(); await userEvent.click(screen.getByRole('button', {name: 'Continue'})); expect(createMock).toHaveBeenCalled(); }); it('closes modal on cancel', async () => { - renderComponent(); + render(); await userEvent.click(screen.getByRole('button', {name: 'Cancel'})); expect(createMock).not.toHaveBeenCalled(); From 96abaa1db5e370ba071a83c852c53fc544b80018 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:24:53 -0700 Subject: [PATCH 05/47] test(seer): Replace the renderChart helper with ExampleChartEmbed The helper wrapped the chart payload in the tag syntax and rendered SeerMarkdown, so the element under test never appeared in a test. A component keeps the tag-building in one place while each test shows what it renders. --- .../markdown/embeds/components/chart.spec.tsx | 175 ++++++++++-------- 1 file changed, 101 insertions(+), 74 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/chart.spec.tsx b/static/app/components/seer/markdown/embeds/components/chart.spec.tsx index 96ac96c20005..9efd2bfbbd0d 100644 --- a/static/app/components/seer/markdown/embeds/components/chart.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/chart.spec.tsx @@ -10,9 +10,8 @@ jest.mock('sentry/components/charts/baseChart', () => ({ BaseChart: jest.fn(() => null), })); -function renderChart(body: Record) { - const raw = `{% chart %}${JSON.stringify(body)}{% /chart %}`; - render(); +function ExampleChartEmbed({body}: {body: Record}) { + return ; } describe('Chart embed', () => { @@ -23,23 +22,27 @@ describe('Chart embed', () => { it.each(['line', 'area', 'bar'] as const)( 'renders a Dashboard %s time-series visualization', visualization => { - renderChart({ - title: 'Error volume', - subtitle: 'Last three hours', - visualization, - x_axis: 'time', - y_axis_unit: 'number', - series: [ - { - label: 'Errors', - data: [ - {x: '2026-07-30T14:00:00Z', y: 15}, - {x: '2026-07-30T12:00:00Z', y: 12}, - {x: '2026-07-30T13:00:00Z', y: 18}, + render( + + ); expect(screen.getByText('Error volume')).toBeInTheDocument(); expect(screen.getByText('Last three hours')).toBeInTheDocument(); @@ -68,20 +71,24 @@ describe('Chart embed', () => { ); it('renders category bars with the Dashboard categorical visualization', () => { - renderChart({ - title: 'Errors by status', - visualization: 'bar', - x_axis: 'category', - series: [ - { - label: 'Errors', - data: [ - {x: 200, y: 12}, - {x: 500, y: 4}, + render( + + ); const props = jest.mocked(BaseChart).mock.calls.at(-1)![0]; expect(props.renderer).toBe('canvas'); @@ -105,21 +112,25 @@ describe('Chart embed', () => { ] as const)( 'uses Dashboard formatting for %s values', (unit, inputValue, chartValue, expected) => { - renderChart({ - title: 'Metric', - visualization: 'line', - x_axis: 'time', - y_axis_unit: unit, - series: [ - { - label: 'Metric', - data: [ - {x: '2026-07-30T12:00:00Z', y: inputValue}, - {x: '2026-07-30T13:00:00Z', y: inputValue}, + render( + + ); const props = jest.mocked(BaseChart).mock.calls.at(-1)![0]; const formatter = ( @@ -136,51 +147,67 @@ describe('Chart embed', () => { it.each(['not-a-timestamp', 1_785_405_600])( 'does not render invalid time-axis value %s', value => { - renderChart({ - title: 'Error volume', - x_axis: 'time', - series: [{label: 'Errors', data: [{x: value, y: 12}]}], - }); + render( + + ); expect(screen.queryByTestId('seer-chart-embed')).not.toBeInTheDocument(); } ); it.each(['line', 'area'])('does not render a category %s chart', visualization => { - renderChart({ - title: 'Invalid', - visualization, - x_axis: 'category', - series: [{label: 'Errors', data: [{x: '500', y: 12}]}], - }); + render( + + ); expect(screen.queryByTestId('seer-chart-embed')).not.toBeInTheDocument(); }); it.each(['heatmap', 'wheel'])('does not render removed %s charts', visualization => { - renderChart({ - title: 'Invalid', - visualization, - x_axis: 'category', - series: [{label: 'Errors', data: [{x: '500', y: 12}]}], - }); + render( + + ); expect(screen.queryByTestId('seer-chart-embed')).not.toBeInTheDocument(); }); it('renders the legacy series name field', () => { - renderChart({ - title: 'Legacy chart', - series: [ - { - name: 'Errors', - data: [ - {x: '2026-07-30T12:00:00Z', y: 12}, - {x: '2026-07-30T13:00:00Z', y: 18}, + render( + + ); expect(screen.getByTestId('seer-chart-embed')).toBeInTheDocument(); }); From 46cd2fc2d183dc6da0b6082ef1d29318a0976a91 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:25:20 -0700 Subject: [PATCH 06/47] test(seer): Replace the renderReleaseEmbed helper with ExampleReleaseEmbed Same shape as the chart embed: the helper built the release tag and rendered it. The block/inline choice is now a prop, so the inline case reads as level="inline" at the call site. --- .../seer/markdown/embeds/components/release.spec.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/release.spec.tsx b/static/app/components/seer/markdown/embeds/components/release.spec.tsx index 3010f69a041e..b72ffab87d6b 100644 --- a/static/app/components/seer/markdown/embeds/components/release.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/release.spec.tsx @@ -10,9 +10,9 @@ import {SeerMarkdown} from 'sentry/components/seer/markdown'; const version = 'frontend@65318d61d370'; const projectId = 4383603; -function renderReleaseEmbed(level: 'block' | 'inline' = 'block') { +function ExampleReleaseEmbed({level = 'block'}: {level?: 'block' | 'inline'}) { const tag = `{% release %}${JSON.stringify({version, projectId})}{% /release %}`; - return render(); + return ; } describe('release embed', () => { @@ -48,7 +48,7 @@ describe('release embed', () => { ], }); - renderReleaseEmbed(); + render(); expect(await screen.findByText('New Issues')).toBeInTheDocument(); expect(screen.getByText('3')).toBeInTheDocument(); @@ -97,7 +97,7 @@ describe('release embed', () => { body: [], }); - renderReleaseEmbed(); + render(); expect(await screen.findByText('2 commits')).toBeInTheDocument(); expect(screen.queryByText('2 commits by 0 authors')).not.toBeInTheDocument(); @@ -113,7 +113,7 @@ describe('release embed', () => { body: [], }); - renderReleaseEmbed('inline'); + render(); expect(screen.getByRole('link', {name: /Release:/})).toBeInTheDocument(); expect(releaseRequest).not.toHaveBeenCalled(); From 41d7637601e26b9d975e431eb0b09598557b3e9f Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:25:30 -0700 Subject: [PATCH 07/47] test(seer): Replace the renderEmbed helper with ExampleLogsQueryEmbed The helper hid the SeerMarkdown element behind a name shared with three sibling spec files that each meant something different by it. Naming the component after the embed makes each file say which tag it exercises. --- .../embeds/components/logsQuery.spec.tsx | 90 ++++++++++++------- 1 file changed, 58 insertions(+), 32 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/logsQuery.spec.tsx b/static/app/components/seer/markdown/embeds/components/logsQuery.spec.tsx index 5519b56bc416..2374811ab34e 100644 --- a/static/app/components/seer/markdown/embeds/components/logsQuery.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/logsQuery.spec.tsx @@ -20,9 +20,9 @@ const TIME_SERIES = [ }, ]; -function renderEmbed(data: Record) { +function ExampleLogsQueryEmbed({data}: {data: Record}) { const tag = `{% logsQuery %}${JSON.stringify(data)}{% /logsQuery %}`; - return render(); + return ; } describe('logs query embed', () => { @@ -54,7 +54,11 @@ describe('logs query embed', () => { }, }); - renderEmbed({query: 'severity:error', mode: 'samples', statsPeriod: '24h'}); + render( + + ); expect(await screen.findByText('Connection refused')).toBeInTheDocument(); expect(await screen.findByTestId('seer-chart-content')).toBeInTheDocument(); @@ -98,13 +102,17 @@ describe('logs query embed', () => { body: {data: [{severity: 'error', 'count(message)': 42}]}, }); - renderEmbed({ - query: '', - mode: 'aggregate', - groupBy: ['severity'], - yAxes: ['count(message)'], - statsPeriod: '7d', - }); + render( + + ); expect(await screen.findByText('error')).toBeInTheDocument(); @@ -131,14 +139,18 @@ describe('logs query embed', () => { body: {data: [{severity: 'error', 'count(message)': 42}]}, }); - renderEmbed({ - query: '', - mode: 'aggregate', - groupBy: ['severity'], - yAxes: ['count(message)'], - sort: 'severity', - statsPeriod: '7d', - }); + render( + + ); expect(await screen.findByText('error')).toBeInTheDocument(); @@ -169,7 +181,11 @@ describe('logs query embed', () => { body: {data: []}, }); - renderEmbed({query: 'severity:error', mode: 'samples', statsPeriod: '24h'}); + render( + + ); expect(await screen.findByTestId('seer-chart-content')).toBeInTheDocument(); @@ -195,12 +211,16 @@ describe('logs query embed', () => { // The logs dataset rejects an orderby that names no selected column, so // the default `-timestamp` cannot survive a field list without it. - renderEmbed({ - query: 'severity:error', - mode: 'samples', - fields: ['message'], - statsPeriod: '24h', - }); + render( + + ); expect(await screen.findByText('Connection refused')).toBeInTheDocument(); @@ -225,12 +245,16 @@ describe('logs query embed', () => { body: {data: [{timestamp: '2026-08-27T12:00:00Z', message: 'Retrying'}]}, }); - renderEmbed({ - query: '', - mode: 'samples', - sort: '-span.duration', - statsPeriod: '24h', - }); + render( + + ); expect(await screen.findByText('Retrying')).toBeInTheDocument(); @@ -252,7 +276,9 @@ describe('logs query embed', () => { body: {data: []}, }); - renderEmbed({query: '', mode: 'aggregate', statsPeriod: '24h'}); + render( + + ); expect(await screen.findByTestId('seer-chart-content')).toBeInTheDocument(); expect(screen.queryByRole('table')).not.toBeInTheDocument(); From 904c40d351c74ba3d8bdff4e45f2d8b1166aa30e Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:25:39 -0700 Subject: [PATCH 08/47] test(seer): Replace the renderEmbed helper with ExampleErrorsQueryEmbed Same change as the sibling embed specs. The data payload and the block/inline choice are now props, so a reader sees both at the call site instead of in an object argument to a local helper. --- .../embeds/components/errorsQuery.spec.tsx | 121 ++++++++++-------- 1 file changed, 66 insertions(+), 55 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/errorsQuery.spec.tsx b/static/app/components/seer/markdown/embeds/components/errorsQuery.spec.tsx index 53a2d3ac51ab..ea68bb1ee1d8 100644 --- a/static/app/components/seer/markdown/embeds/components/errorsQuery.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/errorsQuery.spec.tsx @@ -12,7 +12,7 @@ const SERIES = [ [1_700_003_600, [{count: 8}]], ]; -function renderEmbed({ +function ExampleErrorsQueryEmbed({ data, level = 'block', }: { @@ -20,7 +20,7 @@ function renderEmbed({ level?: 'block' | 'inline'; }) { const tag = `{% errorsQuery %}${JSON.stringify(data)}{% /errorsQuery %}`; - return render(); + return ; } describe('errors query embed', () => { @@ -41,16 +41,18 @@ describe('errors query embed', () => { body: {data: SERIES}, }); - renderEmbed({ - data: { - mode: 'samples', - query: 'event.type:error', - fields: ['title', 'project', 'timestamp'], - sort: '-timestamp', - statsPeriod: '24h', - title: 'Recent errors', - }, - }); + render( + + ); expect(await screen.findByText('Error 1')).toBeInTheDocument(); expect(screen.getByText('Error 5')).toBeInTheDocument(); @@ -116,17 +118,19 @@ describe('errors query embed', () => { body: {data: SERIES}, }); - renderEmbed({ - data: { - mode: 'aggregate', - query: '', - fields: ['title', 'project', 'count_unique(user)'], - sort: '-count_unique_user', - statsPeriod: '1h', - yAxes: ['count()'], - title: 'Errors by title', - }, - }); + render( + + ); expect(await screen.findByText('TypeError')).toBeInTheDocument(); expect(screen.getByText('1,234')).toBeInTheDocument(); @@ -161,16 +165,18 @@ describe('errors query embed', () => { body: {data: SERIES}, }); - renderEmbed({ - data: { - mode: 'aggregate', - query: 'event.type:error', - fields: ['title', 'project', 'count()'], - sort: '-count', - statsPeriod: '24h', - title: 'Errors by title', - }, - }); + render( + + ); expect(await screen.findByTestId('seer-chart-content')).toBeInTheDocument(); @@ -205,15 +211,17 @@ describe('errors query embed', () => { body: {data: SERIES}, }); - renderEmbed({ - data: { - mode: 'aggregate', - query: 'event.type:error', - fields: ['count()'], - statsPeriod: '1h', - title: 'Error count', - }, - }); + render( + + ); expect(await screen.findByTestId('seer-chart-content')).toBeInTheDocument(); expect(screen.getAllByLabelText('event.type:error').length).toBeGreaterThan(0); @@ -247,13 +255,15 @@ describe('errors query embed', () => { body: {data: SERIES}, }); - const {router} = renderEmbed({ - data: { - mode: 'samples', - query: 'event.type:error', - fields: ['title', 'project', 'timestamp'], - }, - }); + const {router} = render( + + ); expect(await screen.findByText('Error 1')).toBeInTheDocument(); @@ -287,12 +297,13 @@ describe('errors query embed', () => { body: {data: []}, }); - renderEmbed({ - // No `mode`, so this also pins the schema default. `errorsQuery` shipped - // before the mode existed, and samples is what it used to do. - data: {query: 'is:unresolved'}, - level: 'inline', - }); + render( + + ); expect(screen.getByRole('link', {name: 'Error search'})).toBeInTheDocument(); expect(request).not.toHaveBeenCalled(); From 1ae666640f226cd861ea4ff6c82abc28353fcb54 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:25:45 -0700 Subject: [PATCH 09/47] test(seer): Replace the renderEmbed helper with ExampleMetricsQueryEmbed Same change as the sibling embed specs, finishing the group so no two files use the name renderEmbed for different tags. --- .../embeds/components/metricsQuery.spec.tsx | 60 +++++++++++-------- 1 file changed, 36 insertions(+), 24 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/metricsQuery.spec.tsx b/static/app/components/seer/markdown/embeds/components/metricsQuery.spec.tsx index f09fee7fdc89..43918d9fdcef 100644 --- a/static/app/components/seer/markdown/embeds/components/metricsQuery.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/metricsQuery.spec.tsx @@ -28,7 +28,7 @@ const TIME_SERIES = [ }, ]; -function renderEmbed({ +function ExampleMetricsQueryEmbed({ data, level = 'block', }: { @@ -36,15 +36,17 @@ function renderEmbed({ level?: 'block' | 'inline'; }) { const tag = `{% metricsQuery %}${JSON.stringify(data)}{% /metricsQuery %}`; - return render(); + return ; } describe('metrics query embed', () => { it('qualifies a bare y-axis with the metric it measures', () => { - renderEmbed({ - data: {...METRIC, mode: 'aggregate', query: '', yAxes: ['p95(value)']}, - level: 'inline', - }); + render( + + ); // Seer emits `p95(value)`; Explore only decodes the qualified spelling, and // refuses a query that charts nothing. @@ -57,10 +59,12 @@ describe('metrics query embed', () => { }); it('falls back to the default aggregate for the metric type', () => { - renderEmbed({ - data: {...METRIC, mode: 'aggregate', query: ''}, - level: 'inline', - }); + render( + + ); // `distribution` defaults to `sum`, not a blanket `sum(value)`. const href = screen @@ -85,16 +89,18 @@ describe('metrics query embed', () => { }, }); - renderEmbed({ - data: { - ...METRIC, - mode: 'aggregate', - query: 'release:1.0', - groupBy: ['service.name'], - yAxes: ['p95(value)'], - statsPeriod: '24h', - }, - }); + render( + + ); expect(await screen.findByTestId('seer-chart-content')).toBeInTheDocument(); expect(await screen.findByText('checkout')).toBeInTheDocument(); @@ -140,9 +146,11 @@ describe('metrics query embed', () => { body: {data: []}, }); - renderEmbed({ - data: {...METRIC, mode: 'aggregate', query: '', yAxes: ['p95(value)']}, - }); + render( + + ); expect(await screen.findByTestId('seer-chart-content')).toBeInTheDocument(); expect(screen.queryByRole('table')).not.toBeInTheDocument(); @@ -159,7 +167,11 @@ describe('metrics query embed', () => { body: {data: [{id: '1', 'metric.value': 42, timestamp: '2026-08-27T12:00:00Z'}]}, }); - renderEmbed({data: {...METRIC, mode: 'samples', query: 'release:1.0'}}); + render( + + ); await waitFor(() => { expect(table).toHaveBeenCalledWith( From 25bc9da2c04b0283a887a4fdf6a3232ba8c033ff Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:27:21 -0700 Subject: [PATCH 10/47] test(acl): Drop the renderComponent wrapper in featureDisabledModal specs The helper supported prop overrides but had a single call site, which passed the only two props that mattered. Rendering the modal inline makes that test self-contained. --- .../acl/featureDisabledModal.spec.tsx | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/static/app/components/acl/featureDisabledModal.spec.tsx b/static/app/components/acl/featureDisabledModal.spec.tsx index 3a16b0ee393a..9ded7ffb7c7b 100644 --- a/static/app/components/acl/featureDisabledModal.spec.tsx +++ b/static/app/components/acl/featureDisabledModal.spec.tsx @@ -8,9 +8,14 @@ import {FeatureDisabledModal} from 'sentry/components/acl/featureDisabledModal'; describe('FeatureTourModal', () => { const onCloseModal = jest.fn(); const styledWrapper = styled((c: PropsWithChildren) => c.children); - const renderComponent = ( - props: Partial> = {} - ) => + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders', () => { + const featureName = 'Custom Feature'; + const features = ['organization:custom-feature']; + render( { Header={() => Header} closeModal={onCloseModal} CloseButton={() => } - featureName="Default Feature" - features="organization:test-feature" - {...props} + featureName={featureName} + features={features} /> ); - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('renders', () => { - const featureName = 'Custom Feature'; - const features = ['organization:custom-feature']; - - renderComponent({ - featureName, - features, - }); - expect( screen.getByText('This feature is not enabled on your Sentry installation.') ).toBeInTheDocument(); From f0bae90a6add7a7d7a69384ebc94fceabb707350 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:27:26 -0700 Subject: [PATCH 11/47] test(issueDetails): Drop the renderAllEvents wrapper in eventList specs One call site, one fixed element. The router config now sits next to the render call it configures. --- static/app/views/issueDetails/eventList.spec.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/static/app/views/issueDetails/eventList.spec.tsx b/static/app/views/issueDetails/eventList.spec.tsx index a1717fe469c9..c4ca74242443 100644 --- a/static/app/views/issueDetails/eventList.spec.tsx +++ b/static/app/views/issueDetails/eventList.spec.tsx @@ -76,14 +76,8 @@ describe('EventList', () => { }); }); - function renderAllEvents() { - render(, { - initialRouterConfig, - }); - } - it('renders the list using a discover event query', async () => { - renderAllEvents(); + render(, {initialRouterConfig}); const {result} = renderHook(() => useEventColumns(group, organization)); expect(await screen.findByText('All Events')).toBeInTheDocument(); From cbf8793f70c332a7b8675083554dbb172bb78f9c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:27:34 -0700 Subject: [PATCH 12/47] test(issueDetails): Replace the renderReason helper with ExampleResolutionReason Six tests called the helper, so the element they exercised was never visible in any of them. Hoisting the organization fixture lets the component hold only the props, with the render option staying at the call site. --- .../actions/resolutionReason.spec.tsx | 121 ++++++++++-------- 1 file changed, 70 insertions(+), 51 deletions(-) diff --git a/static/app/views/issueDetails/actions/resolutionReason.spec.tsx b/static/app/views/issueDetails/actions/resolutionReason.spec.tsx index a5891d61cee6..c656888a7058 100644 --- a/static/app/views/issueDetails/actions/resolutionReason.spec.tsx +++ b/static/app/views/issueDetails/actions/resolutionReason.spec.tsx @@ -34,26 +34,30 @@ const activity = { user: actor, } satisfies GroupActivity; -function renderReason({ +const organization = OrganizationFixture(); + +function ExampleResolutionReason({ activities = [activity], statusDetails, }: { statusDetails: ResolvedStatusDetails; activities?: GroupActivity[]; }) { - return render( + return ( , - {organization: OrganizationFixture()} + /> ); } describe('ResolutionReason', () => { it('shows the resolving pull request and canonical release for activity', () => { - const {container} = renderReason({statusDetails: {actor, inRelease: release}}); + const {container} = render( + , + {organization} + ); expect(container).toHaveTextContent( 'David Cramer resolved via #1234 released in 1.2.3' @@ -65,30 +69,36 @@ describe('ResolutionReason', () => { }); it('shows an exact release without a pull request for activity', () => { - const {container} = renderReason({ - activities: [ - { - ...activity, - data: {version: 'frontend@1.2.3'}, - }, - ], - statusDetails: {actor, inRelease: release}, - }); + const {container} = render( + , + {organization} + ); expect(container).toHaveTextContent('David Cramer resolved in 1.2.3'); expect(screen.queryByRole('link', {name: '#1234'})).not.toBeInTheDocument(); }); it('shows the first release that will contain the resolution for activity', () => { - const {container} = renderReason({ - activities: [ - { - ...activity, - data: {current_release_version: 'backend@1.0.0'}, - }, - ], - statusDetails: {actor, inNextRelease: true}, - }); + const {container} = render( + , + {organization} + ); expect(container).toHaveTextContent( 'David Cramer resolved starting with a release after 1.0.0' @@ -96,10 +106,13 @@ describe('ResolutionReason', () => { }); it('shows an upcoming release without a known current release for activity', () => { - const {container} = renderReason({ - activities: [], - statusDetails: {actor, inNextRelease: true}, - }); + const {container} = render( + , + {organization} + ); expect(container).toHaveTextContent( 'David Cramer set this to resolve in the upcoming release' @@ -108,18 +121,21 @@ describe('ResolutionReason', () => { it('shows the resolving commit for activity', () => { const commit = CommitFixture({repository}); - const {container} = renderReason({ - activities: [ - { - type: GroupActivityType.SET_RESOLVED_IN_COMMIT, - id: 'resolved-in-commit-1', - dateCreated: '2020-01-01T00:00:00', - data: {commit}, - user: actor, - }, - ], - statusDetails: {inCommit: {commit: commit.id}}, - }); + const {container} = render( + , + {organization} + ); expect(container).toHaveTextContent('David Cramer resolved via f7f395d'); expect(screen.getByRole('link', {name: /f7f395d/})).toHaveAttribute( @@ -130,18 +146,21 @@ describe('ResolutionReason', () => { it('prefers the pull request associated with a resolving commit', () => { const commit = CommitFixture({pullRequest, repository}); - const {container} = renderReason({ - activities: [ - { - type: GroupActivityType.SET_RESOLVED_IN_COMMIT, - id: 'resolved-in-commit-1', - dateCreated: '2020-01-01T00:00:00', - data: {commit}, - user: actor, - }, - ], - statusDetails: {inCommit: {commit: commit.id}}, - }); + const {container} = render( + , + {organization} + ); expect(container).toHaveTextContent('David Cramer resolved via #1234'); expect(screen.getByRole('link', {name: '#1234'})).toHaveAttribute( From 5749622e290854d0479dce4d7162c9f9384f62a8 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:28:54 -0700 Subject: [PATCH 13/47] test(onboarding): Replace the renderComponent helper with ExampleCreateSampleEventButton Four tests shared a helper that rendered a fixed button and set the organization. The component now holds the element and the organization option moves to the call sites that need it. --- .../onboarding/createSampleEventButton.spec.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/static/app/components/onboarding/createSampleEventButton.spec.tsx b/static/app/components/onboarding/createSampleEventButton.spec.tsx index f8719fa62dbf..caf840842843 100644 --- a/static/app/components/onboarding/createSampleEventButton.spec.tsx +++ b/static/app/components/onboarding/createSampleEventButton.spec.tsx @@ -15,17 +15,14 @@ describe('CreateSampleEventButton', () => { const groupID = '123'; const createSampleText = 'Create a sample event'; - function renderComponent() { - return render( + function ExampleCreateSampleEventButton() { + return ( {createSampleText} - , - { - organization: org, - } + ); } @@ -34,7 +31,7 @@ describe('CreateSampleEventButton', () => { }); it('creates a sample event', async () => { - const {router} = renderComponent(); + const {router} = render(, {organization: org}); MockApiClient.addMockResponse({ url: `/projects/${org.slug}/${project.slug}/create-sample/`, method: 'POST', @@ -63,7 +60,7 @@ describe('CreateSampleEventButton', () => { }); it('fires the legacy view sample event when hasScmOnboarding is not set', async () => { - renderComponent(); + render(, {organization: org}); MockApiClient.addMockResponse({ url: `/projects/${org.slug}/${project.slug}/create-sample/`, method: 'POST', @@ -129,7 +126,7 @@ describe('CreateSampleEventButton', () => { it('waits for the latest event to be processed', async () => { jest.useFakeTimers(); - const {router} = renderComponent(); + const {router} = render(, {organization: org}); const createRequest = MockApiClient.addMockResponse({ url: `/projects/${org.slug}/${project.slug}/create-sample/`, method: 'POST', From 5d0e79060060311f797c825a9ae824988e0ab9cd Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:29:00 -0700 Subject: [PATCH 14/47] test(replays): Replace renderWithOrganization with ExampleReplayTableHeader The old name described the render option rather than what was rendered, which was a fixed SimpleTable wrapping one header column. --- .../replays/table/replayTableHeader.spec.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/static/app/components/replays/table/replayTableHeader.spec.tsx b/static/app/components/replays/table/replayTableHeader.spec.tsx index 874573994ed0..b0dd0671aad4 100644 --- a/static/app/components/replays/table/replayTableHeader.spec.tsx +++ b/static/app/components/replays/table/replayTableHeader.spec.tsx @@ -39,8 +39,8 @@ function baseListCheckboxState(overrides: Partial) { }; } -function renderWithOrganization() { - render( +function ExampleReplayTableHeader() { + return ( - , - {organization: OrganizationFixture()} + ); } @@ -64,7 +63,7 @@ describe('ReplayTableHeader', () => { baseListCheckboxState({isAnySelected: false, selectedIds: []}) ); - renderWithOrganization(); + render(, {organization: OrganizationFixture()}); expect(screen.queryByTestId('replay-bulk-viewed-actions')).not.toBeInTheDocument(); expect(screen.queryByTestId('delete-replays')).not.toBeInTheDocument(); @@ -80,7 +79,7 @@ describe('ReplayTableHeader', () => { }) ); - renderWithOrganization(); + render(, {organization: OrganizationFixture()}); expect(screen.queryByTestId('replay-bulk-viewed-actions')).not.toBeInTheDocument(); expect(screen.getByTestId('delete-replays')).toBeInTheDocument(); @@ -96,7 +95,7 @@ describe('ReplayTableHeader', () => { }) ); - renderWithOrganization(); + render(, {organization: OrganizationFixture()}); expect(screen.getByTestId('replay-bulk-viewed-actions')).toBeInTheDocument(); expect(screen.getByTestId('delete-replays')).toBeInTheDocument(); From 8fce52e3b10fa671780fdd75b61fc5bf2f231b27 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:29:04 -0700 Subject: [PATCH 15/47] test(commandPalette): Replace renderFeatureFlagActions with ExampleFeatureFlagActions The helper took an organization only to forward it to render(). Making that a render option at the call site leaves the component holding just the fragment under test. --- .../ui/featureFlagCommandPaletteActions.spec.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.spec.tsx b/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.spec.tsx index fff7cd3086c7..5a6f32ffd004 100644 --- a/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.spec.tsx +++ b/static/app/components/commandPalette/ui/featureFlagCommandPaletteActions.spec.tsx @@ -36,15 +36,14 @@ function SlotOutlets() { ); } -function renderFeatureFlagActions(organization = OrganizationFixture()) { - render( +function ExampleFeatureFlagActions() { + return ( - , - {organization} + ); } @@ -74,7 +73,7 @@ describe('FeatureFlagCommandPaletteActions', () => { it('toggles an existing feature flag without reloading', async () => { const organization = OrganizationFixture({features: ['enabled-feature']}); - renderFeatureFlagActions(organization); + render(, {organization}); await openCommandPalette(); await userEvent.type( @@ -96,7 +95,7 @@ describe('FeatureFlagCommandPaletteActions', () => { it('keeps a disabled feature flag in the list after returning to it', async () => { const organization = OrganizationFixture({features: ['enabled-feature']}); - renderFeatureFlagActions(organization); + render(, {organization}); await openCommandPalette(); await userEvent.type( @@ -131,7 +130,7 @@ describe('FeatureFlagCommandPaletteActions', () => { it('adds a new enabled feature flag from the modal', async () => { const organization = OrganizationFixture({features: []}); - renderFeatureFlagActions(organization); + render(, {organization}); await openCommandPalette(); await userEvent.type( From 6a7fcf73c104d59f39015c21e7e7fbeb54cd82ea Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:29:59 -0700 Subject: [PATCH 16/47] test(stackTrace): Replace the renderStackTrace helper with ExampleStackTrace Ten tests called a no-argument helper, so none of them showed the provider tree they depended on. The fixture data is built in a useMemo so it stays stable for the life of the mounted component, matching what the helper gave each call. --- .../components/stackTrace/stackTrace.spec.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/static/app/components/stackTrace/stackTrace.spec.tsx b/static/app/components/stackTrace/stackTrace.spec.tsx index 76d45e5addc7..071168dfe43d 100644 --- a/static/app/components/stackTrace/stackTrace.spec.tsx +++ b/static/app/components/stackTrace/stackTrace.spec.tsx @@ -1,4 +1,4 @@ -import type {ComponentProps} from 'react'; +import {useMemo, type ComponentProps} from 'react'; import {DataScrubbingRelayPiiConfigFixture} from 'sentry-fixture/dataScrubbingRelayPiiConfig'; import {EventFixture} from 'sentry-fixture/event'; import {EventEntryStacktraceFixture} from 'sentry-fixture/eventEntryStacktrace'; @@ -85,10 +85,10 @@ function TestStackTraceProvider({ ); } -function renderStackTrace() { - const {event, stacktrace} = makeStackTraceData(); +function ExampleStackTrace() { + const {event, stacktrace} = useMemo(() => makeStackTraceData(), []); - render( + return ( @@ -116,7 +116,7 @@ describe('Core StackTrace', () => { }); it('switches between app and full stack views', async () => { - renderStackTrace(); + render(); expect(screen.getAllByTestId('core-stacktrace-frame-row')).toHaveLength(4); @@ -127,7 +127,7 @@ describe('Core StackTrace', () => { }); it('toggles frame ordering', async () => { - renderStackTrace(); + render(); expect(screen.getAllByTestId('core-stacktrace-frame-title')[0]).toHaveTextContent( 'raven/scripts/runner.py' @@ -142,7 +142,7 @@ describe('Core StackTrace', () => { }); it('supports raw stack trace view', async () => { - renderStackTrace(); + render(); await userEvent.click(screen.getByRole('button', {name: 'Display options'})); await userEvent.click(await screen.findByRole('option', {name: 'Raw Stack Trace'})); @@ -192,7 +192,7 @@ describe('Core StackTrace', () => { }); it('toggles frame expansion', async () => { - renderStackTrace(); + render(); expect(screen.getByTestId('core-stacktrace-frame-context')).toBeInTheDocument(); @@ -217,7 +217,7 @@ describe('Core StackTrace', () => { }); it('toggles frame expansion when clicking the right trailing area', async () => { - renderStackTrace(); + render(); const firstTrailingArea = screen.getAllByTestId('core-stacktrace-frame-trailing')[0]!; @@ -248,7 +248,7 @@ describe('Core StackTrace', () => { }); it('shows and hides collapsed system frames', async () => { - renderStackTrace(); + render(); const toggleButton = screen.getByRole('button', {name: 'Show 1 more frame'}); @@ -259,14 +259,14 @@ describe('Core StackTrace', () => { }); it('renders frame badges for in-app frames only', async () => { - renderStackTrace(); + render(); expect((await screen.findAllByText('In App')).length).toBeGreaterThan(0); expect(screen.queryByText('System')).not.toBeInTheDocument(); }); it('renders captured python frame variables', async () => { - renderStackTrace(); + render(); expect(await screen.findByText('args')).toBeInTheDocument(); expect(screen.getByText('dsn')).toBeInTheDocument(); @@ -374,7 +374,7 @@ describe('Core StackTrace', () => { }); it('renders lead hint when non-app frame leads to app frame', async () => { - renderStackTrace(); + render(); expect(await screen.findByText('Called from:')).toBeInTheDocument(); }); From 09cda9e9819f70e497d4c8abdb23a7ddc275570a Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:30:04 -0700 Subject: [PATCH 17/47] test(events): Replace the renderViewer helper with ExampleLogFileViewer The helper rendered a fixed viewer built from the describe-level fixtures. Naming it after the component makes the three call sites say what they render. --- .../events/attachmentViewers/logFileViewer.spec.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx index 51a8bc3b7132..753f4ff9b9b0 100644 --- a/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx +++ b/static/app/components/events/attachmentViewers/logFileViewer.spec.tsx @@ -19,8 +19,8 @@ describe('LogFileViewer', () => { }); const attachmentUrl = `/api/0/projects/${organization.id}/${project.slug}/events/${event.id}/attachments/${attachment.id}/?download`; - function renderViewer() { - render( + function ExampleLogFileViewer() { + return ( { ]); fetchMock.route(attachmentUrl, fetchMock.Response(bytes)); - renderViewer(); + render(); expect(await screen.findByText('A中')).toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith( @@ -61,7 +61,7 @@ describe('LogFileViewer', () => { it('renders an error when the attachment cannot be downloaded', async () => { fetchMock.route(attachmentUrl, '', {status: 404}); - renderViewer(); + render(); expect(await screen.findByText('Failed to download attachment.')).toBeInTheDocument(); }); From afb3454c30ebab8dccc6064b6e168850a1dc4ce4 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:30:32 -0700 Subject: [PATCH 18/47] test(timeRangeSelector): Replace getComponent/renderComponent with ExampleTimeRangeSelector The file had two layers of indirection: getComponent built the element and renderComponent rendered it. One component covers both, and the thirteen call sites now show which props each test varies. --- .../timeRangeSelector/index.spec.tsx | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/static/app/components/timeRangeSelector/index.spec.tsx b/static/app/components/timeRangeSelector/index.spec.tsx index 342763ff2f4c..9a79873b2713 100644 --- a/static/app/components/timeRangeSelector/index.spec.tsx +++ b/static/app/components/timeRangeSelector/index.spec.tsx @@ -1,3 +1,4 @@ +import type {ComponentProps} from 'react'; import {ConfigFixture} from 'sentry-fixture/config'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {UserFixture} from 'sentry-fixture/user'; @@ -12,14 +13,12 @@ const organization = OrganizationFixture({features: ['open-membership']}); describe('TimeRangeSelector', () => { const onChange = jest.fn(); - function getComponent(props = {}) { + function ExampleTimeRangeSelector( + props: Partial> + ) { return ; } - function renderComponent(props = {}) { - return render(getComponent(props)); - } - beforeEach(() => { ConfigStore.loadInitialData( ConfigFixture({ @@ -32,7 +31,7 @@ describe('TimeRangeSelector', () => { }); it('renders when given relative period', async () => { - renderComponent({relative: '9d'}); + render(); expect(await screen.findByRole('button', {name: '9D'})).toBeInTheDocument(); }); @@ -46,7 +45,7 @@ describe('TimeRangeSelector', () => { }); it('hides relative options', async () => { - renderComponent({showRelative: false, start: '0', end: '0'}); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); @@ -63,7 +62,7 @@ describe('TimeRangeSelector', () => { }); it('hides absolute selector', async () => { - renderComponent({showAbsolute: false}); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); @@ -73,7 +72,7 @@ describe('TimeRangeSelector', () => { }); it('can select an absolute date range', async () => { - renderComponent(); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); await userEvent.click(screen.getByRole('option', {name: 'Absolute date'})); @@ -104,7 +103,7 @@ describe('TimeRangeSelector', () => { }); it('can select an absolute range with utc enabled', async () => { - renderComponent({utc: true}); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); await userEvent.click(screen.getByRole('option', {name: 'Absolute date'})); @@ -136,7 +135,7 @@ describe('TimeRangeSelector', () => { }); it('keeps time inputs focused while interacting with them', async () => { - renderComponent(); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); await userEvent.click(screen.getByRole('option', {name: 'Absolute date'})); @@ -151,7 +150,7 @@ describe('TimeRangeSelector', () => { }); it('switches from relative to absolute and then toggling UTC (starting with UTC)', async () => { - renderComponent({relative: '7d', utc: true}); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); await userEvent.click(screen.getByRole('option', {name: 'Absolute date'})); @@ -173,7 +172,7 @@ describe('TimeRangeSelector', () => { }); it('switches from relative to absolute and then toggling UTC (starting with non-UTC)', async () => { - renderComponent({relative: '7d', utc: false}); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); await userEvent.click(screen.getByRole('option', {name: 'Absolute date'})); @@ -194,12 +193,14 @@ describe('TimeRangeSelector', () => { }); it('uses the default absolute date', async () => { - renderComponent({ - defaultAbsolute: { - start: new Date('2017-10-10T00:00:00.000Z'), - end: new Date('2017-10-17T23:59:59.000Z'), - }, - }); + render( + + ); await userEvent.click(screen.getByRole('button', {expanded: false})); await userEvent.click(screen.getByRole('option', {name: 'Absolute date'})); @@ -210,7 +211,7 @@ describe('TimeRangeSelector', () => { }); it('can select arbitrary relative time ranges', async () => { - renderComponent(); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); @@ -247,7 +248,7 @@ describe('TimeRangeSelector', () => { }); it('respects maxPickableDays for defaults', async () => { - renderComponent({maxPickableDays: 30}); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); @@ -255,7 +256,7 @@ describe('TimeRangeSelector', () => { }); it('respects maxPickableDays for arbitrary time ranges', async () => { - renderComponent({maxPickableDays: 30}); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); @@ -283,7 +284,7 @@ describe('TimeRangeSelector', () => { }); it('cannot select arbitrary relative time ranges with disallowArbitraryRelativeRanges', async () => { - renderComponent({disallowArbitraryRelativeRanges: true}); + render(); await userEvent.click(screen.getByRole('button', {expanded: false})); From f16cc1bbc9802e9917382abe0f368bc5b6a2e764 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:31:45 -0700 Subject: [PATCH 19/47] test(resultGrid): Replace renderBasicGrid with ExampleBasicResultGrid Five tests shared a helper holding the grid's fixed columns and sort. The sibling renderGrid helper in this file is left alone: it builds the router config from its arguments, so inlining it would repeat that config across twenty-nine call sites. --- static/app/components/resultGrid.spec.tsx | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/static/app/components/resultGrid.spec.tsx b/static/app/components/resultGrid.spec.tsx index f85411be1627..ee996a6948b9 100644 --- a/static/app/components/resultGrid.spec.tsx +++ b/static/app/components/resultGrid.spec.tsx @@ -19,10 +19,10 @@ describe('ResultGrid', () => { ); } - function renderBasicGrid( - extraProps: Partial> = {} + function ExampleBasicResultGrid( + extraProps: Partial> ) { - return render( + return ( { headers: {Link: makeLinkHeader()}, }); - renderBasicGrid(); + render(); expect(await screen.findByText('alpha')).toBeInTheDocument(); expect(screen.getByText('beta')).toBeInTheDocument(); @@ -63,7 +63,7 @@ describe('ResultGrid', () => { headers: {Link: makeLinkHeader()}, }); - const {router} = renderBasicGrid(); + const {router} = render(); await userEvent.type(await screen.findByPlaceholderText('Search'), 'hello'); await userEvent.click(screen.getByRole('button', {name: 'Search'})); @@ -81,7 +81,7 @@ describe('ResultGrid', () => { headers: {Link: makeLinkHeader()}, }); - const {router} = renderBasicGrid(); + const {router} = render(); await screen.findByTestId('pagination'); @@ -103,9 +103,11 @@ describe('ResultGrid', () => { headers: {Link: makeLinkHeader()}, }); - const {router} = renderBasicGrid({ - filters: {status: {name: 'Status', options: [['active', 'Active']]}}, - }); + const {router} = render( + + ); await screen.findByTestId('pagination'); await userEvent.click(screen.getByRole('button', {name: /Status/})); @@ -127,7 +129,7 @@ describe('ResultGrid', () => { cancel: () => {}, }); - renderBasicGrid(); + render(); const alert = await screen.findByText('Something bad happened :/'); expect(alert).toBeInTheDocument(); From c4e6ca62de2ccb27b1d6bf3314f510c8211425ea Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:32:49 -0700 Subject: [PATCH 20/47] test(onboarding): Replace renderCore with ExampleScmProjectDetailsCore The helper built a full props object and returned it, but neither call site used the return value, so it was only hiding the element. The defaults now live in the component and each test states the one prop it varies. --- .../scm/scmProjectDetailsCore.spec.tsx | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/static/app/components/onboarding/scm/scmProjectDetailsCore.spec.tsx b/static/app/components/onboarding/scm/scmProjectDetailsCore.spec.tsx index 954c2a64dfd6..562669fdbec0 100644 --- a/static/app/components/onboarding/scm/scmProjectDetailsCore.spec.tsx +++ b/static/app/components/onboarding/scm/scmProjectDetailsCore.spec.tsx @@ -6,31 +6,32 @@ import {ScmProjectDetailsCore} from './scmProjectDetailsCore'; type CoreProps = React.ComponentProps; -function renderCore(overrides: Partial = {}) { - const props: CoreProps = { - projectName: 'my-project', - onProjectNameChange: jest.fn(), - onProjectNameBlur: jest.fn(), - teamSlug: 'my-team', - onTeamChange: jest.fn(), - isOrgMemberWithNoAccess: false, - ...overrides, - }; - - render(, {organization: OrganizationFixture()}); - return props; +function ExampleScmProjectDetailsCore(overrides: Partial) { + return ( + + ); } describe('ScmProjectDetailsCore', () => { it('labels the project name and team fields', () => { - renderCore(); + render(, {organization: OrganizationFixture()}); expect(screen.getByRole('textbox', {name: 'Project name'})).toHaveValue('my-project'); expect(screen.getByRole('textbox', {name: 'Team'})).toBeInTheDocument(); }); it('hides the team selector for a no-access member', () => { - renderCore({isOrgMemberWithNoAccess: true}); + render(, { + organization: OrganizationFixture(), + }); expect(screen.getByRole('textbox', {name: 'Project name'})).toBeInTheDocument(); expect(screen.queryByRole('textbox', {name: 'Team'})).not.toBeInTheDocument(); From 65e5b02ea21de21b119ff3e748ca8fe1120bfcba Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:32:53 -0700 Subject: [PATCH 21/47] test(onboarding): Replace renderActions with ExampleRowActions visualState was a positional argument, so a reader had to check the helper signature to know what the first string meant. It is now a named prop at each call site. --- .../scmMessagingProviderRow/action.spec.tsx | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/static/app/components/onboarding/scm/scmMessagingProviderRow/action.spec.tsx b/static/app/components/onboarding/scm/scmMessagingProviderRow/action.spec.tsx index a13cf88a23bc..b2f2bf746856 100644 --- a/static/app/components/onboarding/scm/scmMessagingProviderRow/action.spec.tsx +++ b/static/app/components/onboarding/scm/scmMessagingProviderRow/action.spec.tsx @@ -22,11 +22,11 @@ const permissionLimitedSlack: ScmMessagingResolvedProvider = { status: 'permission-limited', }; -function renderActions( - visualState: RowVisualState, - overrides: Partial> = {} -) { - return render( +function ExampleRowActions({ + visualState, + ...overrides +}: {visualState: RowVisualState} & Partial>) { + return ( { describe.each(['loading', 'installing'])('%s state', visualState => { it('shows a spinner', () => { - renderActions(visualState); + render(); expect(screen.getByTestId('loading-indicator')).toBeInTheDocument(); }); }); describe('installable state', () => { it('renders an enabled Connect button', () => { - renderActions('installable'); + render(); expect(screen.getByRole('button', {name: /Connect Slack/})).toBeEnabled(); }); }); @@ -61,14 +61,19 @@ describe('RowActions', () => { ['permission-limited', permissionLimitedSlack], ])('%s state', (visualState, resolvedProvider) => { it('renders a disabled Connect button', () => { - renderActions(visualState, {resolvedProvider}); + render( + + ); expect(screen.getByRole('button', {name: /Connect/})).toBeDisabled(); }); }); describe('choose-destination state', () => { it('renders the Choose destination button', () => { - renderActions('choose-destination'); + render(); expect( screen.getByRole('button', {name: /Choose destination for Slack/}) ).toBeInTheDocument(); @@ -77,7 +82,7 @@ describe('RowActions', () => { describe('configured state', () => { it('renders Edit and Remove buttons', () => { - renderActions('configured'); + render(); expect(screen.getByRole('button', {name: /Edit/})).toBeInTheDocument(); expect(screen.getByRole('button', {name: /Remove/})).toBeInTheDocument(); @@ -86,7 +91,7 @@ describe('RowActions', () => { describe('removing state', () => { it('renders Cancel and Remove buttons', () => { - renderActions('removing'); + render(); expect(screen.getByRole('button', {name: /Cancel/})).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Remove'})).toBeInTheDocument(); @@ -97,7 +102,7 @@ describe('RowActions', () => { '%s state', visualState => { it('renders nothing', () => { - const {container} = renderActions(visualState); + const {container} = render(); expect(container).toBeEmptyDOMElement(); }); } From 7433f455c375184b9eb492dfa2ff4aa1c744fe2e Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:33:51 -0700 Subject: [PATCH 22/47] test(charts): Drop the renderComponent arrow in optionSelector specs The file already had a TestComponent holding the stateful wiring; renderComponent only called render on it. --- static/app/components/charts/optionSelector.spec.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/static/app/components/charts/optionSelector.spec.tsx b/static/app/components/charts/optionSelector.spec.tsx index b6e04ae7d063..f61db2df0be6 100644 --- a/static/app/components/charts/optionSelector.spec.tsx +++ b/static/app/components/charts/optionSelector.spec.tsx @@ -36,12 +36,8 @@ describe('Charts > OptionSelector (Multiple)', () => { ); } - const renderComponent = () => { - return render(); - }; - it('renders yAxisOptions with yAxisValue selected', async () => { - renderComponent(); + render(); expect(await screen.findByRole('option', {name: 'count()'})).toHaveAttribute( 'aria-selected', 'true' @@ -57,7 +53,7 @@ describe('Charts > OptionSelector (Multiple)', () => { }); it('calls onChange prop with new checkbox option state', async () => { - renderComponent(); + render(); await userEvent.click(screen.getByRole('option', {name: 'count()'})); expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']); onChangeStub.mockClear(); @@ -79,7 +75,7 @@ describe('Charts > OptionSelector (Multiple)', () => { }); it('does not uncheck options when clicked if only one option is currently selected', async () => { - renderComponent(); + render(); await userEvent.click(screen.getByRole('option', {name: 'count()'})); expect(onChangeStub).toHaveBeenCalledWith(['failure_count()']); await userEvent.click(screen.getByRole('option', {name: 'failure_count()'})); @@ -87,7 +83,7 @@ describe('Charts > OptionSelector (Multiple)', () => { }); it('only allows up to 3 options to be checked at one time', async () => { - renderComponent(); + render(); await userEvent.click(screen.getByRole('option', {name: 'count_unique(user)'})); expect(onChangeStub).toHaveBeenCalledWith([ 'count()', From b9ab9e697bec388e45e282703f506c88aee41426 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:35:03 -0700 Subject: [PATCH 23/47] test(explore): Replace renderTooltip with ExampleDroppedDataTooltip The helper turned the dropped and accepted annotations into a bucket before rendering. The component keeps that derivation and gives the seven call sites named props instead of two positional arrays. --- .../droppedDataTooltip.spec.tsx | 144 +++++++++++------- 1 file changed, 87 insertions(+), 57 deletions(-) diff --git a/static/app/views/explore/components/chart/droppedDataBand/droppedDataTooltip.spec.tsx b/static/app/views/explore/components/chart/droppedDataBand/droppedDataTooltip.spec.tsx index c40e20573b57..92a27ca6e254 100644 --- a/static/app/views/explore/components/chart/droppedDataBand/droppedDataTooltip.spec.tsx +++ b/static/app/views/explore/components/chart/droppedDataBand/droppedDataTooltip.spec.tsx @@ -9,37 +9,45 @@ import {groupIntoBuckets} from 'sentry/views/explore/components/chart/droppedDat const START = Date.UTC(2024, 0, 12, 15, 0); const END = Date.UTC(2024, 0, 12, 15, 5); -function renderTooltip(dropped: Annotation[], accepted: Annotation[] = []) { +function ExampleDroppedDataTooltip({ + dropped, + accepted = [], +}: { + dropped: Annotation[]; + accepted?: Annotation[]; +}) { const [bucket] = groupIntoBuckets(dropped, accepted); - render(); + return ; } describe('DroppedDataTooltip', () => { it('shows the drop ratio, a row per outcome, and the time range', () => { - renderTooltip( - [ - AnnotationFixture({ - start: START, - end: END, - outcome: 'rate_limited', - eventCount: 40_000, - }), - AnnotationFixture({ - start: START, - end: END, - outcome: 'invalid', - eventCount: 20_000, - }), - ], - [ - AnnotationFixture({ - start: START, - end: END, - outcome: 'accepted', - eventCount: 180_000, - }), - ] + render( + ); expect(screen.getByText('Total Dropped')).toBeInTheDocument(); @@ -55,9 +63,11 @@ describe('DroppedDataTooltip', () => { }); it('shows <0.01% for a drop ratio at or below 0.01%', () => { - renderTooltip( - [AnnotationFixture({start: START, end: END, eventCount: 1})], - [AnnotationFixture({start: START, end: END, eventCount: 9_999})] + render( + ); expect(screen.getByText('<0.01%')).toBeInTheDocument(); @@ -65,37 +75,49 @@ describe('DroppedDataTooltip', () => { }); it('reads a drop with no accepted volume as the whole bucket', () => { - renderTooltip([AnnotationFixture({start: START, end: END, eventCount: 10})]); + render( + + ); expect(screen.getByText('100%')).toBeInTheDocument(); expect(screen.getByText('/10')).toBeInTheDocument(); }); it('labels an unintentional client discard as an SDK drop', () => { - renderTooltip([ - AnnotationFixture({ - start: START, - end: END, - outcome: 'client_discard', - reason: 'queue_overflow', - eventCount: 10, - }), - ]); + render( + + ); expect(screen.getByText('SDK Data Dropped')).toBeInTheDocument(); }); it('shares one byte unit between the payload row numbers', () => { - renderTooltip( - [AnnotationFixture({start: START, end: END, eventCount: 10, byteSize: 26e9})], - [ - AnnotationFixture({ - start: START, - end: END, - eventCount: 90, - byteSize: 224e9, - }), - ] + render( + ); expect(screen.getByText('Payloads Rejected')).toBeInTheDocument(); @@ -104,20 +126,28 @@ describe('DroppedDataTooltip', () => { }); it('omits the payload row for datasets without a byte category', () => { - renderTooltip([AnnotationFixture({start: START, end: END, eventCount: 10})]); + render( + + ); expect(screen.queryByText('Payloads Rejected')).not.toBeInTheDocument(); }); it('falls back to a generic label for an unknown outcome', () => { - renderTooltip([ - AnnotationFixture({ - start: START, - end: END, - outcome: 'something_new', - eventCount: 10, - }), - ]); + render( + + ); expect(screen.getByText('Other Rejected')).toBeInTheDocument(); }); From 3882ba873775372bf2fc37f7293b1fc097961b8d Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:35:07 -0700 Subject: [PATCH 24/47] test(dashboards): Drop the renderSelector wrapper in xAxisSelector specs One call site, so the router and provider config now sits in the test that depends on it. --- .../components/xAxisSelector.spec.tsx | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx index 80b1f100e55e..10a4e71d2682 100644 --- a/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/xAxisSelector.spec.tsx @@ -38,8 +38,15 @@ describe('WidgetBuilderXAxisSelector', () => { }); }); - function renderSelector() { - return render(, { + it('fetches attributes from the server while typing', async () => { + const searchAttributesMock = MockApiClient.addMockResponse({ + url: ATTRIBUTES_URL, + method: 'GET', + body: [cappedAttribute, genAiAttribute], + match: [MockApiClient.matchQuery({substringMatch: 'gen_ai'})], + }); + + render(, { organization, additionalWrapper: WidgetBuilderProvider, initialRouterConfig: { @@ -53,17 +60,6 @@ describe('WidgetBuilderXAxisSelector', () => { route: DASHBOARD_WIDGET_BUILDER_ROUTE, }, }); - } - - it('fetches attributes from the server while typing', async () => { - const searchAttributesMock = MockApiClient.addMockResponse({ - url: ATTRIBUTES_URL, - method: 'GET', - body: [cappedAttribute, genAiAttribute], - match: [MockApiClient.matchQuery({substringMatch: 'gen_ai'})], - }); - - renderSelector(); expect(await screen.findByText('X-Axis')).toBeInTheDocument(); From 4c9038d9bf8bd4c0a175d0b7900851be0f468b6a Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:35:11 -0700 Subject: [PATCH 25/47] test(dashboards): Drop the renderWithProvider wrapper in spansSearchBar specs Despite the name it added no provider: it forwarded the component's own props and passed an empty options object. --- .../filterResultsStep/spansSearchBar.spec.tsx | 53 ++++++++++--------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx index b613fc92cf44..fd78026613a6 100644 --- a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx @@ -14,17 +14,6 @@ interface MockedTagValue extends Pick< 'key' | 'value' | 'name' | 'count' | 'firstSeen' | 'lastSeen' > {} -function renderWithProvider({ - widgetQuery, - onSearch, - onClose, -}: ComponentProps) { - return render( - , - {} - ); -} - function mockSpanTags({ type, mockedTags, @@ -111,11 +100,15 @@ describe('SpansSearchBar', () => { ], }); - renderWithProvider({ - widgetQuery: WidgetQueryFixture({conditions: 'span.op:function'}), - onSearch: jest.fn(), - onClose: jest.fn(), - }); + render( + + ); await screen.findByLabelText('span.op:function'); }); @@ -123,11 +116,15 @@ describe('SpansSearchBar', () => { it.isKnownFlake('calls onSearch with the correct query', async () => { const onSearch = jest.fn(); - renderWithProvider({ - widgetQuery: WidgetQueryFixture({conditions: ''}), - onSearch, - onClose: jest.fn(), - }); + render( + + ); const searchInput = await screen.findByRole('combobox', { name: 'Add a search term', @@ -148,11 +145,15 @@ describe('SpansSearchBar', () => { it.isKnownFlake('triggers onClose when the query changes', async () => { const onClose = jest.fn(); - renderWithProvider({ - widgetQuery: WidgetQueryFixture({conditions: ''}), - onSearch: jest.fn(), - onClose, - }); + render( + + ); const searchInput = await screen.findByRole('combobox', { name: 'Add a search term', From e72401356c59230242d2c579205d5875dc0fafaf Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:36:29 -0700 Subject: [PATCH 26/47] test(preprod): Replace renderInstallPage with ExampleInstallPage The TopBar slot scaffolding the page needs is now visible as a component, and the organization and router config stay at the four call sites. --- .../views/preprod/install/installPage.spec.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/static/app/views/preprod/install/installPage.spec.tsx b/static/app/views/preprod/install/installPage.spec.tsx index 70cc0987f5ee..af2aab24594a 100644 --- a/static/app/views/preprod/install/installPage.spec.tsx +++ b/static/app/views/preprod/install/installPage.spec.tsx @@ -42,20 +42,19 @@ describe('InstallPage', () => { }); }); - function renderInstallPage() { - return render( + function ExampleInstallPage() { + return ( {props =>
} - , - {organization, initialRouterConfig} + ); } it('renders the Releases breadcrumb linking to the mobile-builds distribution view', async () => { - renderInstallPage(); + render(, {organization, initialRouterConfig}); expect(await screen.findByText('Test App')).toBeInTheDocument(); @@ -69,7 +68,7 @@ describe('InstallPage', () => { }); it('renders the app info as the current crumb after Releases', async () => { - renderInstallPage(); + render(, {organization, initialRouterConfig}); expect(await screen.findByText('Test App')).toBeInTheDocument(); @@ -107,7 +106,7 @@ describe('InstallPage', () => { body: {platform: 'ios', install_url: 'https://example.com/install'}, }); - renderInstallPage(); + render(, {organization, initialRouterConfig}); expect(await screen.findByText('Install Groups')).toBeInTheDocument(); expect(screen.getByText('qa')).toBeInTheDocument(); @@ -122,7 +121,7 @@ describe('InstallPage', () => { body: {detail: 'Internal Error'}, }); - renderInstallPage(); + render(, {organization, initialRouterConfig}); expect(await screen.findByText('Install')).toBeInTheDocument(); From 81b3093a67c8997be81851c40465a870ac143adf Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:36:34 -0700 Subject: [PATCH 27/47] test(preprod): Replace renderSidebar with ExampleSnapshotSidebarContent Eight fixed props lived in the helper. They now sit in a component, and the two that tests vary are named props. --- .../sidebar/snapshotSidebarContent.spec.tsx | 95 +++++++++++-------- 1 file changed, 54 insertions(+), 41 deletions(-) diff --git a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx index f330f930933d..dac7b8eb45f1 100644 --- a/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx +++ b/static/app/views/preprod/snapshots/sidebar/snapshotSidebarContent.spec.tsx @@ -21,11 +21,14 @@ const statusCounts: Record = { [DiffStatus.SKIPPED]: 0, }; -function renderSidebar( - sections: SidebarSection[], - counts: Record = statusCounts -) { - return render( +function ExampleSnapshotSidebarContent({ + sections, + counts = statusCounts, +}: { + sections: SidebarSection[]; + counts?: Record; +}) { + return ( { it('renders displayName in the sidebar, not the key', async () => { - renderSidebar([ - { - type: DiffStatus.CHANGED, - groups: [ + render( + + ); expect(await screen.findByText('MyPreview')).toBeInTheDocument(); expect(screen.queryByText('com.example.MyClass.MyPreview')).not.toBeInTheDocument(); }); it('shows group name as displayName when group is set', async () => { - renderSidebar([ - { - type: DiffStatus.UNCHANGED, - groups: [ + render( + + ); expect(await screen.findByText('components')).toBeInTheDocument(); }); it('renders an errored pill when there are errored images', async () => { - renderSidebar( - [ - { - type: DiffStatus.ERRORED, - groups: [{key: 'errored:LoginScreen', displayName: 'LoginScreen', count: 2}], - }, - ], - { - [DiffStatus.CHANGED]: 0, - [DiffStatus.ADDED]: 0, - [DiffStatus.REMOVED]: 0, - [DiffStatus.RENAMED]: 0, - [DiffStatus.UNCHANGED]: 0, - [DiffStatus.ERRORED]: 2, - [DiffStatus.SKIPPED]: 0, - } + render( + ); expect(await screen.findByText('2 errored')).toBeInTheDocument(); From 12b880b185ba1a2cd244817eccc9ec727f845b65 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:37:30 -0700 Subject: [PATCH 28/47] test(preprod): Replace renderImageCard with ExampleImageCard The helper built the SnapshotImage from a positional canvas theme. Both arguments are now named props, so a call no longer reads as renderImageCard(null, fn). --- .../snapshots/main/snapshotCards.spec.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/static/app/views/preprod/snapshots/main/snapshotCards.spec.tsx b/static/app/views/preprod/snapshots/main/snapshotCards.spec.tsx index 727deb417590..ffff5e9147be 100644 --- a/static/app/views/preprod/snapshots/main/snapshotCards.spec.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotCards.spec.tsx @@ -21,10 +21,13 @@ jest.mock('sentry/utils/useCopyToClipboard', () => ({ useCopyToClipboard: () => ({copy: jest.fn()}), })); -function renderImageCard( - canvasTheme: SnapshotImage['canvas_theme'], - onSelectSnapshot?: (key: string | null) => void -) { +function ExampleImageCard({ + canvasTheme, + onSelectSnapshot, +}: { + canvasTheme: SnapshotImage['canvas_theme']; + onSelectSnapshot?: (key: string | null) => void; +}) { const image: SnapshotImage = { display_name: 'Button', height: 180, @@ -34,7 +37,7 @@ function renderImageCard( width: 320, canvas_theme: canvasTheme, }; - render( + return ( describe('ImageCard canvas theme', () => { it('seeds the canvas from an explicit canvas_theme hint', () => { - renderImageCard('dark'); + render(); expectDarkCanvas(); }); it('toggles the canvas independently of the hint', async () => { - renderImageCard('dark'); + render(); expectDarkCanvas(); await userEvent.click(screen.getByRole('button', {name: 'Light preview'})); @@ -71,7 +74,7 @@ describe('ImageCard canvas theme', () => { describe('ImageCard zoom', () => { it('renders zoom controls wired to the image zoom', async () => { - renderImageCard(null); + render(); await userEvent.click(screen.getByRole('button', {name: 'Zoom in'})); await userEvent.click(screen.getByRole('button', {name: 'Zoom out'})); @@ -83,7 +86,7 @@ describe('ImageCard zoom', () => { }); it('hints at modifier scroll zoom on the zoom buttons', async () => { - renderImageCard(null); + render(); await userEvent.hover(screen.getByRole('button', {name: 'Zoom in'})); @@ -92,7 +95,7 @@ describe('ImageCard zoom', () => { it('does not toggle card selection when using zoom controls', async () => { const onSelectSnapshot = jest.fn(); - renderImageCard(null, onSelectSnapshot); + render(); await userEvent.click(screen.getByRole('button', {name: 'Zoom in'})); From 454d691bdf29df6293e8222f43c2402ea916172c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:37:42 -0700 Subject: [PATCH 29/47] test(preprod): Replace renderListView with ExampleSnapshotListView Same shape as the sibling snapshot specs: fixed imageBaseUrl in the component, varying props at the call site. --- .../snapshots/main/snapshotListView.spec.tsx | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/static/app/views/preprod/snapshots/main/snapshotListView.spec.tsx b/static/app/views/preprod/snapshots/main/snapshotListView.spec.tsx index 05af5af47162..9f6de503309e 100644 --- a/static/app/views/preprod/snapshots/main/snapshotListView.spec.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotListView.spec.tsx @@ -50,8 +50,14 @@ const erroredPair: SnapshotDiffPair = { head_image: image(), }; -function renderListView(items: SidebarItem[], diffMode?: 'split' | 'wipe' | 'onion') { - return render( +function ExampleSnapshotListView({ + items, + diffMode, +}: { + items: SidebarItem[]; + diffMode?: 'split' | 'wipe' | 'onion'; +}) { + return ( { }); it('renders errored pairs as side-by-side cards with a failed badge', () => { - renderListView([erroredItem]); + render(); expect(screen.getByText('Failed to compare')).toBeInTheDocument(); }); it('renders errored pairs side-by-side even when the diff mode is onion', () => { - renderListView([erroredItem], 'onion'); + render(); expect(screen.getByText('Failed to compare')).toBeInTheDocument(); // Onion mode renders an opacity slider; side-by-side (split) does not. @@ -116,7 +122,7 @@ describe('SnapshotListView', () => { } it('renders every card in a large single group (per-card rows) plus one header', () => { - renderListView([changedGroup(6)]); + render(); expect(screen.getAllByRole('heading', {name: 'Screens'})).toHaveLength(1); expect(screen.getByText('Screen 0')).toBeInTheDocument(); @@ -124,7 +130,7 @@ describe('SnapshotListView', () => { }); it('frames the first row of a group as frame-top and the last card row as frame-bottom', () => { - renderListView([changedGroup(2)]); + render(); expect(document.querySelectorAll('[data-frame-top]')).toHaveLength(1); expect(document.querySelectorAll('[data-frame-bottom]')).toHaveLength(1); @@ -132,15 +138,19 @@ describe('SnapshotListView', () => { }); it('renders no group header for ungrouped items', () => { - renderListView([ - { - key: 'added:solo', - name: 'solo.png', - displayName: 'solo.png', - type: 'added', - images: [image({group: undefined, image_file_name: 'solo.png'})], - }, - ]); + render( + + ); expect(screen.queryByRole('heading', {name: 'solo.png'})).not.toBeInTheDocument(); }); From 7cf86752b0ee411b005e85903cd891d941f709d5 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:39:26 -0700 Subject: [PATCH 30/47] test(seer): Restore the mangled comment in errorsQuery specs Converting the helper's object argument into JSX props split this two-line comment on its internal commas, leaving two fragments that read as attributes. The comment now sits above the render call, where it belongs. --- .../markdown/embeds/components/errorsQuery.spec.tsx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/static/app/components/seer/markdown/embeds/components/errorsQuery.spec.tsx b/static/app/components/seer/markdown/embeds/components/errorsQuery.spec.tsx index ea68bb1ee1d8..1d394905c63c 100644 --- a/static/app/components/seer/markdown/embeds/components/errorsQuery.spec.tsx +++ b/static/app/components/seer/markdown/embeds/components/errorsQuery.spec.tsx @@ -297,13 +297,9 @@ describe('errors query embed', () => { body: {data: []}, }); - render( - - ); + // No `mode`, so this also pins the schema default. `errorsQuery` shipped + // before the mode existed, and samples is what it used to do. + render(); expect(screen.getByRole('link', {name: 'Error search'})).toBeInTheDocument(); expect(request).not.toHaveBeenCalled(); From 4218ef3800400d10c4c4ad00d157879560b9c719 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:39:36 -0700 Subject: [PATCH 31/47] test(gsApp): Replace renderCartDiff with ExampleCartDiff Nine tests shared the helper's fixed isOpen, onToggle and organization props. Those stay in the component; the plan, form data and subscription each test varies are now named props. --- .../amCheckout/components/cartDiff.spec.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/static/gsApp/views/amCheckout/components/cartDiff.spec.tsx b/static/gsApp/views/amCheckout/components/cartDiff.spec.tsx index 2f42666cd584..f704ff478f03 100644 --- a/static/gsApp/views/amCheckout/components/cartDiff.spec.tsx +++ b/static/gsApp/views/amCheckout/components/cartDiff.spec.tsx @@ -33,7 +33,7 @@ describe('CartDiff', () => { }, }; - function renderCartDiff({ + function ExampleCartDiff({ activePlan = teamAnnualPlan, formData, subscription = sub, @@ -42,7 +42,7 @@ describe('CartDiff', () => { activePlan?: Plan; subscription?: Subscription; }) { - render( + return ( { }, }; - renderCartDiff({formData, activePlan: bizPlan}); + render(); expect(await screen.findByText('Changes')).toBeInTheDocument(); const planDiff = await screen.findByTestId('plan-diff'); @@ -95,7 +95,7 @@ describe('CartDiff', () => { }); it('does not render for returning customers with no changes', () => { - renderCartDiff({formData: defaultFormData}); + render(); expect(screen.queryByTestId('cart-diff')).not.toBeInTheDocument(); }); @@ -104,7 +104,7 @@ describe('CartDiff', () => { organization: org, plan: 'am3_f', }); - renderCartDiff({formData: defaultFormData, subscription: newSub}); + render(); expect(screen.queryByTestId('cart-diff')).not.toBeInTheDocument(); }); @@ -130,7 +130,7 @@ describe('CartDiff', () => { }, }; - renderCartDiff({formData, subscription: sharedOdSub}); + render(); expect(await screen.findByText('Changes')).toBeInTheDocument(); const paygDiff = await screen.findByTestId('shared-spend-limit-diff'); @@ -168,7 +168,7 @@ describe('CartDiff', () => { }, }; - renderCartDiff({formData, subscription: perCategorySub}); + render(); expect(await screen.findByText('Changes')).toBeInTheDocument(); const paygDiff = await screen.findByTestId('shared-spend-limit-diff'); expect(paygDiff).toHaveTextContent('PAYG spend limit'); @@ -191,7 +191,7 @@ describe('CartDiff', () => { }, }; - renderCartDiff({formData}); + render(); expect(screen.queryByTestId('cart-diff')).not.toBeInTheDocument(); }); @@ -204,7 +204,7 @@ describe('CartDiff', () => { }, }; - renderCartDiff({formData}); + render(); expect(await screen.findByText('Changes')).toBeInTheDocument(); const paygDiff = await screen.findByTestId('shared-spend-limit-diff'); expect(paygDiff).toHaveTextContent('PAYG spend limit'); @@ -224,7 +224,7 @@ describe('CartDiff', () => { }, }; - renderCartDiff({formData}); + render(); expect(await screen.findByText('Changes')).toBeInTheDocument(); const perCategoryDiff = await screen.findByTestId('per-category-spend-limit-diff'); expect(perCategoryDiff).toHaveTextContent('Per-product spend limits'); @@ -253,7 +253,7 @@ describe('CartDiff', () => { sharedMaxBudget: 0, }, }; - renderCartDiff({formData, subscription: subWithBudget}); + render(); expect(await screen.findByText('Changes')).toBeInTheDocument(); const paygDiff = await screen.findByTestId('shared-spend-limit-diff'); expect(paygDiff).toHaveTextContent('PAYG spend limit'); From 211857299cf3b635b5ac55dd5409a0b127fd40f8 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:42:31 -0700 Subject: [PATCH 32/47] test(gsApp): Replace renderCheckout with ExampleAMCheckout in billing cycle specs The helper hid the AMCheckout element behind a referrer argument. The component now takes the location, which each call site builds, because calling a capitalized fixture inside a component body trips the React Compiler capitalized-calls rule. --- .../steps/chooseYourBillingCycle.spec.tsx | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx b/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx index ae4597105653..f21c05fa1a7b 100644 --- a/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/chooseYourBillingCycle.spec.tsx @@ -1,3 +1,4 @@ +import type {ComponentProps} from 'react'; import {LocationFixture} from 'sentry-fixture/locationFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture'; @@ -86,28 +87,20 @@ describe('ChooseYourBillingCycle', () => { ).toBeInTheDocument(); } - function renderCheckout(referrer?: string) { - let location = LocationFixture(); - if (referrer) { - location = LocationFixture({ - query: { - referrer, - }, - }); - } - render( - , - {organization} + const routeProps = RouteComponentPropsFixture(); + + function ExampleAMCheckout({ + location, + }: { + location: ComponentProps['location']; + }) { + return ( + ); } it('renders for upgrade from developer', async () => { - renderCheckout(); + render(, {organization}); await assertCycleText({ // org is on monthly cycle, but is on developer plan, so upgrade should apply immediately and create a new period monthlyInfo: /Billed on the 13th of each month/, @@ -122,7 +115,7 @@ describe('ChooseYourBillingCycle', () => { billingPeriodEnd: '2025-08-28', }); SubscriptionStore.set(organization.slug, monthlySub); - renderCheckout(); + render(, {organization}); await assertCycleText({ // org is on monthly cycle and is paid plan, so upgrade should apply immediately to the current period monthlyInfo: /Billed on the 29th of each month/, @@ -138,7 +131,7 @@ describe('ChooseYourBillingCycle', () => { organization, }); SubscriptionStore.set(organization.slug, annualSub); - renderCheckout(); + render(, {organization}); await assertCycleText({ monthlyInfo: /Billed on the 16th of each month/, yearlyInfo: /Billed annually/, @@ -162,7 +155,7 @@ describe('ChooseYourBillingCycle', () => { organization, }); SubscriptionStore.set(organization.slug, partnerSub); - renderCheckout(); + render(, {organization}); await assertCycleText({ monthlyInfo: /Billed monthly starting on your selected start date on submission/, yearlyInfo: /Billed annually from your selected start date on submission/, @@ -170,7 +163,7 @@ describe('ChooseYourBillingCycle', () => { }); it('can select billing cycle', async () => { - renderCheckout(); + render(, {organization}); const monthly = await screen.findByRole('radio', {name: 'Monthly billing cycle'}); const annual = screen.getByRole('radio', {name: 'Yearly billing cycle'}); From 01eaaec1b1c7a2889ebddbc1121d2f6332df9316 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:42:41 -0700 Subject: [PATCH 33/47] test(gsApp): Replace renderCheckout with ExampleAMCheckout in buildYourPlan specs The same helper was duplicated in this file; both now use one component shape. --- .../amCheckout/steps/buildYourPlan.spec.tsx | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx b/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx index cfb0527a4e93..8b14ff146750 100644 --- a/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx +++ b/static/gsApp/views/amCheckout/steps/buildYourPlan.spec.tsx @@ -1,3 +1,4 @@ +import type {ComponentProps} from 'react'; import {LocationFixture} from 'sentry-fixture/locationFixture'; import {OrganizationFixture} from 'sentry-fixture/organization'; import {RouteComponentPropsFixture} from 'sentry-fixture/routeComponentPropsFixture'; @@ -48,28 +49,20 @@ describe('BuildYourPlan', () => { }); }); - function renderCheckout(referrer?: string) { - let location = LocationFixture(); - if (referrer) { - location = LocationFixture({ - query: { - referrer, - }, - }); - } - render( - , - {organization} + const routeProps = RouteComponentPropsFixture(); + + function ExampleAMCheckout({ + location, + }: { + location: ComponentProps['location']; + }) { + return ( + ); } it('renders', async () => { - renderCheckout(); + render(, {organization}); expect(await screen.findByText('Select a plan')).toBeInTheDocument(); expect(screen.queryByTestId('body-choose-your-plan')).not.toBeInTheDocument(); @@ -83,7 +76,7 @@ describe('BuildYourPlan', () => { }); SubscriptionStore.set(bizOrg.slug, businessSubscription); - renderCheckout(); + render(, {organization}); const businessPlan = await screen.findByTestId('plan-option-am3_business'); expect(businessPlan).toBeInTheDocument(); @@ -93,7 +86,7 @@ describe('BuildYourPlan', () => { }); it('can select plan', async () => { - renderCheckout(); + render(, {organization}); const teamPlan = await screen.findByRole('radio', {name: 'Team'}); const businessPlan = screen.getByRole('radio', {name: 'Business'}); From bc711a9f0d6dfcdd89b9491c67836efa43894a69 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:44:56 -0700 Subject: [PATCH 34/47] test: Drop ComponentProps imports left behind by removed render helpers Both imports only typed a helper's props parameter, so deleting the helper orphaned them. Neither jest nor oxlint reports an unused type import, so this only showed up under tsc. --- static/app/components/acl/featureDisabledModal.spec.tsx | 2 +- .../buildSteps/filterResultsStep/spansSearchBar.spec.tsx | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/static/app/components/acl/featureDisabledModal.spec.tsx b/static/app/components/acl/featureDisabledModal.spec.tsx index 9ded7ffb7c7b..176c9531a789 100644 --- a/static/app/components/acl/featureDisabledModal.spec.tsx +++ b/static/app/components/acl/featureDisabledModal.spec.tsx @@ -1,4 +1,4 @@ -import type {ComponentProps, PropsWithChildren} from 'react'; +import type {PropsWithChildren} from 'react'; import styled from '@emotion/styled'; import {render, screen} from 'sentry-test/reactTestingLibrary'; diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx index fd78026613a6..9dc364b287f4 100644 --- a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx @@ -1,4 +1,3 @@ -import type {ComponentProps} from 'react'; import {WidgetQueryFixture} from 'sentry-fixture/widgetQuery'; import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; From 41225f56e61cd37f47b39f5798dc3749694a8925 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:49:02 -0700 Subject: [PATCH 35/47] test(seer): Replace renderTable with ExampleSeerProjectTable The helper took an organization only to pass it to render(). The nuqs adapter the table needs is now visible as a component. --- .../seer/projectTable/seerProjectTable.spec.tsx | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/static/app/components/seer/projectTable/seerProjectTable.spec.tsx b/static/app/components/seer/projectTable/seerProjectTable.spec.tsx index 6fa0dee9b09d..16146fdb813b 100644 --- a/static/app/components/seer/projectTable/seerProjectTable.spec.tsx +++ b/static/app/components/seer/projectTable/seerProjectTable.spec.tsx @@ -91,12 +91,11 @@ describe('SeerProjectTable', () => { jest.restoreAllMocks(); }); - function renderTable(renderOrganization = organization) { - render( + function ExampleSeerProjectTable() { + return ( - , - {organization: renderOrganization} + ); } @@ -108,7 +107,7 @@ describe('SeerProjectTable', () => { }); const errorSpy = jest.spyOn(indicators, 'addErrorMessage'); - renderTable(); + render(, {organization}); // The agent dropdown renders its current value, "Seer". await userEvent.click(await screen.findByText('Seer')); @@ -152,7 +151,7 @@ describe('SeerProjectTable', () => { }); const errorSpy = jest.spyOn(indicators, 'addErrorMessage'); - renderTable(); + render(, {organization}); await userEvent.click(await screen.findByText('Seer')); await userEvent.click( @@ -177,7 +176,7 @@ describe('SeerProjectTable', () => { }); const errorSpy = jest.spyOn(indicators, 'addErrorMessage'); - renderTable(); + render(, {organization}); await userEvent.click(await screen.findByText('Seer')); await userEvent.click( @@ -190,7 +189,9 @@ describe('SeerProjectTable', () => { }); it('disables adding a project without organization write access', async () => { - renderTable(OrganizationFixture({slug: organization.slug, access: []})); + render(, { + organization: OrganizationFixture({slug: organization.slug, access: []}), + }); expect(await screen.findByRole('button', {name: 'Add Project'})).toBeDisabled(); }); From 74693922f20ca779af8b4a56f8f6ceffd9afa216 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:49:16 -0700 Subject: [PATCH 36/47] test(explore): Replace renderModal with ExampleExploreExportModal The modal's Body, Footer, Header and CloseButton scaffolding stays in the component; the config and onCancel each test varies are named props. --- .../exports/exploreExportModal.spec.tsx | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/static/app/views/explore/components/exports/exploreExportModal.spec.tsx b/static/app/views/explore/components/exports/exploreExportModal.spec.tsx index c7a6bc9a0c95..82b7e693b564 100644 --- a/static/app/views/explore/components/exports/exploreExportModal.spec.tsx +++ b/static/app/views/explore/components/exports/exploreExportModal.spec.tsx @@ -51,8 +51,14 @@ function makeConfig( }; } -function renderModal(config: TraceItemExportConfig, onCancel = jest.fn()) { - render( +function ExampleExploreExportModal({ + config, + onCancel = jest.fn(), +}: { + config: TraceItemExportConfig; + onCancel?: () => void; +}) { + return ( , - {organization} + /> ); } @@ -74,7 +79,9 @@ describe('ExploreExportModal', () => { it('calls onCancel and closeModal when Cancel is clicked', async () => { const onCancel = jest.fn(); - renderModal(makeConfig(), onCancel); + render(, { + organization, + }); await userEvent.click(screen.getByRole('button', {name: 'Cancel'})); @@ -90,7 +97,7 @@ describe('ExploreExportModal', () => { body: {id: 721}, }); - renderModal(config); + render(, {organization}); await userEvent.click(screen.getByRole('button', {name: 'Export'})); @@ -112,7 +119,7 @@ describe('ExploreExportModal', () => { }); it("disables the Format radios and selects JSONL when the 'All Columns' switch is on", async () => { - renderModal(makeConfig()); + render(, {organization}); await userEvent.click(await screen.findByRole('checkbox', {name: 'All Columns?'})); @@ -130,7 +137,7 @@ describe('ExploreExportModal', () => { body: {id: 721}, }); - renderModal(config); + render(, {organization}); await userEvent.click(await screen.findByRole('checkbox', {name: 'All Columns?'})); await userEvent.click(screen.getByRole('button', {name: 'Export'})); @@ -165,7 +172,7 @@ describe('ExploreExportModal', () => { body: {id: 721}, }); - renderModal(config); + render(, {organization}); await userEvent.click(screen.getByRole('button', {name: 'Number of rows'})); await userEvent.click(await screen.findByRole('option', {name: /\(All\)$/})); @@ -209,7 +216,7 @@ describe('ExploreExportModal', () => { body: {id: 721}, }); - renderModal(config); + render(, {organization}); await userEvent.click(screen.getByRole('button', {name: 'Number of rows'})); await userEvent.click(await screen.findByRole('option', {name: /\(All\)$/})); @@ -235,7 +242,7 @@ describe('ExploreExportModal', () => { body: {id: 721}, }); - renderModal(config); + render(, {organization}); await userEvent.click(screen.getByRole('button', {name: 'Export'})); @@ -256,7 +263,10 @@ describe('ExploreExportModal', () => { }); it('hides the All Columns switch when not supported', async () => { - renderModal(makeConfig({supportsAllColumns: false})); + render( + , + {organization} + ); expect(await screen.findByRole('button', {name: 'Export'})).toBeInTheDocument(); expect( @@ -265,7 +275,12 @@ describe('ExploreExportModal', () => { }); it('hides the Format radios when only one format is available', async () => { - renderModal(makeConfig({supportsAllColumns: false, availableFormats: ['csv']})); + render( + , + {organization} + ); expect(await screen.findByRole('button', {name: 'Export'})).toBeInTheDocument(); expect(screen.queryByRole('radio', {name: 'CSV'})).not.toBeInTheDocument(); From 0caf7a11c029f2b39252b8b3b76fc485e885cf50 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:49:30 -0700 Subject: [PATCH 37/47] test(performance): Replace renderComponent with ExampleTransactionThresholdButton Three positional arguments meant a reader had to check the signature to know what each one was. They are named props now, and the fixed transaction name stays in the component. --- .../transactionThresholdButton.spec.tsx | 56 +++++++++++++++---- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/static/app/views/performance/transactionSummary/transactionThresholdButton.spec.tsx b/static/app/views/performance/transactionSummary/transactionThresholdButton.spec.tsx index 9bf22b0b7497..359392f096c9 100644 --- a/static/app/views/performance/transactionSummary/transactionThresholdButton.spec.tsx +++ b/static/app/views/performance/transactionSummary/transactionThresholdButton.spec.tsx @@ -15,12 +15,16 @@ import type {Organization} from 'sentry/types/organization'; import {EventView} from 'sentry/utils/discover/eventView'; import {TransactionThresholdButton} from 'sentry/views/performance/transactionSummary/transactionThresholdButton'; -function renderComponent( - eventView: EventView, - organization: Organization, - onChangeThreshold: () => void -) { - return render( +function ExampleTransactionThresholdButton({ + eventView, + organization, + onChangeThreshold, +}: { + eventView: EventView; + onChangeThreshold: () => void; + organization: Organization; +}) { + return ( { metric: 'duration', }, }); - renderComponent(eventView, organization, onChangeThreshold); + render( + + ); const button = screen.getByRole('button'); await waitFor(() => expect(button).toBeEnabled()); @@ -97,7 +107,13 @@ describe('TransactionThresholdButton', () => { metric: 'duration', }, }); - renderComponent(eventView, organization, onChangeThreshold); + render( + + ); const button = screen.getByRole('button'); await waitFor(() => expect(button).toBeEnabled()); @@ -116,7 +132,13 @@ describe('TransactionThresholdButton', () => { }, }); - renderComponent(eventView, organization, onChangeThreshold); + render( + + ); const button = screen.getByRole('button'); await waitFor(() => expect(button).toBeEnabled()); @@ -138,7 +160,13 @@ describe('TransactionThresholdButton', () => { // render before the store is populated. ProjectsStore.reset(); - renderComponent(eventView, organization, onChangeThreshold); + render( + + ); const button = screen.getByRole('button', {name: 'Settings'}); expect(button).toBeDisabled(); @@ -166,7 +194,13 @@ describe('TransactionThresholdButton', () => { body: {threshold: '200', metric: 'duration'}, }); - renderComponent(eventView, organization, onChangeThreshold); + render( + + ); renderGlobalModal(); const button = screen.getByRole('button', {name: 'Settings'}); From 6348d8ea789835951d1c199688704e633ee56123 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:49:40 -0700 Subject: [PATCH 38/47] test(discover): Replace renderComponent with ExampleCellAction Twenty-five tests passed an options object to a helper that spread it over a fixed CellAction. The props are now on the element, and the @ts-expect-error comments stayed directly above the props they suppress. --- .../views/discover/table/cellAction.spec.tsx | 268 +++++++++++------- 1 file changed, 172 insertions(+), 96 deletions(-) diff --git a/static/app/views/discover/table/cellAction.spec.tsx b/static/app/views/discover/table/cellAction.spec.tsx index 5e2c8be0b722..124c71f97e75 100644 --- a/static/app/views/discover/table/cellAction.spec.tsx +++ b/static/app/views/discover/table/cellAction.spec.tsx @@ -32,7 +32,7 @@ const defaultData: TableDataRow = { id: '42', }; -function renderComponent({ +function ExampleCellAction({ eventView, handleCellAction = jest.fn(), columnIndex = 0, @@ -52,7 +52,7 @@ function renderComponent({ pin?: React.ReactNode; triggerType?: ActionTriggerType; }) { - return render( + return ( CellAction', () => { describe('hover menu button', () => { it('shows no menu by default', () => { - renderComponent({eventView: view}); + render(); expect(screen.getByRole('button', {name: 'Actions'})).toBeInTheDocument(); }); }); describe('opening the menu', () => { it('toggles the menu on click', async () => { - renderComponent({eventView: view}); + render(); await openMenu(); expect( screen.getByRole('menuitemradio', {name: 'Add to filter'}) @@ -124,7 +124,7 @@ describe('Discover -> CellAction', () => { }); it('add button appends condition', async () => { - renderComponent({eventView: view, handleCellAction}); + render(); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); @@ -132,7 +132,7 @@ describe('Discover -> CellAction', () => { }); it('exclude button adds condition', async () => { - renderComponent({eventView: view, handleCellAction}); + render(); await openMenu(); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) @@ -147,7 +147,9 @@ describe('Discover -> CellAction', () => { query: {...location.query, query: '!transaction:nope'}, }) ); - renderComponent({eventView: excludeView, handleCellAction}); + render( + + ); await openMenu(); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) @@ -157,7 +159,13 @@ describe('Discover -> CellAction', () => { }); it('go to release button goes to release health page', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 3}); + render( + + ); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Go to release'})); @@ -168,7 +176,13 @@ describe('Discover -> CellAction', () => { }); it('greater than button adds condition', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 2}); + render( + + ); await openMenu(); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Show values greater than'}) @@ -181,7 +195,13 @@ describe('Discover -> CellAction', () => { }); it('less than button adds condition', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 2}); + render( + + ); await openMenu(); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Show values less than'}) @@ -283,12 +303,14 @@ describe('Discover -> CellAction', () => { }); it('error.handled with null adds condition', async () => { - renderComponent({ - eventView: view, - handleCellAction, - columnIndex: 7, - data: defaultData, - }); + render( + + ); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); @@ -296,12 +318,14 @@ describe('Discover -> CellAction', () => { }); it('error.type with array values adds condition', async () => { - renderComponent({ - eventView: view, - handleCellAction, - columnIndex: 8, - data: defaultData, - }); + render( + + ); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); @@ -314,16 +338,18 @@ describe('Discover -> CellAction', () => { }); it('error.handled with 0 adds condition', async () => { - renderComponent({ - eventView: view, - handleCellAction, - columnIndex: 7, - data: { - ...defaultData, - // @ts-expect-error TODO: Fix this type - 'error.handled': ['0'], - }, - }); + render( + + ); await openMenu(); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); @@ -331,7 +357,7 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for string cells', async () => { - renderComponent({eventView: view, handleCellAction}); + render(); await openMenu(); expect( @@ -359,16 +385,18 @@ describe('Discover -> CellAction', () => { column: {kind: 'field', field: 'tags[my.tags,array]'}, width: undefined, }; - renderComponent({ - eventView: view, - handleCellAction, - column: arrayColumn, - data: { - ...defaultData, - // @ts-expect-error TODO: Fix this type - 'tags[my.tags,array]': ['foo', 'bar'], - }, - }); + render( + + ); await openMenu(); expect( @@ -394,16 +422,18 @@ describe('Discover -> CellAction', () => { column: {kind: 'field', field: 'tags[my.tags,array]'}, width: undefined, }; - renderComponent({ - eventView: view, - handleCellAction, - column: arrayColumn, - data: { - ...defaultData, - // @ts-expect-error TODO: Fix this type - 'tags[my.tags,array]': null, - }, - }); + render( + + ); await openMenu(); expect( @@ -415,7 +445,13 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for string cells with null values', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 4}); + render( + + ); await openMenu(); expect( @@ -427,7 +463,13 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for number cells', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 1}); + render( + + ); await openMenu(); expect( @@ -445,7 +487,13 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for date cells', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 2}); + render( + + ); await openMenu(); expect( @@ -463,7 +511,13 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for release cells', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 3}); + render( + + ); await openMenu(); expect( @@ -472,13 +526,15 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for empty release cells', async () => { - renderComponent({ - eventView: view, - handleCellAction, - columnIndex: 3, - // @ts-expect-error TODO: Fix this type - data: {...defaultData, release: null}, - }); + render( + + ); await openMenu(); expect( @@ -487,7 +543,13 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for measurement cells', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 5}); + render( + + ); await openMenu(); expect( @@ -505,16 +567,18 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for empty measurement cells', async () => { - renderComponent({ - eventView: view, - handleCellAction, - columnIndex: 5, - data: { - ...defaultData, - // @ts-expect-error TODO: Fix this type - 'measurements.fcp': null, - }, - }); + render( + + ); await openMenu(); expect( @@ -532,7 +596,13 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for numeric function cells', async () => { - renderComponent({eventView: view, handleCellAction, columnIndex: 6}); + render( + + ); await openMenu(); expect( @@ -544,37 +614,43 @@ describe('Discover -> CellAction', () => { }); it('show appropriate actions for empty numeric function cells', () => { - renderComponent({ - eventView: view, - handleCellAction, - columnIndex: 6, - data: { - ...defaultData, - // @ts-expect-error TODO: Fix this type - 'percentile(measurements.fcp, 0.5)': null, - }, - }); + render( + + ); expect(screen.queryByRole('button', {name: 'Actions'})).not.toBeInTheDocument(); }); }); describe('pin prop', () => { it('renders the pin element with the bold hover trigger', () => { - renderComponent({ - eventView: view, - triggerType: ActionTriggerType.BOLD_HOVER, - pin: , - }); + render( + pin me} + /> + ); expect(screen.getByRole('button', {name: 'pin me'})).toBeInTheDocument(); }); it('renders the pin element with the ellipsis trigger', () => { - renderComponent({ - eventView: view, - triggerType: ActionTriggerType.ELLIPSIS, - pin: , - }); + render( + pin me} + /> + ); expect(screen.getByRole('button', {name: 'pin me'})).toBeInTheDocument(); }); From 26b5820d1269fefc85813a067e55ea15e59f6ca2 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:52:18 -0700 Subject: [PATCH 39/47] test(discover): Replace renderComponent with ExampleTableView Fifteen tests shared a helper holding eleven fixed props and a router config. The config is hoisted once as renderOptions; the three values tests vary are named props. --- .../views/discover/table/tableView.spec.tsx | 104 ++++++++++++------ 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/static/app/views/discover/table/tableView.spec.tsx b/static/app/views/discover/table/tableView.spec.tsx index 2cfa644208b9..5d0c798fb1f7 100644 --- a/static/app/views/discover/table/tableView.spec.tsx +++ b/static/app/views/discover/table/tableView.spec.tsx @@ -54,12 +54,26 @@ describe('TableView > CellActions', () => { const eventView = EventView.fromLocation(location); - function renderComponent( - tableData: TableData, - view: EventView, - queryDataset = SavedQueryDatasets.TRANSACTIONS - ) { - return render( + const renderOptions = { + organization, + initialRouterConfig: { + location: { + pathname: location.pathname, + query: locationQuery, + }, + }, + }; + + function ExampleTableView({ + tableData, + view, + queryDataset = SavedQueryDatasets.TRANSACTIONS, + }: { + tableData: TableData; + view: EventView; + queryDataset?: SavedQueryDatasets; + }) { + return ( CellActions', () => { showTags={false} title="" queryDataset={queryDataset} - />, - { - organization, - initialRouterConfig: { - location: { - pathname: location.pathname, - query: locationQuery, - }, - }, - } + /> ); } @@ -142,7 +147,7 @@ describe('TableView > CellActions', () => { it('updates sort order on equation fields', () => { const view = eventView.clone(); - renderComponent(rows, view); + render(, renderOptions); const equationCell = screen.getByRole('columnheader', {name: 'count() + 100'}); const sortLink = within(equationCell).getByRole('link'); @@ -155,7 +160,7 @@ describe('TableView > CellActions', () => { it('updates sort order on non-equation fields', () => { const view = eventView.clone(); - renderComponent(rows, view); + render(, renderOptions); const transactionCell = screen.getByRole('columnheader', {name: 'transaction'}); const sortLink = within(transactionCell).getByRole('link'); @@ -169,7 +174,10 @@ describe('TableView > CellActions', () => { it('handles add cell action on null value', async () => { rows.data[0]!.title = null as any; - const {router} = renderComponent(rows, eventView); + const {router} = render( + , + renderOptions + ); await openContextMenu(1); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); @@ -190,7 +198,10 @@ describe('TableView > CellActions', () => { const view = eventView.clone(); view.query = 'tag:value has:title'; - const {router} = renderComponent(rows, view); + const {router} = render( + , + renderOptions + ); await openContextMenu(1); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); @@ -210,7 +221,10 @@ describe('TableView > CellActions', () => { const view = eventView.clone(); view.query = 'tag:value !title:nope'; - const {router} = renderComponent(rows, view); + const {router} = render( + , + renderOptions + ); await openContextMenu(1); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Add to filter'})); @@ -277,7 +291,10 @@ describe('TableView > CellActions', () => { }); it('handles exclude cell action on string value', async () => { - const {router} = renderComponent(rows, eventView); + const {router} = render( + , + renderOptions + ); await openContextMenu(1); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) @@ -299,7 +316,10 @@ describe('TableView > CellActions', () => { const view = eventView.clone(); view.query = 'tag:value title:nope'; - const {router} = renderComponent(rows, view); + const {router} = render( + , + renderOptions + ); await openContextMenu(1); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) @@ -320,7 +340,10 @@ describe('TableView > CellActions', () => { it('handles exclude cell action on null value', async () => { rows.data[0]!.title = null as any; - const {router} = renderComponent(rows, eventView); + const {router} = render( + , + renderOptions + ); await openContextMenu(1); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) @@ -343,7 +366,10 @@ describe('TableView > CellActions', () => { const view = eventView.clone(); view.query = 'tag:value !has:title'; - const {router} = renderComponent(rows, view); + const {router} = render( + , + renderOptions + ); await openContextMenu(1); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Exclude from filter'}) @@ -362,7 +388,10 @@ describe('TableView > CellActions', () => { }); it('handles greater than cell action on number value', async () => { - const {router} = renderComponent(rows, eventView); + const {router} = render( + , + renderOptions + ); await openContextMenu(3); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Show values greater than'}) @@ -381,7 +410,10 @@ describe('TableView > CellActions', () => { }); it('handles less than cell action on number value', async () => { - const {router} = renderComponent(rows, eventView); + const {router} = render( + , + renderOptions + ); await openContextMenu(3); await userEvent.click( screen.getByRole('menuitemradio', {name: 'Show values less than'}) @@ -402,7 +434,7 @@ describe('TableView > CellActions', () => { it('renders transaction summary link', () => { rows.data[0]!.project = 'project-slug'; - renderComponent(rows, eventView); + render(, renderOptions); const firstRow = screen.getAllByRole('row')[1]!; const link = within(firstRow).getByTestId('tableView-transaction-link'); @@ -508,7 +540,14 @@ describe('TableView > CellActions', () => { 'project.name': 'project-slug', }; - renderComponent(rows, view, SavedQueryDatasets.ERRORS); + render( + , + renderOptions + ); const firstRow = screen.getAllByRole('row')[1]!; const link = within(firstRow).getByTestId('view-event'); @@ -520,7 +559,10 @@ describe('TableView > CellActions', () => { }); it('handles go to release', async () => { - const {router} = renderComponent(rows, eventView); + const {router} = render( + , + renderOptions + ); await openContextMenu(5); await userEvent.click(screen.getByRole('menuitemradio', {name: 'Go to release'})); @@ -541,7 +583,7 @@ describe('TableView > CellActions', () => { it('has title on integer value greater than 999', () => { rows.data[0]!['count()'] = 1000; - renderComponent(rows, eventView); + render(, renderOptions); const firstRow = screen.getAllByRole('row')[1]!; const emptyValueCell = within(firstRow).getAllByRole('cell')[3]!; From cb98a9be5f67aa06233cad078cd78d155f3a006c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:52:30 -0700 Subject: [PATCH 40/47] test(navigation): Replace renderActions with ExampleTopBarActions The width argument never reached the JSX; it only drove a clientWidth spy. That setup is now a mockContainerWidth call in the tests that need it, leaving the component to hold only what is rendered. --- .../views/navigation/topBarActions.spec.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/static/app/views/navigation/topBarActions.spec.tsx b/static/app/views/navigation/topBarActions.spec.tsx index f708413d6585..bb436ac76879 100644 --- a/static/app/views/navigation/topBarActions.spec.tsx +++ b/static/app/views/navigation/topBarActions.spec.tsx @@ -10,25 +10,27 @@ import {AskSeerButton} from 'sentry/views/seerExplorer/components/askSeerButton' const theme = ThemeFixture(); -function renderActions(width = 0) { - jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(width); - - return render( +function ExampleTopBarActions() { + return ( - , - {organization: OrganizationFixture()} + ); } +function mockContainerWidth(width: number) { + jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(width); +} + describe('top bar actions', () => { afterEach(() => { jest.restoreAllMocks(); }); it('shows tooltips for icon-only actions', async () => { - renderActions(); + mockContainerWidth(0); + render(, {organization: OrganizationFixture()}); const searchButton = screen.getByRole('button', {name: 'Command Palette'}); const askSeerButton = screen.getByRole('button', {name: 'Ask Seer'}); @@ -43,7 +45,8 @@ describe('top bar actions', () => { }); it('keeps Command Palette compact and exposes the Ask Seer shortcut in its tooltip at sm', async () => { - renderActions(Number.parseFloat(theme.container.sm)); + mockContainerWidth(Number.parseFloat(theme.container.sm)); + render(, {organization: OrganizationFixture()}); const askSeerButton = screen.getByRole('button', {name: 'Ask Seer'}); expect(screen.getByRole('button', {name: 'Command Palette'})).toBeInTheDocument(); From d179b2c9f7c35a3b18e36783cc9c9e62bc9dec93 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:52:41 -0700 Subject: [PATCH 41/47] test(dashboards): Replace renderPreview with ExampleWidgetPreview The provider and router options are hoisted as renderOptions; previewStatus stays the one thing each test varies. --- .../components/widgetPreview.spec.tsx | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx index 226fb0e0342b..133590e34b52 100644 --- a/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/components/widgetPreview.spec.tsx @@ -20,32 +20,40 @@ describe('WidgetPreview', () => { filters: {}, }; - function renderPreview( - previewStatus: Parameters[0]['previewStatus'] - ) { - render( + const renderOptions = { + organization: OrganizationFixture(), + additionalWrapper: WidgetBuilderProvider, + initialRouterConfig: { + location: {pathname: DASHBOARD_WIDGET_BUILDER_PATHNAME}, + }, + }; + + function ExampleWidgetPreview({ + previewStatus, + }: { + previewStatus: Parameters[0]['previewStatus']; + }) { + return ( , - { - organization: OrganizationFixture(), - additionalWrapper: WidgetBuilderProvider, - initialRouterConfig: { - location: {pathname: DASHBOARD_WIDGET_BUILDER_PATHNAME}, - }, - } + /> ); } it('renders a loading state when the preview status is loading', () => { - renderPreview({status: 'loading'}); + render(, renderOptions); expect(screen.getByTestId('loading-placeholder')).toBeInTheDocument(); }); it('renders the error message when the preview status is invalid', () => { - renderPreview({status: 'invalid', message: 'This widget is broken.'}); + render( + , + renderOptions + ); expect(screen.getByText('This widget is broken.')).toBeInTheDocument(); }); }); From f614169af23912bf3782a3ffc3023bca0b0c1a26 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:53:31 -0700 Subject: [PATCH 42/47] test(preprod): Replace renderSnapshotMainContent with ExampleSnapshotMainContent Two tests rerendered a raw Container tree after an initial render through the helper. Once the initial render used the component, those rerenders passed a different root type, so React remounted and the manual canvas toggle was lost. Both rerenders now use the same component, which is what makes rerender behave. --- .../main/snapshotMainContent.spec.tsx | 198 +++++++++--------- 1 file changed, 103 insertions(+), 95 deletions(-) diff --git a/static/app/views/preprod/snapshots/main/snapshotMainContent.spec.tsx b/static/app/views/preprod/snapshots/main/snapshotMainContent.spec.tsx index 97dcbe40e79f..0277a5da9a61 100644 --- a/static/app/views/preprod/snapshots/main/snapshotMainContent.spec.tsx +++ b/static/app/views/preprod/snapshots/main/snapshotMainContent.spec.tsx @@ -65,10 +65,10 @@ function buildProps( return {...defaultProps, ...props}; } -function renderSnapshotMainContent( - props: Partial> = {} +function ExampleSnapshotMainContent( + props: Partial> ) { - return render( + return ( @@ -151,7 +151,7 @@ describe('SnapshotMainContent', () => { it('keeps the diff/head toggle visible when viewing the head-only comparison', async () => { const onToggleSoloView = jest.fn(); - renderSnapshotMainContent({onToggleSoloView}); + render(); expect(screen.getByText('Diff')).toBeInTheDocument(); expect(screen.getByText('Head')).toBeInTheDocument(); @@ -165,31 +165,33 @@ describe('SnapshotMainContent', () => { it('renders focused changed snapshots with diff controls and navigation state', async () => { const onNavigateSingleView = jest.fn(); - renderSnapshotMainContent({ - canNavigateNext: true, - canNavigatePrev: false, - comparisonType: 'diff', - headBranch: 'feature/snapshot-updates', - isSoloView: false, - listItems: [ - { + render( + + ); expect(screen.getByText('Buttons')).toBeInTheDocument(); expect(screen.getByText('Button / light')).toBeInTheDocument(); @@ -220,16 +222,18 @@ describe('SnapshotMainContent', () => { type: 'changed' as const, }; - renderSnapshotMainContent({ - comparisonType: 'diff', - diffMode: 'split', - isSoloView: false, - listItems: [changedItem], - selectedItem: changedItem, - onOverlayOpacityChange, - overlayOpacity: 100, - viewMode: 'single', - }); + render( + + ); // Presets live inside the color picker popover, not the toolbar itself. expect( @@ -258,15 +262,17 @@ describe('SnapshotMainContent', () => { type: 'changed' as const, }; - renderSnapshotMainContent({ - comparisonType: 'diff', - diffMode: 'split', - isSoloView: false, - listItems: [changedItem], - selectedItem: changedItem, - overlayOpacity: 50, - viewMode: 'single', - }); + render( + + ); await userEvent.click(screen.getByRole('button', {name: 'Pick overlay color'})); @@ -301,16 +307,14 @@ describe('SnapshotMainContent', () => { viewMode: 'single' as const, }; - const {rerender} = renderSnapshotMainContent({...props, overlayOpacity: 100}); + const {rerender} = render( + + ); await userEvent.click(screen.getByRole('button', {name: 'Hide overlay'})); expect(onOverlayOpacityChange).toHaveBeenLastCalledWith(0); - rerender( - - - - ); + rerender(); await userEvent.click(screen.getByRole('button', {name: 'Show overlay'})); expect(onOverlayOpacityChange).toHaveBeenLastCalledWith(100); @@ -325,14 +329,16 @@ describe('SnapshotMainContent', () => { type: 'changed' as const, }; - renderSnapshotMainContent({ - comparisonType: 'diff', - diffMode: 'wipe', - isSoloView: false, - listItems: [changedItem], - selectedItem: changedItem, - viewMode: 'single', - }); + render( + + ); expect( screen.queryByRole('button', {name: 'Pick overlay color'}) @@ -341,18 +347,20 @@ describe('SnapshotMainContent', () => { }); it('renders focused errored snapshots side-by-side with a failed badge', () => { - renderSnapshotMainContent({ - comparisonType: 'diff', - isSoloView: false, - selectedItem: { - key: 'errored-screens', - name: 'Screens', - displayName: 'Screens', - pairs: [erroredPair], - type: 'errored', - }, - viewMode: 'single', - }); + render( + + ); expect(screen.getByText('Screens')).toBeInTheDocument(); expect(screen.getByText('Login screen')).toBeInTheDocument(); @@ -369,18 +377,20 @@ describe('SnapshotMainContent', () => { }); it('renders focused renamed snapshots as a single image with pair metadata', async () => { - renderSnapshotMainContent({ - comparisonType: 'diff', - isSoloView: false, - selectedItem: { - key: 'renamed-buttons', - name: 'Buttons', - displayName: 'Buttons', - pairs: [renamedPair], - type: 'renamed', - }, - viewMode: 'single', - }); + render( + + ); expect(screen.getByText('Buttons')).toBeInTheDocument(); expect(screen.getByText('Renamed')).toBeInTheDocument(); @@ -450,22 +460,20 @@ describe('SnapshotMainContent', () => { function renderSingleView(img: SnapshotImage) { const item = soloItem(img); - const view = renderSnapshotMainContent({ - listItems: [item], - selectedItem: item, - viewMode: 'single', - }); + const view = render( + + ); const renderItem = (nextItem: SidebarItem) => view.rerender( - - - + ); return { navigateTo: (nextImg: SnapshotImage) => renderItem(soloItem(nextImg)), From c852a2f3fd19bc6c41bd424e5db0447a14061c7c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:54:46 -0700 Subject: [PATCH 43/47] test(events): Rename renderFlagDrawer to openFlagDrawer The helper renders, clicks through to the drawer and returns it, so the render call is one step of an interaction flow rather than a wrapper. The name now says that, and the JSX stays visible inside it. --- .../events/featureFlags/eventFeatureFlagDrawer.spec.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/static/app/components/events/featureFlags/eventFeatureFlagDrawer.spec.tsx b/static/app/components/events/featureFlags/eventFeatureFlagDrawer.spec.tsx index 7252b2c6de93..58e9586dff88 100644 --- a/static/app/components/events/featureFlags/eventFeatureFlagDrawer.spec.tsx +++ b/static/app/components/events/featureFlags/eventFeatureFlagDrawer.spec.tsx @@ -11,7 +11,7 @@ import { import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; import {GroupDataContextProvider} from 'sentry/views/issueDetails/groupDataContext'; -async function renderFlagDrawer() { +async function openFlagDrawer() { mockElementSize({width: 0, height: 30}); render( { }); }); it('renders the drawer as expected', async () => { - const drawerScreen = await renderFlagDrawer(); + const drawerScreen = await openFlagDrawer(); expect( within(drawerScreen).getByRole('button', {name: 'Close Drawer'}) ).toBeInTheDocument(); @@ -73,7 +73,7 @@ describe('FeatureFlagDrawer', () => { }); it('allows search to affect displayed flags', async () => { - const drawerScreen = await renderFlagDrawer(); + const drawerScreen = await openFlagDrawer(); const [webVitalsFlag, enableReplay] = MOCK_FLAGS.filter(f => f.result); expect(within(drawerScreen).getByText(webVitalsFlag!.flag)).toBeInTheDocument(); @@ -89,7 +89,7 @@ describe('FeatureFlagDrawer', () => { }); it('allows sort dropdown to affect displayed flags', async () => { - const drawerScreen = await renderFlagDrawer(); + const drawerScreen = await openFlagDrawer(); const [webVitalsFlag, enableReplay] = MOCK_FLAGS.filter(f => f.result); From 95730fbcfe13d88d70ba1b193421939bfb370d09 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:54:50 -0700 Subject: [PATCH 44/47] test(events): Rename renderBreadcrumbDrawer to openBreadcrumbDrawer Same shape as the feature flag drawer: the helper's job is opening the drawer, not wrapping render. --- .../events/breadcrumbs/breadcrumbsDrawer.spec.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/static/app/components/events/breadcrumbs/breadcrumbsDrawer.spec.tsx b/static/app/components/events/breadcrumbs/breadcrumbsDrawer.spec.tsx index 1b8aef335430..6d354d91bbb9 100644 --- a/static/app/components/events/breadcrumbs/breadcrumbsDrawer.spec.tsx +++ b/static/app/components/events/breadcrumbs/breadcrumbsDrawer.spec.tsx @@ -7,7 +7,7 @@ import { } from 'sentry/components/events/breadcrumbs/testUtils'; import {mockElementSize} from 'sentry/utils/fixtures/virtualization'; -async function renderBreadcrumbDrawer() { +async function openBreadcrumbDrawer() { mockElementSize({width: 0, height: 30}); render(); await userEvent.click(screen.getByRole('button', {name: 'View 2 more'})); @@ -16,7 +16,7 @@ async function renderBreadcrumbDrawer() { describe('BreadcrumbsDrawer', () => { it('renders the drawer as expected', async () => { - const drawerScreen = await renderBreadcrumbDrawer(); + const drawerScreen = await openBreadcrumbDrawer(); expect( within(drawerScreen).getByRole('button', {name: 'Close Drawer'}) ).toBeInTheDocument(); @@ -60,7 +60,7 @@ describe('BreadcrumbsDrawer', () => { }); it('allows search to affect displayed crumbs', async () => { - const drawerScreen = await renderBreadcrumbDrawer(); + const drawerScreen = await openBreadcrumbDrawer(); const [warningCrumb, logCrumb] = MOCK_BREADCRUMBS; expect(within(drawerScreen).getByText(warningCrumb.category)).toBeInTheDocument(); @@ -76,7 +76,7 @@ describe('BreadcrumbsDrawer', () => { }); it('allows type filter to affect displayed crumbs', async () => { - const drawerScreen = await renderBreadcrumbDrawer(); + const drawerScreen = await openBreadcrumbDrawer(); const queryCrumb = MOCK_BREADCRUMBS[3]; const requestCrumb = MOCK_BREADCRUMBS[2]; @@ -95,7 +95,7 @@ describe('BreadcrumbsDrawer', () => { }); it('allows level spofilter to affect displayed crumbs', async () => { - const drawerScreen = await renderBreadcrumbDrawer(); + const drawerScreen = await openBreadcrumbDrawer(); const [warningCrumb, logCrumb] = MOCK_BREADCRUMBS; @@ -112,7 +112,7 @@ describe('BreadcrumbsDrawer', () => { }); it('allows sort dropdown to affect displayed crumbs', async () => { - const drawerScreen = await renderBreadcrumbDrawer(); + const drawerScreen = await openBreadcrumbDrawer(); const [warningCrumb, logCrumb] = MOCK_BREADCRUMBS; @@ -145,7 +145,7 @@ describe('BreadcrumbsDrawer', () => { }); it('allows time display dropdown to change all displayed crumbs', async () => { - const drawerScreen = await renderBreadcrumbDrawer(); + const drawerScreen = await openBreadcrumbDrawer(); expect( within(drawerScreen).getAllByText('May 21, 2019 6:00:48.760 PM UTC') ).toHaveLength(MOCK_BREADCRUMBS.length); From 7dd7adf39ea520da63ccd396b01450d4a0af9a3d Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 11:55:41 -0700 Subject: [PATCH 45/47] test(explore): Replace renderButton with ExampleExploreExportModalButton The helper created the onOpen and onClose spies and handed them back, so a test asserting on one had to accept the other. Each test now creates the spy it asserts on and passes it as a prop. --- .../exports/exploreExportModalButton.spec.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/static/app/views/explore/components/exports/exploreExportModalButton.spec.tsx b/static/app/views/explore/components/exports/exploreExportModalButton.spec.tsx index c47ae4508584..c80017095654 100644 --- a/static/app/views/explore/components/exports/exploreExportModalButton.spec.tsx +++ b/static/app/views/explore/components/exports/exploreExportModalButton.spec.tsx @@ -36,25 +36,20 @@ function makeConfig(): ExploreExportConfig { }; } -function renderButton( - props: Partial> = {} +function ExampleExploreExportModalButton( + props: Partial> ) { - const onOpen = jest.fn(); - const onClose = jest.fn(); - render( + return ( , - {organization} + /> ); - renderGlobalModal(); - return {onOpen, onClose}; } describe('ExploreExportModalButton', () => { @@ -63,7 +58,9 @@ describe('ExploreExportModalButton', () => { }); it('opens the modal and fires onOpen when clicked', async () => { - const {onOpen} = renderButton(); + const onOpen = jest.fn(); + render(, {organization}); + renderGlobalModal(); await userEvent.click(screen.getByRole('button', {name: 'Export'})); @@ -72,7 +69,9 @@ describe('ExploreExportModalButton', () => { }); it('fires onClose with escape_key when closed via Escape', async () => { - const {onClose} = renderButton(); + const onClose = jest.fn(); + render(, {organization}); + renderGlobalModal(); await userEvent.click(screen.getByRole('button', {name: 'Export'})); expect(await screen.findByRole('dialog')).toBeInTheDocument(); @@ -85,7 +84,9 @@ describe('ExploreExportModalButton', () => { }); it('fires onClose once with cancel_button when the Cancel button is clicked', async () => { - const {onClose} = renderButton(); + const onClose = jest.fn(); + render(, {organization}); + renderGlobalModal(); await userEvent.click(screen.getByRole('button', {name: 'Export'})); await userEvent.click(await screen.findByRole('button', {name: 'Cancel'})); @@ -97,7 +98,8 @@ describe('ExploreExportModalButton', () => { }); it('disables the button with a tooltip when data is empty', async () => { - renderButton({isDataEmpty: true}); + render(, {organization}); + renderGlobalModal(); const button = screen.getByRole('button', {name: 'Export'}); expect(button).toBeDisabled(); From 1ebed8038e5b16d6e8f63abb518c1ae42b9614f3 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Tue, 22 Sep 2026 12:56:16 -0700 Subject: [PATCH 46/47] meta(refactor-tasks): Drop the no-custom-render-helper convention from this branch The rule still fails on the scanner's default model, so it is not ready to land alongside the test conversions. This branch now carries only the spec changes; the rule is recoverable from 37e8628be2e if it is picked up in its own PR. --- .../conventions/no-custom-render-helper.yaml | 161 ------------------ 1 file changed, 161 deletions(-) delete mode 100644 .sentry-refactor-tasks/conventions/no-custom-render-helper.yaml diff --git a/.sentry-refactor-tasks/conventions/no-custom-render-helper.yaml b/.sentry-refactor-tasks/conventions/no-custom-render-helper.yaml deleted file mode 100644 index 4e79c22e6814..000000000000 --- a/.sentry-refactor-tasks/conventions/no-custom-render-helper.yaml +++ /dev/null @@ -1,161 +0,0 @@ -name: no-custom-render-helper -severity: warning -tags: [testing, react, jest] - -why: | - Test files often grow a local `renderFoo()` helper that only forwards its - arguments into the shared `render()` / `renderHookWithProviders()` helper from - `sentry-test/reactTestingLibrary`. Every file invents its own name and its own - argument shape, so a reader must first go read the helper before any `it()` - block makes sense, and the standard RTL return value (`rerender`, `router`, - `unmount`) sometimes gets dropped or renamed on the way out. - - When a test needs fixed props, wrapper providers or a bit of glue, put that in - a component instead of in a function. A small `ExampleFoo` component keeps - every call site in the shape the rest of the codebase already uses — - `render()` — so the JSX is visible in the test, extra props can - be passed per test, and `rerender()` works the way RTL - documents it. - - See: .agents/skills/react-testing/SKILL.md - -detect: | - In a test file (`*.spec.ts` / `*.spec.tsx`), look for a locally declared - function whose only job is to call the shared render helper: - - - A `function renderX(...)` declaration, or a `const renderX = (...) => ...` - arrow, declared in the test file or inside a `describe()` block. - - Its body returns (or directly calls) `render(...)`, `renderGlobalModal(...)` - or `renderHookWithProviders(...)` imported from - `sentry-test/reactTestingLibrary`. - - The JSX / hook callback it renders is essentially fixed: the helper's - parameters are spread into props, used to pick between a few literal - prop values, or not used at all. - - Flag the helper declaration itself. - - Do NOT flag: - - Helpers that do real test setup beyond rendering — registering - `MockApiClient.addMockResponse()` mocks, seeding stores, building fixtures, - advancing timers — and never call `render()` (e.g. `renderMockRequests()`). - - Helpers that return extra handles the test needs alongside the render - result, such as a `jest.fn()` spy installed through a context provider - (`return {tracking, ...render(ui, {additionalWrapper})}`). - - Helpers that take the element to render as a parameter and only supply - providers or options (`renderWithX(ui)`), since there is no fixed JSX to - move into a component. - - Render-prop callbacks and test components that return JSX without calling - `render()` at all. - - `render` / `renderHookWithProviders` themselves in - `tests/js/sentry-test/reactTestingLibrary.tsx`, and any other shared helper - under `tests/js/`. - -fix: | - Replace the helper function with a component, then call the shared `render()` - at each call site. - - 1. Name the component `Example` — `ExampleLoadingContainer`, - `ExampleWidgetModal`. (Older tests in the repo use `Test`; either - name is fine, be consistent inside a file.) - 2. Give it the props the helper took as arguments, so each test can vary only - what it cares about. - 3. Move provider wrappers into the component's JSX; move `render()` options - (`organization`, `initialRouterConfig`, `additionalWrapper`) to the call - sites that need them. - 4. Delete the helper and call `render()` in each test. - - Example before: - function renderComponent(props: LoadingContainerProps = {}) { - return render( - -
hello!
-
- ); - } - - it('handles loading', () => { - const {rerender} = renderComponent({isLoading: true}); - }); - - Example after: - function ExampleLoadingContainer(props: LoadingContainerProps) { - return ( - -
hello!
-
- ); - } - - it('handles loading', () => { - const {rerender} = render(); - rerender(); - }); - - For a hook helper, the same move applies to `renderHookWithProviders`: - - // Before - function renderProviders() { - return renderHookWithProviders(() => useScmMessagingProviders(), {organization}); - } - // After — inline the hook call, keep the options at the call site - const {result} = renderHookWithProviders(() => useScmMessagingProviders(), { - organization, - }); - - Gotchas: - 1. Do not re-export the render result through a renamed object; tests expect - the standard `{rerender, unmount, router}` shape from `render()`. - 2. A helper that also returns a spy or a mock handle is doing two jobs — - move the JSX into the component and leave the spy setup in the test, or - leave the helper alone if the spy must be installed through a provider. - 3. Components declared inside `describe()` re-create their identity on every - call; declare `ExampleFoo` at module scope so `rerender` keeps the same - component type and preserves state. - 4. Helpers that are `async` usually await something after rendering (a - `findBy` query, a router navigation) — that awaited part belongs in the - test body, not in the component. - -examples: - bad: - - | - function renderComponent(props: LoadingContainerProps = {}) { - return render( - -
hello!
-
- ); - } - - | - const renderAvatars = ({users, teams}: Props) => - render(); - - | - function renderProviders() { - return renderHookWithProviders(() => useScmMessagingProviders(), {organization}); - } - good: - - | - function ExampleLoadingContainer(props: LoadingContainerProps) { - return ( - -
hello!
-
- ); - } - // in the test: render(); - - | - // Setup helper that registers mocks and never renders - function mockProjectRequests({firstIssue}: {firstIssue?: string} = {}) { - MockApiClient.addMockResponse({url: '/projects/org-slug/project-slug/', body: {}}); - } - - | - // Wrapper that installs a spy through a provider and takes the element - function renderWithTracking(ui: React.ReactElement) { - const tracking = jest.fn(); - return {tracking, ...render(ui, {additionalWrapper: TrackingWrapper})}; - } - -include: - - 'static/**/*.spec.tsx' - - 'static/**/*.spec.ts' - -prefilter: "grep -rlE '^[[:space:]]*(export )?(async )?function render[A-Z]|^[[:space:]]*(const|let) render[A-Z][A-Za-z0-9]*[[:space:]]*=' --include='*.spec.tsx' --include='*.spec.ts' {repo_path}/static/" From d64216b8c6d10991cd372bb5cf36b361cd347f2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 22 Sep 2026 20:07:03 +0000 Subject: [PATCH 47/47] test(spansSearchBar): Use regular React props instead of object spread Co-authored-by: Ryan Albrecht --- .../filterResultsStep/spansSearchBar.spec.tsx | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx index 9dc364b287f4..74b3a11d3f14 100644 --- a/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx +++ b/static/app/views/dashboards/widgetBuilder/buildSteps/filterResultsStep/spansSearchBar.spec.tsx @@ -101,11 +101,9 @@ describe('SpansSearchBar', () => { render( ); @@ -117,11 +115,9 @@ describe('SpansSearchBar', () => { render( ); @@ -146,11 +142,9 @@ describe('SpansSearchBar', () => { render( );