M8-R20: enforce warning-free frontend tests
This commit is contained in:
Generated
+19
-128
@@ -27,8 +27,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
@@ -1932,7 +1933,6 @@
|
||||
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -1975,52 +1975,31 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@testing-library/react": {
|
||||
"version": "14.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
|
||||
"integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
|
||||
"version": "16.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
|
||||
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@testing-library/dom": "^9.0.0",
|
||||
"@types/react-dom": "^18.0.0"
|
||||
"@babel/runtime": "^7.12.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react/node_modules/@testing-library/dom": {
|
||||
"version": "9.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
|
||||
"integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/aria-query": "^5.0.1",
|
||||
"aria-query": "5.1.3",
|
||||
"chalk": "^4.1.0",
|
||||
"dom-accessibility-api": "^0.5.9",
|
||||
"lz-string": "^1.5.0",
|
||||
"pretty-format": "^27.0.2"
|
||||
"@testing-library/dom": "^10.0.0",
|
||||
"@types/react": "^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^18.0.0 || ^19.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/react/node_modules/aria-query": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz",
|
||||
"integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"deep-equal": "^2.0.5"
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/user-event": {
|
||||
@@ -3451,39 +3430,6 @@
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-equal": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
|
||||
"integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"array-buffer-byte-length": "^1.0.0",
|
||||
"call-bind": "^1.0.5",
|
||||
"es-get-iterator": "^1.1.3",
|
||||
"get-intrinsic": "^1.2.2",
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-array-buffer": "^3.0.2",
|
||||
"is-date-object": "^1.0.5",
|
||||
"is-regex": "^1.1.4",
|
||||
"is-shared-array-buffer": "^1.0.2",
|
||||
"isarray": "^2.0.5",
|
||||
"object-is": "^1.1.5",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.4",
|
||||
"regexp.prototype.flags": "^1.5.1",
|
||||
"side-channel": "^1.0.4",
|
||||
"which-boxed-primitive": "^1.0.2",
|
||||
"which-collection": "^1.0.1",
|
||||
"which-typed-array": "^1.1.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -3687,27 +3633,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-get-iterator": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz",
|
||||
"integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.2",
|
||||
"get-intrinsic": "^1.1.3",
|
||||
"has-symbols": "^1.0.3",
|
||||
"is-arguments": "^1.1.1",
|
||||
"is-map": "^2.0.2",
|
||||
"is-set": "^2.0.2",
|
||||
"is-string": "^1.0.7",
|
||||
"isarray": "^2.0.5",
|
||||
"stop-iteration-iterator": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-iterator-helpers": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.3.tgz",
|
||||
@@ -4650,23 +4575,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-arguments": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"has-tostringtag": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
@@ -5490,23 +5398,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-is": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind": "^1.0.7",
|
||||
"define-properties": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/object-keys": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
|
||||
@@ -32,8 +32,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.31",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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.',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,10 @@ export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
// Keep test output identical in interactive, agent, and CI environments.
|
||||
// Vitest otherwise selects its minimal agent reporter when CODEX_CI is set,
|
||||
// which hides console output from passing tests.
|
||||
reporters: ['default'],
|
||||
// Mantine-heavy UI tests allocate substantial jsdom resources. Keep the
|
||||
// worker pool bounded so concurrent CI jobs remain reliable.
|
||||
maxWorkers: 2,
|
||||
|
||||
Reference in New Issue
Block a user