Skip to content

Commit e4df1ed

Browse files
test(frontend): make unit + integration test suites warning-free (#2506)
Eliminate every console warning emitted during `bun run test:unit` and `bun run test:integration` (1124 + 799 tests) so the suites run clean and the test-warning hooks pass without TEST_WARNINGS_ALLOW. Source fix: - tsx-utils Button: destructure `disabledReason` out of the spread in BOTH branches. Previously the no-tooltip branch spread the whole prop bag, so the (falsy) `disabledReason` key still reached RpButton and leaked to the DOM element, which React warns about as an unknown attribute. Test-side act() fixes (no source/behavior change): - create-acl: 5 synchronous tests rendered an RHF form whose default-value / resolver settling landed after the sync body but before unmount, outside act(). Made them async and switched the primary assertion to findByTestId (polls inside act, flushing the pending update). - shadowlink-create-page: page useForm uses mode:'onChange' + async zodResolver; each synchronous fireEvent.input kicked off validation that re-rendered subscribers one microtask later, outside act(). Await each input's settled value, plus the final toast.success, so updates flush inside act(). - group-details: the file's own afterEach store reset ran before RTL cleanup (vitest hooks are LIFO), re-rendering the still-mounted class component outside act(). Wrapped the reset in act() and switched initial waits to findByTestId. Suppressed (external/vendored, not fixable in-repo — each with rationale): - `ref` is not a prop: React 18.3 warning getter read by the CLI-installed registry Slot during asChild composition; resolves in React 19. - isInPortal: valid @redpanda-data/ui Popover prop the component forwards to a DOM node in some render paths. - <button> descendant of <button>: @redpanda-data/ui Accordion renders `heading` inside its trigger button; the consumer-group topic view keeps its action controls in that header by design, so suppress rather than move them into the collapsed panel (a UX regression). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent acb0916 commit e4df1ed

5 files changed

Lines changed: 75 additions & 26 deletions

File tree

frontend/src/components/pages/consumers/group-details.test.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* by the Apache License, Version 2.0
1010
*/
1111

12-
import { screen, waitFor } from 'test-utils';
12+
import { act, screen } from 'test-utils';
1313
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
1414

1515
const { refreshConsumerGroupMock, refreshConsumerGroupAclsMock } = vi.hoisted(() => ({
@@ -108,15 +108,21 @@ describe('GroupDetails - unconsumed partitions', () => {
108108
});
109109

110110
afterEach(() => {
111-
useApiStore.setState({ consumerGroups: new Map(), consumerGroupAcls: new Map() });
111+
// This afterEach runs before the harness's RTL cleanup() (vitest hooks are
112+
// LIFO), so the GroupDetails tree is still mounted and its PageComponent
113+
// store subscription fires forceUpdate() on this reset. Wrap the store
114+
// mutation in act() so that re-render is flushed inside act().
115+
act(() => {
116+
useApiStore.setState({ consumerGroups: new Map(), consumerGroupAcls: new Map() });
117+
});
112118
});
113119

114120
test('edit button is enabled for consumed partition and disabled for unconsumed partitions', async () => {
115121
renderGroupDetails();
116122

117-
await waitFor(() => {
118-
expect(screen.getByTestId('partition-edit-0')).toBeInTheDocument();
119-
});
123+
// findBy* polls inside act(), flushing the class component's async
124+
// refreshData updates and the GroupState Popover's deferred effects.
125+
expect(await screen.findByTestId('partition-edit-0')).toBeInTheDocument();
120126

121127
// Partition 0 has a committed offset → edit button renders as <button>
122128
expect(screen.getByTestId('partition-edit-0').tagName).toBe('BUTTON');
@@ -129,9 +135,9 @@ describe('GroupDetails - unconsumed partitions', () => {
129135
test('unconsumed partitions render "—" for group offset and lag', async () => {
130136
renderGroupDetails();
131137

132-
await waitFor(() => {
133-
expect(screen.getByTestId('partition-edit-1')).toBeInTheDocument();
134-
});
138+
// findBy* polls inside act(), flushing the class component's async
139+
// refreshData updates and the GroupState Popover's deferred effects.
140+
expect(await screen.findByTestId('partition-edit-1')).toBeInTheDocument();
135141

136142
// Each unconsumed partition (1 and 2) shows "—" for both group offset and lag = 4 dashes minimum
137143
const dashes = screen.getAllByText('—');

frontend/src/components/pages/security/acls/create-acl.test.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ const noop = () => {};
1818

1919
describe('CreateACL - principal field composability', () => {
2020
describe('default principal field (no renderPrincipal)', () => {
21-
test('shows the principal type selector when principalType is provided without renderPrincipal', () => {
21+
test('shows the principal type selector when principalType is provided without renderPrincipal', async () => {
2222
renderWithFileRoutes(<CreateACL edit={false} onCancel={noop} principalType={PrincipalTypeUser} />);
2323

24-
expect(screen.getByTestId('shared-principal-type-select')).toBeInTheDocument();
24+
expect(await screen.findByTestId('shared-principal-type-select')).toBeInTheDocument();
2525
});
2626

27-
test('disables the principal type selector when edit is true', () => {
27+
test('disables the principal type selector when edit is true', async () => {
2828
renderWithFileRoutes(
2929
<CreateACL
3030
edit={true}
@@ -34,12 +34,12 @@ describe('CreateACL - principal field composability', () => {
3434
/>
3535
);
3636

37-
expect(screen.getByTestId('shared-principal-type-select')).toBeDisabled();
37+
expect(await screen.findByTestId('shared-principal-type-select')).toBeDisabled();
3838
});
3939
});
4040

4141
describe('custom principal field (renderPrincipal)', () => {
42-
test('hides the default selector and shows custom content when renderPrincipal is provided', () => {
42+
test('hides the default selector and shows custom content when renderPrincipal is provided', async () => {
4343
renderWithFileRoutes(
4444
<CreateACL
4545
edit={false}
@@ -54,12 +54,12 @@ describe('CreateACL - principal field composability', () => {
5454
/>
5555
);
5656

57+
expect(await screen.findByTestId('custom-principal-field')).toBeInTheDocument();
5758
expect(screen.queryByTestId('shared-principal-type-select')).not.toBeInTheDocument();
58-
expect(screen.getByTestId('custom-principal-field')).toBeInTheDocument();
5959
expect(screen.getByText('Role name')).toBeInTheDocument();
6060
});
6161

62-
test('passes correct value to renderPrincipal', () => {
62+
test('passes correct value to renderPrincipal', async () => {
6363
renderWithFileRoutes(
6464
<CreateACL
6565
edit={false}
@@ -69,10 +69,10 @@ describe('CreateACL - principal field composability', () => {
6969
/>
7070
);
7171

72-
expect(screen.getByTestId('principal-value')).toHaveTextContent('RedpandaRole:');
72+
expect(await screen.findByTestId('principal-value')).toHaveTextContent('RedpandaRole:');
7373
});
7474

75-
test('passes disabled=true when edit is true', () => {
75+
test('passes disabled=true when edit is true', async () => {
7676
renderWithFileRoutes(
7777
<CreateACL
7878
edit={true}
@@ -85,7 +85,7 @@ describe('CreateACL - principal field composability', () => {
8585
/>
8686
);
8787

88-
expect(screen.getByTestId('disabled-state')).toHaveTextContent('disabled');
88+
expect(await screen.findByTestId('disabled-state')).toHaveTextContent('disabled');
8989
});
9090
});
9191
});

frontend/src/components/pages/shadowlinks/create/shadowlink-create-page.test.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { fireEvent } from '@testing-library/react';
1313
import userEvent from '@testing-library/user-event';
1414
import { FilterType, PatternType } from 'protogen/redpanda/core/admin/v2/shadow_link_pb';
15+
import { toast } from 'sonner';
1516
import { renderWithFileRoutes, screen, waitFor } from 'test-utils';
1617
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
1718

@@ -112,11 +113,19 @@ const performCreateAction = async (
112113
const nameInput = scr.getByPlaceholderText('my-shadow-link');
113114
// Value-only — tests assert submitted payload shape, not keystroke behaviour.
114115
fireEvent.input(nameInput, { target: { value: action.value } });
116+
// mode: 'onChange' kicks off the async zodResolver, which re-renders the
117+
// form fields once it settles. Await that settle so it lands inside act().
118+
await waitFor(() => {
119+
expect((nameInput as HTMLInputElement).value).toBe(action.value);
120+
});
115121
break;
116122
}
117123
case 'fillBootstrapServer': {
118124
const serverInput = scr.getByTestId(`bootstrap-server-input-${action.index}`);
119125
fireEvent.input(serverInput, { target: { value: action.value } });
126+
await waitFor(() => {
127+
expect((serverInput as HTMLInputElement).value).toBe(action.value);
128+
});
120129
break;
121130
}
122131
case 'addBootstrapServer':
@@ -125,11 +134,17 @@ const performCreateAction = async (
125134
case 'fillScramUsername': {
126135
const usernameInput = scr.getByTestId('scram-username-input');
127136
fireEvent.input(usernameInput, { target: { value: action.value } });
137+
await waitFor(() => {
138+
expect((usernameInput as HTMLInputElement).value).toBe(action.value);
139+
});
128140
break;
129141
}
130142
case 'fillScramPassword': {
131143
const passwordInput = scr.getByTestId('scram-password-input');
132144
fireEvent.input(passwordInput, { target: { value: action.value } });
145+
await waitFor(() => {
146+
expect((passwordInput as HTMLInputElement).value).toBe(action.value);
147+
});
133148
break;
134149
}
135150
case 'enableTLS':
@@ -461,6 +476,15 @@ describe('ShadowLinkCreatePage', () => {
461476

462477
// Run custom verification function
463478
verify(createRequest, expect);
479+
480+
// The submit succeeds asynchronously: after mutateAsync resolves, the
481+
// onSuccess callback fires toast.success and navigate(). Those land as a
482+
// late RHF/router re-render of the still-mounted form fields. Await the
483+
// settled side effect so any final update is flushed inside act() before
484+
// the test ends.
485+
await waitFor(() => {
486+
expect(toast.success).toHaveBeenCalledWith('Shadow link created');
487+
});
464488
});
465489
});
466490

frontend/src/utils/tsx-utils.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -646,17 +646,17 @@ export function LabelTooltip(p: {
646646

647647
export type ButtonProps = Omit<RpButtonProps, 'disabled' | 'isDisabled'> & { disabledReason?: string };
648648
export function Button(p: ButtonProps) {
649-
if (!p.disabledReason) {
650-
return <RpButton {...p} />;
649+
// Destructure disabledReason OUT of the spread in BOTH branches: leaving the key (even with a
650+
// falsy value) lets RpButton forward it to its DOM element, which React warns about as an
651+
// unknown attribute.
652+
const { disabledReason, ...btnProps } = p;
653+
if (!disabledReason) {
654+
return <RpButton {...btnProps} />;
651655
}
652656

653-
const reason = p.disabledReason;
654-
const btnProps = { ...p };
655-
btnProps.disabledReason = undefined;
656-
657657
return (
658-
<Tooltip hasArrow label={reason} placement="top">
659-
<RpButton {...btnProps} className={`${p.className ?? ''} disabled`} isDisabled onClick={undefined} />
658+
<Tooltip hasArrow label={disabledReason} placement="top">
659+
<RpButton {...btnProps} className={`${btnProps.className ?? ''} disabled`} isDisabled onClick={undefined} />
660660
</Tooltip>
661661
);
662662
}

frontend/vitest.setup.integration.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ const originalInfo = console.info;
182182
const SUPPRESSED_PATTERNS = [
183183
// Radix UI ref-forwarding — fixed in React 19, not actionable in React 18
184184
/Function components cannot be given refs/,
185+
// React 18.3 installs a warning getter on `props.ref`; the CLI-installed registry Slot
186+
// (components/redpanda-ui/lib/base-ui-compat.tsx) reads it during asChild composition. Not
187+
// fixable here (vendored, CLI-managed) — fix upstream in the registry; resolves in React 19.
188+
/`ref` is not a prop/,
185189
// Radix DialogContent missing Description/aria-describedby — tracked separately for a11y
186190
/Missing `Description` or `aria-describedby=\{undefined\}` for \{DialogContent\}/,
187191
// happy-dom DOMException noise from unmocked fetch/script loads
@@ -191,6 +195,21 @@ const SUPPRESSED_PATTERNS = [
191195
/socket hang up/,
192196
/ECONNREFUSED/,
193197
/ECONNRESET/,
198+
// `isInPortal` is a valid @redpanda-data/ui Popover prop, but the Popover forwards it to a DOM
199+
// element in some render paths (e.g. hover triggers) instead of consuming it. External component,
200+
// used correctly on our side — fix upstream in @redpanda-data/ui.
201+
// (React formats the prop name as a separate `%s` arg, so match the template + the isInPortal token.)
202+
/React does not recognize the[\s\S]+\bisInPortal\b/,
203+
// @redpanda-data/ui Accordion renders each item's `heading` inside its AccordionButton
204+
// (a real <button>). The consumer-group topic view (pages/consumers/group-details.tsx)
205+
// intentionally keeps the per-topic action controls (edit/delete offsets, "Go to topic")
206+
// in that always-visible header for discoverability; relocating them into the collapsed
207+
// panel was considered and rejected as a UX regression. That choice nests <button> inside
208+
// <button> (invalid HTML). Suppressed pending an Accordion API that supports header-level
209+
// actions rendered outside the trigger button — not fixable without changing UX here.
210+
// (React logs this as a `%s ... <%s>` template with the tag names as separate args, so we
211+
// match the phrase + the <button> token rather than the interpolated string.)
212+
/cannot appear as a descendant of[\s\S]*<button>/,
194213
// @redpanda-data/ui hardcodes `debugTable: true` on its DataTable,
195214
// which makes @tanstack/table-core emit `console.info('Creating Table
196215
// Instance...')` on every table render. Not reachable from our source;

0 commit comments

Comments
 (0)