Files
supabase/apps/studio/tests/features/logs/LogsPreviewer.test.tsx
Alaister YoungandAlaister Young d676c832f3 fix(studio): pre-empt React 19 regressions in tests + Support form (#45784)
Four React-19-sensitive patterns that pass on React 18 today but break
under React 19 (verified on the in-flight TanStack Start branch).
Landing on master now so the eventual React 19 upgrade is a no-op for
tests, instead of a separate cleanup pass under upgrade pressure.

Each fix is a strict superset / less-fragile equivalent of the existing
pattern, so master (React 18) stays green.

**Changed:**
- `hooks/misc/useStateTransition.ts` — fire on entry into `newTest` from
any state other than `newTest`, instead of requiring exactly `prevTest →
newTest`. React 18+ auto-batches dispatches across awaits (e.g.
`dispatch SUBMIT` in the handler, `dispatch ERROR` in `onError`),
collapsing `editing → submitting → error` into a single render where the
intermediate `submitting` tick is never observed. Strict superset of the
old check for our reducers — `success`/`error` are only reachable from
`submitting`.
- `Support/CategoryAndSeverityInfo.tsx` — guard `onValueChange` against
Radix Select's spurious `''` emission. When the controlled value
transitions from `undefined` to a defined value whose `SelectItem` isn't
mounted yet (dropdown closed → items haven't registered), Radix's hidden
`BubbleSelect` fires `onValueChange('')` and clobbers the field. No
`SelectItem` can have `value=""` (Radix throws), so any `''` is
guaranteed spurious — drop it before calling `field.onChange`.
([radix-ui/primitives#3381](https://github.com/radix-ui/primitives/issues/3381))
- `EditSecretModal.test.tsx` — `getByLabelText` → `findByLabelText`.
Under React 19's scheduling, the decrypted-value query resolves on a
separate render tick, so form fields appear one tick after the skeleton.
- `LogsPreviewer.test.tsx` — `addEventListener('click', spy)` instead of
`loadOlder.onclick = vi.fn()`. React 19 reassigns `.onclick` on managed
elements as part of its event wiring, clobbering the direct-property
spy.

## To test

### Unit tests
- `pnpm --filter studio test` — all unit tests pass on master (React 18)

### Support form URL prefill (Radix Select guard)
- `/support/new?category=Problem` → category dropdown reads "APIs and
client libraries" on first paint
- `/support/new?category=dashboard_bug` → "Dashboard bug"
(case-insensitive match)
- `/support/new?category=invalid_garbage` → falls back to "Select an
issue" placeholder, no crash
- `/support/new?subject=My%20issue&message=Details%20here` → subject and
message inputs are prefilled
- `/support/new?projectRef=<your-ref>&category=Problem` → both project
selector and category set, library selector appears
- With a prefilled URL, click the category dropdown and pick a different
option — the new value sticks (this is the path that surfaced the Radix
bug, want to confirm we didn't break user selection)
- DevTools console on first load should be clean — no React hydration
mismatch warning

### Support form submit (`useStateTransition` success + error branches)
- Submit a valid support form → green toast "Support request sent"
appears **once**, view swaps to the success screen, one `POST
/platform/feedback/send` in the network panel
- Block `POST /platform/feedback/send` in DevTools → submit → red error
toast appears **once** (not twice — if you see two toasts the relaxed
transition is firing more than it should), form stays editable with all
inputs preserved
- Unblock and submit again → success path runs cleanly

### Sidebar support form (same reducer + `useStateTransition`, separate
component)
- Open the support widget in the side nav (`SupportSidebarForm`)
- Repeat the success and error paths — should behave identically

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed category selector to prevent selected values from being
unexpectedly cleared during form interactions.

* **Tests**
* Improved test reliability for modal field rendering and event handling
assertions.

* **Chores**
  * Clarified internal comments for form initialization logic.

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45784)

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-05-11 21:56:51 +08:00

227 lines
7.2 KiB
TypeScript

import { screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LOGS_API_MOCKS } from './logs.mocks'
import { LogsTableName } from '@/components/interfaces/Settings/Logs/Logs.constants'
import {
calculateBarClickTimeRange,
LogsPreviewer,
} from '@/components/interfaces/Settings/Logs/LogsPreviewer'
import useLogsPreview from '@/hooks/analytics/useLogsPreview'
import { customRender, customRenderHook } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
vi.mock('@/components/interfaces/Settings/Logs/LogTable', () => ({
LogTable: ({ data }: { data: any[] }) => (
<div data-testid="log-table-mock">
{data.map((row) => (
<div key={row.id} data-testid="log-row">
{row.event_message}
</div>
))}
</div>
),
}))
dayjs.extend(utc)
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal()
return {
useParams: vi.fn().mockReturnValue({}),
useIsLoggedIn: vi.fn(),
isBrowser: false,
LOCAL_STORAGE_KEYS: (actual as any).LOCAL_STORAGE_KEYS,
...(actual as any),
}
})
vi.mock('@/lib/gotrue', async (importOriginal) => ({
...(await importOriginal()),
auth: { onAuthStateChange: vi.fn() },
}))
beforeEach(() => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/analytics/endpoints/logs.all',
response: LOGS_API_MOCKS,
})
})
test('search loads with whatever is on the URL', async () => {
customRender(
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />,
{
nuqs: {
searchParams: {
s: 'test-search-box-value',
},
},
}
)
await waitFor(() => {
expect(screen.getByRole('textbox')).toHaveValue('test-search-box-value')
})
await waitFor(() => {
expect(screen.getByRole('textbox')).not.toHaveValue('WRONGVALUE!🪿')
})
})
test('useLogsPreview returns data from MSW', async () => {
const { result } = customRenderHook(() =>
useLogsPreview({
projectRef: 'default',
table: LogsTableName.EDGE,
})
)
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
await waitFor(() => {
expect(result.current.logData.length).toBeGreaterThan(0)
})
expect(result.current.logData).toEqual(LOGS_API_MOCKS.result)
})
test('LogsPreviewer passes API data to LogTable', async () => {
customRender(
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />
)
const firstLogEventMessage = LOGS_API_MOCKS.result[0].event_message
await waitFor(() => {
expect(screen.getAllByText(firstLogEventMessage)[0]).toBeInTheDocument()
})
})
test('can toggle log event chart', async () => {
customRender(
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />
)
expect(screen.getByRole('button', { name: /Chart/i })).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByTestId('logs-bar-chart')).toBeInTheDocument()
})
await userEvent.click(screen.getByRole('button', { name: /Chart/i }))
await waitFor(() => {
expect(screen.queryByTestId('logs-bar-chart')).not.toBeInTheDocument()
})
})
test('can click load older', async () => {
customRender(
<LogsPreviewer queryType="api" projectRef="default" tableName={LogsTableName.EDGE} />
)
const loadOlder = await waitFor(
async () => await screen.findByRole('button', { name: /Load older/i }),
{ timeout: 10000 }
)
const handleClick = vi.fn()
loadOlder.addEventListener('click', handleClick)
await userEvent.click(loadOlder)
expect(handleClick).toHaveBeenCalled()
})
describe('calculateBarClickTimeRange', () => {
const clickedTime = '2024-01-15T12:30:00.000Z'
test('uses 15-second range for time ranges less than 2 minutes', () => {
const rangeStart = '2024-01-15T12:00:00.000Z'
const rangeEnd = '2024-01-15T12:01:30.000Z' // 1.5 minutes
const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime)
expect(result.start).toBe('2024-01-15T12:29:52.500Z') // 7.5 seconds before
expect(result.end).toBe('2024-01-15T12:30:07.500Z') // 7.5 seconds after
})
test('uses 2-minute range for time ranges between 2 minutes and 1 hour', () => {
const rangeStart = '2024-01-15T12:00:00.000Z'
const rangeEnd = '2024-01-15T12:30:00.000Z' // 30 minutes
const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime)
expect(result.start).toBe('2024-01-15T12:29:00.000Z') // 1 minute before
expect(result.end).toBe('2024-01-15T12:31:00.000Z') // 1 minute after
})
test('uses 5-minute range for time ranges between 1 and 12 hours', () => {
const rangeStart = '2024-01-15T10:00:00.000Z'
const rangeEnd = '2024-01-15T14:00:00.000Z' // 4 hours
const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime)
expect(result.start).toBe('2024-01-15T12:27:30.000Z') // 2.5 minutes before
expect(result.end).toBe('2024-01-15T12:32:30.000Z') // 2.5 minutes after
})
test('uses 1-hour range for time ranges 12 hours or more', () => {
const rangeStart = '2024-01-15T00:00:00.000Z'
const rangeEnd = '2024-01-15T24:00:00.000Z' // 24 hours
const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime)
expect(result.start).toBe('2024-01-15T12:00:00.000Z') // 30 minutes before
expect(result.end).toBe('2024-01-15T13:00:00.000Z') // 30 minutes after
})
test('handles edge case of exactly 2 minutes range', () => {
const rangeStart = '2024-01-15T12:00:00.000Z'
const rangeEnd = '2024-01-15T12:02:00.000Z' // exactly 2 minutes
const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime)
expect(result.start).toBe('2024-01-15T12:29:00.000Z') // 1 minute before
expect(result.end).toBe('2024-01-15T12:31:00.000Z') // 1 minute after
})
test('handles edge case of exactly 1 hour range', () => {
const rangeStart = '2024-01-15T12:00:00.000Z'
const rangeEnd = '2024-01-15T13:00:00.000Z' // exactly 1 hour
const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime)
expect(result.start).toBe('2024-01-15T12:27:30.000Z') // 2.5 minutes before
expect(result.end).toBe('2024-01-15T12:32:30.000Z') // 2.5 minutes after
})
test('handles edge case of exactly 12 hours range', () => {
const rangeStart = '2024-01-15T00:00:00.000Z'
const rangeEnd = '2024-01-15T12:00:00.000Z' // exactly 12 hours
const result = calculateBarClickTimeRange(rangeStart, rangeEnd, clickedTime)
expect(result.start).toBe('2024-01-15T12:00:00.000Z') // 30 minutes before
expect(result.end).toBe('2024-01-15T13:00:00.000Z') // 30 minutes after
})
test('handles different clicked timestamps correctly', () => {
const rangeStart = '2024-01-15T00:00:00.000Z'
const rangeEnd = '2024-01-15T24:00:00.000Z' // 24 hours
const differentClickedTime = '2024-01-15T06:15:30.000Z'
const result = calculateBarClickTimeRange(rangeStart, rangeEnd, differentClickedTime)
expect(result.start).toBe('2024-01-15T05:45:30.000Z') // 30 minutes before
expect(result.end).toBe('2024-01-15T06:45:30.000Z') // 30 minutes after
})
})