M8-R20: enforce warning-free frontend tests
pytest / test (push) Successful in 4m14s
frontend / frontend (push) Successful in 59s

This commit is contained in:
2026-08-28 16:54:15 +02:00
parent 35691e08eb
commit 9e93ca0db4
8 changed files with 158 additions and 140 deletions
+11 -6
View File
@@ -15,7 +15,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { screen, waitFor } from '@testing-library/react'
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '../test-utils'
import { MeterManager } from './MeterManager'
@@ -1197,20 +1197,24 @@ describe('MeterManager — lifecycle modal submissions', () => {
let input: HTMLInputElement
let submit: HTMLElement
let modal: HTMLElement
if (entry === 'close') {
await user.click(await screen.findByRole('button', { name: 'Close meter' }))
input = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
modal = screen.getByTestId(`close-meter-modal-${ACTIVE_METER.id}`)
input = modal.querySelector('input[type="datetime-local"]')!
submit = screen.getAllByRole('button', { name: 'Close meter' })[1]
} else if (entry === 'unbind') {
await user.click(await screen.findByRole('button', { name: 'Unbind' }))
input = screen.getByTestId('unbind-modal-pending-source').querySelector('input[type="datetime-local"]')!
modal = screen.getByTestId('unbind-modal-pending-source')
input = modal.querySelector('input[type="datetime-local"]')!
submit = screen.getAllByRole('button', { name: 'Unbind' })[1]
} else {
await user.click(await screen.findByRole('button', { name: entry === 'direct bind' ? 'Bind source' : entry === 'same-meter transfer' ? 'Transfer source' : 'Recover binding' }))
await chooseChannel(user)
input = entry === 'direct bind'
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`).querySelector('input[type="datetime-local"]')!
: screen.getByTestId('transfer-effective-at')
modal = entry === 'direct bind'
? screen.getByTestId(`direct-bind-modal-${ACTIVE_METER.id}`)
: screen.getByTestId('transfer-modal-pending-source')
input = entry === 'direct bind' ? modal.querySelector('input[type="datetime-local"]')! : screen.getByTestId('transfer-effective-at')
const submitButtons = screen.getAllByRole('button', { name: entry === 'direct bind' ? 'Bind source' : 'Transfer binding' })
submit = submitButtons[submitButtons.length - 1]
}
@@ -1224,6 +1228,7 @@ describe('MeterManager — lifecycle modal submissions', () => {
await user.type(input, '{Enter}')
expect(entry === 'unbind' ? mockPatch : mockPost).toHaveBeenCalledTimes(1)
release()
await waitForElementToBeRemoved(modal)
},
)
+5 -5
View File
@@ -10,7 +10,7 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
import { MantineProvider } from '@mantine/core'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
@@ -199,7 +199,7 @@ describe('HomePage', () => {
altitude: null,
}
expect(capturedOnSelectLocation).toBeDefined()
capturedOnSelectLocation!(record)
act(() => capturedOnSelectLocation!(record))
// EditLocationModal should appear
await waitFor(() => screen.getByTestId('edit-location-modal'))
@@ -216,7 +216,7 @@ describe('HomePage', () => {
longitude: 116.41,
}
expect(capturedOnSelectPoo).toBeDefined()
capturedOnSelectPoo!(record)
act(() => capturedOnSelectPoo!(record))
await waitFor(() => screen.getByTestId('edit-poo-modal'))
expect(screen.getByTestId('edit-poo-modal')).toBeTruthy()
@@ -232,7 +232,7 @@ describe('HomePage', () => {
longitude: 116.4,
altitude: null,
}
capturedOnSelectLocation!(record)
act(() => capturedOnSelectLocation!(record))
await waitFor(() => screen.getByTestId('edit-location-modal'))
fireEvent.click(screen.getByTestId('edit-location-cancel'))
@@ -248,7 +248,7 @@ describe('HomePage', () => {
latitude: 39.91,
longitude: 116.41,
}
capturedOnSelectPoo!(record)
act(() => capturedOnSelectPoo!(record))
await waitFor(() => screen.getByTestId('edit-poo-modal'))
fireEvent.click(screen.getByTestId('edit-poo-cancel'))
+22
View File
@@ -7,6 +7,28 @@
* - ResizeObserver (Mantine uses it for responsive components)
*/
import '@testing-library/jest-dom'
// Import RTL here so its automatic cleanup hook is registered before the
// warning assertion below; later test imports reuse the same module instance.
import { cleanup } from '@testing-library/react'
import { afterEach, beforeEach } from 'vitest'
import { assertNoReactTestWarnings, createReactWarningGuard } from './test-warning-guard'
const originalConsoleError = console.error
const reactTestWarnings: unknown[][] = []
console.error = createReactWarningGuard(originalConsoleError, reactTestWarnings)
beforeEach(() => {
reactTestWarnings.length = 0
})
afterEach(() => {
// Run teardown before checking the guard. RTL's automatic cleanup is also
// registered, but cleanup is idempotent and this ordering keeps a guard
// failure from preventing a later test from starting with stale portals.
cleanup()
assertNoReactTestWarnings(reactTestWarnings)
})
// ---------------------------------------------------------------------------
// window.matchMedia polyfill (jsdom does not implement this)
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest'
import {
assertNoReactTestWarnings,
createReactWarningGuard,
formatReactTestWarnings,
isReactTestWarning,
} from './test-warning-guard'
describe('React test warning guard', () => {
it('recognizes React act and duplicate-key warnings only', () => {
expect(isReactTestWarning(['Warning: An update to Example inside a test was not wrapped in act(...).'])).toBe(true)
expect(isReactTestWarning(['Warning: Each child in a list should have a unique "key" prop.'])).toBe(true)
expect(isReactTestWarning(['Warning: Encountered two children with the same key, `duplicate`.'])).toBe(true)
expect(isReactTestWarning(['network request failed'])).toBe(false)
})
it('collects target warnings for one compact diagnostic and forwards unrelated errors', () => {
const originalConsoleError = vi.fn()
const warnings: unknown[][] = []
const guardedConsoleError = createReactWarningGuard(originalConsoleError, warnings)
guardedConsoleError('Warning: An update to Example inside a test was not wrapped in act(...)')
guardedConsoleError('Warning: An update to Other inside a test was not wrapped in act(...)')
guardedConsoleError('Warning: Encountered two children with the same key, `duplicate`.')
guardedConsoleError('network request failed', { status: 500 })
expect(originalConsoleError).toHaveBeenCalledTimes(1)
expect(originalConsoleError).toHaveBeenCalledWith('network request failed', { status: 500 })
expect(formatReactTestWarnings(warnings)).toBe(
'React act warning: 2, React duplicate-key warning: 1 (Example, Other)',
)
expect(() => assertNoReactTestWarnings(warnings)).toThrow(
'React test warning guard: React act warning: 2, React duplicate-key warning: 1 (Example, Other).',
)
})
it('passes only when no target warnings were captured', () => {
expect(() => assertNoReactTestWarnings([])).not.toThrow()
})
})
+55
View File
@@ -0,0 +1,55 @@
/**
* Fail tests that emit React warnings which otherwise only reach console.error.
*
* The guard intentionally forwards every unrelated console error unchanged.
* It collects only the two warnings that make React test results unreliable and
* reports one compact error per test instead of flooding CI logs with stacks.
*/
export function isReactTestWarning(args: unknown[]): boolean {
const message = args.map((arg) => String(arg)).join(' ')
return (
message.includes('not wrapped in act(...)') ||
message.includes('Each child in a list should have a unique "key"') ||
message.includes('Encountered two children with the same key')
)
}
export function createReactWarningGuard(
originalConsoleError: (...args: unknown[]) => void,
warnings: unknown[][],
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
if (isReactTestWarning(args)) {
warnings.push(args)
return
}
originalConsoleError(...args)
}
}
export function formatReactTestWarnings(warnings: unknown[][]): string {
const kinds = new Map<string, number>()
for (const args of warnings) {
const kind = args.map((arg) => String(arg)).join(' ').includes('not wrapped in act(...)')
? 'React act warning'
: 'React duplicate-key warning'
kinds.set(kind, (kinds.get(kind) ?? 0) + 1)
}
const summaries = [...kinds.entries()].map(([kind, count]) => `${kind}: ${count}`).join(', ')
const components = [...new Set(warnings
.map((args) => {
const message = String(args[0])
return message.includes('An update to %s inside a test') ? String(args[1]) : message.match(/An update to (.+?) inside a test/)?.[1]
})
.filter((component): component is string => Boolean(component)))].slice(0, 3)
return components.length > 0 ? `${summaries} (${components.join(', ')})` : summaries
}
export function assertNoReactTestWarnings(warnings: unknown[][]): void {
if (warnings.length > 0) {
throw new Error(
`React test warning guard: ${formatReactTestWarnings(warnings)}. ` +
'Await the observable update, close/removal, or query settlement that caused it.',
)
}
}