Files
supabase/apps/studio/components/interfaces/Integrations/CronJobs/CronJobsTab.EnableCleanupButton.test.tsx
Alaister Young a06eb5f26f [FE-3724] feat(studio): add enable cleanup button to cron jobs page (#48200)
Adds a standalone **Enable cleanup** button to the Cron Jobs page header
so users can schedule the daily `delete-job-run-details` cleanup job
proactively — previously this was only reachable inside the conditional
"table too big" overflow dialog. Addresses
[FE-3724](https://linear.app/supabase/issue/FE-3724/enable-pg-cron-cleanup-job-from-ui-and-api)
(the UI half; the Management API half needs platform-side work).

**Added:**
- `Enable cleanup` button in the cron jobs header (left of Refresh),
hidden while the existence check loads and whenever a
`delete-job-run-details` job already exists
- Confirmation dialog with a retention-period select (defaults to 7
days), live SQL preview, and telemetry
(`cron_job_cleanup_enable_button_clicked` with `origin` +
`retentionInterval`)
- Component tests (MSW) for visibility gating and the schedule/cancel
flows
- E2E regression test for the full schedule → delete → button-reappears
cycle

**Fixed:**
- Name-based `useCronJobQuery` lookup: the `queryFn` dropped the `name`
param, and a not-found job returned `undefined` (rejected by react-query
v5) — now passes `name` through and returns `CronJob | null`
- Cache invalidation gaps: create/delete now invalidate the whole
cron-jobs prefix (list, count, job details), so the footer count updates
after create/delete and the button reappears after the cleanup job is
deleted. The schedule mutation deliberately invalidates only the
existence check + count (see inline comment)
- Pre-existing e2e leak: the cleanup-workflow test left
`delete-job-run-details` scheduled; it now cleans up after itself

## Screenshots

| Header button | Dialog |
| --- | --- |
| <img width="890" height="325" alt="Screenshot 2026-07-22 at 9 44
40 PM"
src="https://github.com/user-attachments/assets/966cd640-d8a6-4c8f-92e7-73151bf4de9c"
/> | <img width="512" height="461" alt="fe3724-dialog"
src="https://github.com/user-attachments/assets/6be1785f-cc7e-4048-a648-9ef260b0949f"
/> |

## To test

- Go to a project's Integrations → Cron → Jobs with pg_cron enabled and
no `delete-job-run-details` job → the `Enable cleanup` button shows next
to Refresh
- Open the dialog, switch retention intervals → the SQL preview updates;
confirm → success toast, the job appears in the grid (`0 12 * * *`), and
the button disappears without a reload
- Delete the `delete-job-run-details` job from the grid → the button
reappears without a reload
- Create then delete any other job → the footer `Total: N jobs` count
updates both ways without a reload
- Regression: with the high-query-cost banner forced (or via the e2e),
the overflow dialog's "Schedule cleanup job" step still shows its
success state — the dialog must not close mid-flow

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

## Summary by CodeRabbit

* **New Features**
* Added an **Enable cleanup** action to the Cron Jobs tab header,
including a retention selector and SQL preview.
* Enabling schedules the daily cleanup, shows a success toast, updates
the grid, and hides the enable button; **Cancel** closes the dialog
without scheduling.

* **Bug Fixes**
  * Improved cron job lookup to work by name when needed.
* Refreshed related cron job data more reliably after scheduling and
deletion.

* **Telemetry**
  * Added an event for cleanup enable button clicks.

* **Tests**
* Added component and Playwright coverage for
enable/cancel/schedule/delete and cleanup banner flows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-24 16:43:00 +08:00

139 lines
4.9 KiB
TypeScript

import { screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { EnableCleanupButton } from './CronJobsTab.EnableCleanupButton'
import { ProjectContextProvider } from '@/components/layouts/ProjectLayout/ProjectContext'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
import { routerMock } from '@/tests/lib/route-mock'
mockAnimationsApi()
const cleanupJobRow = {
jobid: 1,
jobname: 'delete-job-run-details',
schedule: '0 12 * * *',
command: `DELETE FROM cron.job_run_details WHERE end_time < now() - interval '7 days';`,
active: true,
}
// Mutable state for the pg-meta mock, reset per test. Scheduling flips
// cleanupJobExists so the subsequent existence refetch sees the new job,
// mirroring the real invalidation flow.
let cleanupJobExists = false
let lookupCount = 0
let scheduleQueries: string[] = []
const renderButton = (onScheduled = vi.fn()) => {
customRender(
<ProjectContextProvider projectRef="default">
<EnableCleanupButton onScheduled={onScheduled} />
</ProjectContextProvider>
)
return onScheduled
}
describe('EnableCleanupButton', () => {
beforeEach(() => {
cleanupJobExists = false
lookupCount = 0
scheduleQueries = []
// useSelectedProjectQuery -> useParams
routerMock.setCurrentUrl('/project/default/integrations/cron/jobs')
// useSelectedProjectQuery
addAPIMock({
method: 'get',
path: '/platform/projects/:ref',
// @ts-expect-error partial project shape
response: {
cloud_provider: 'localhost',
id: 1,
inserted_at: '2021-08-02T06:40:40.646Z',
name: 'Default Project',
organization_id: 1,
ref: 'default',
region: 'local',
status: 'ACTIVE_HEALTHY',
},
})
// The existence lookup (useCronJobQuery by name) and the schedule mutation
// both go through the pg-meta query endpoint with different SQL
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const { query } = (await request.json()) as { query: string }
if (query.includes('cron.schedule')) {
scheduleQueries.push(query)
cleanupJobExists = true
return HttpResponse.json([{ schedule: 1 }])
}
// jobname lookup
lookupCount += 1
return HttpResponse.json(cleanupJobExists ? [cleanupJobRow] : [])
},
})
})
test('shows the button once the cleanup job is confirmed missing', async () => {
renderButton()
expect(await screen.findByRole('button', { name: 'Enable cleanup' })).toBeInTheDocument()
})
test('hides the button when the cleanup job already exists', async () => {
cleanupJobExists = true
renderButton()
await waitFor(() => expect(lookupCount).toBeGreaterThan(0))
expect(screen.queryByRole('button', { name: 'Enable cleanup' })).not.toBeInTheDocument()
})
test('schedules the cleanup job with the selected retention interval', async () => {
const onScheduled = renderButton()
await userEvent.click(await screen.findByRole('button', { name: 'Enable cleanup' }))
const dialog = await screen.findByRole('dialog')
expect(within(dialog).getByText('Enable automatic cleanup')).toBeInTheDocument()
expect(within(dialog).getByRole('combobox')).toHaveTextContent('Older than 7 days')
await userEvent.click(within(dialog).getByRole('combobox'))
await userEvent.click(await screen.findByRole('option', { name: 'Older than 1 day' }))
await userEvent.click(within(dialog).getByRole('button', { name: 'Enable cleanup' }))
await waitFor(() => expect(scheduleQueries).toHaveLength(1))
expect(scheduleQueries[0]).toContain(`'delete-job-run-details'`)
expect(scheduleQueries[0]).toContain(`'0 12 * * *'`)
expect(scheduleQueries[0]).toContain(`interval ''1 day''`)
await waitFor(() => expect(onScheduled).toHaveBeenCalledTimes(1))
// The mutation invalidates the existence query, which now returns the job,
// so the whole component (dialog included) unmounts
await waitFor(() => {
expect(screen.queryByRole('button', { name: 'Enable cleanup' })).not.toBeInTheDocument()
})
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
test('cancel closes the dialog without scheduling', async () => {
renderButton()
await userEvent.click(await screen.findByRole('button', { name: 'Enable cleanup' }))
const dialog = await screen.findByRole('dialog')
await userEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(scheduleQueries).toHaveLength(0)
expect(screen.getByRole('button', { name: 'Enable cleanup' })).toBeInTheDocument()
})
})