diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6b71af0..4b9fc61 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 0cfae17..8a1edc8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/energy/MeterManager.test.tsx b/frontend/src/energy/MeterManager.test.tsx index de38fee..deb9749 100644 --- a/frontend/src/energy/MeterManager.test.tsx +++ b/frontend/src/energy/MeterManager.test.tsx @@ -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) }, ) diff --git a/frontend/src/pages/HomePage.test.tsx b/frontend/src/pages/HomePage.test.tsx index 7728820..8937b16 100644 --- a/frontend/src/pages/HomePage.test.tsx +++ b/frontend/src/pages/HomePage.test.tsx @@ -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')) diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index 5854dde..bea4e4b 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -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) diff --git a/frontend/src/test-warning-guard.test.ts b/frontend/src/test-warning-guard.test.ts new file mode 100644 index 0000000..ddd4341 --- /dev/null +++ b/frontend/src/test-warning-guard.test.ts @@ -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() + }) +}) diff --git a/frontend/src/test-warning-guard.ts b/frontend/src/test-warning-guard.ts new file mode 100644 index 0000000..b43f103 --- /dev/null +++ b/frontend/src/test-warning-guard.ts @@ -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() + 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.', + ) + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 1415476..519ddbd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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,