Files
supabase/apps/studio/components/interfaces/Database/Replication/Destinations.test.tsx
T
Danny White 6c829b32da fix(studio): enable Pipelines before opening creation (#51311)
## Problem

Add pipeline opens the creation sheet before users enable Pipelines. A
pending or failed source lookup also lets creation open with an unknown
enablement state.

## Solution

Show the existing enablement dialog first when required, then open
creation after successful enablement. Cancellation and failed enablement
keep creation closed; enabling through the page menu does not open
creation.

Disable both Add pipeline buttons and their keyboard shortcut until the
source lookup succeeds. Failed lookups show an error with Retry,
including the local replication configuration message. Analytics Bucket
keeps its existing creation path.

| Before | After |
| --- | --- |
| <img width="1275" height="919" alt="Pipelines Database Shears Toolshed
Supabase"
src="https://github.com/user-attachments/assets/cf328732-4342-4758-bde2-98b393341d6a"
/> | _No longer in sheet; dialog is shown conditionally before sheet._ |
| <img width="1275" height="919" alt="Pipelines Database Shears Toolshed
Supabase"
src="https://github.com/user-attachments/assets/f593e271-d29c-4955-8849-0bb64946d4cc"
/> | <img width="1275" height="919" alt="Pipelines Database Shears
Toolshed Supabase"
src="https://github.com/user-attachments/assets/3257d391-b892-44df-85d1-5a2108072312"
/>|
| _“Enable Pipelines”_ | _“Enable”_ |

| After |
| --- |
| <img width="1275" height="919" alt="Pipelines Database Shears Toolshed
Supabase"
src="https://github.com/user-attachments/assets/b11770a1-4785-49ee-950d-d821892b3245"
/> |
| _Loading_ |
| <img width="1275" height="919" alt="Pipelines Database Shears Toolshed
Supabase"
src="https://github.com/user-attachments/assets/710f82be-ca6f-4337-a5ce-b65f9d7f6a32"
/> |
| _Lookup failed_ |
| <img width="1275" height="919" alt="Pipelines Database Shears Toolshed
Supabase"
src="https://github.com/user-attachments/assets/68486ca8-37e8-4b27-89ae-8004f159c38a"
/> |
| _Plan-access loading, throttled_ |

## Review instructions

Use a project with Pipelines access and a working replication API (which
should work on [deploy
preview](https://studio-staging-git-dnywh-fixpipeline-enable-create-supabase.vercel.app/)).
Test the PR preview or locally
([instructions](https://app.notion.com/p/supabase/Danny-s-Local-ETL-Pipelines-Setup-3b25004b775f8058a108f8f67fc813e9?source=copy_link)).
In DevTools Network, enable **Disable cache** before each reload.
Analytics Bucket intentionally bypasses the source-status guard.

1. **Loading:** select **Slow 3G**, reload, and watch the request ending
in `/replication/<ref>/sources`. While it is pending, both **Add
pipeline** buttons must be disabled and **Shift+N** must open nothing.
Both buttons replace the plus with a loading spinner and have no loading
tooltip. Restore **No throttling** afterwards.
2. **Lookup failed:** right-click that source request and choose **Block
request URL**, then reload. After retries finish, expect **Failed to
retrieve pipeline enablement status** with **Retry**, disabled Add
buttons whose tooltip matches the error title, and no sheet/dialog from
**Shift+N**. An unconfigured local replication API instead shows
**Replication unavailable locally**. Unblock the request before clicking
**Retry**.
3. **Lookup succeeded:** on a disposable project with Pipelines
disabled, successful Retry restores Add pipeline. Clicking it opens
**Enable Pipelines**. Cancel stays on the list; **Enable** opens the
sheet after successful enablement. On an already enabled project, Add
pipeline opens the sheet directly. Enabling through the page's three-dot
menu stays on the list. Analytics Bucket opens its sheet without ETL
enablement.
4. **Plan-access loading is separate:** in Chrome 145 or newer, find
`/organizations/<slug>/entitlements` in Network, right-click it and
choose **Throttle request**. In the **Request conditions** drawer,
select Slow 3G for that request only, leaving global throttling off.
Reload on a Pro organisation with Pipelines disabled and open Add
pipeline after the source lookup succeeds. While entitlements remain
pending, expect body shimmers, the accessible “Checking Pipelines
access…” status, a disabled loading **Enable** button, and no upgrade
prompt. Remove the request condition afterwards. If your DevTools lacks
per-request throttling, use the component tests for deterministic
coverage.
2026-10-08 10:42:39 +11:00

239 lines
8.9 KiB
TypeScript

import { QueryClient } from '@tanstack/react-query'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import type { components } from 'api-types'
import { mockAnimationsApi } from 'jsdom-testing-mocks'
import { http, HttpResponse } from 'msw'
import { useQueryState } from 'nuqs'
import { beforeEach, expect, test, vi } from 'vitest'
import { Destinations } from './Destinations'
import { replicationKeys } from '@/data/replication/keys'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, mswServer, type APIErrorBody } from '@/tests/lib/msw'
mockAnimationsApi()
const options = vi.hoisted(() => ({
legacy: false,
hasAccess: true,
isLoading: false,
failEnable: false,
failRefresh: false,
}))
vi.mock('./useIsETLPrivateAlpha', () => ({
useIsETLBigQueryPrivateAlpha: () => !options.legacy,
useIsETLIcebergPrivateAlpha: () => options.legacy,
useIsETLDucklakePrivateAlpha: () => false,
useIsETLSnowflakePrivateAlpha: () => false,
useIsETLClickHousePrivateAlpha: () => false,
}))
vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
useCheckEntitlements: () => ({ hasAccess: options.hasAccess, isLoading: options.isLoading }),
}))
vi.mock('./DestinationPanel/DestinationPanel', () => ({
DestinationPanel: () => {
const [type] = useQueryState('destinationType')
return type ? <h2>Creation sheet: {type}</h2> : null
},
}))
let isEnabled = false
let enableGate: Promise<void> | undefined
beforeEach(() => {
isEnabled = false
enableGate = undefined
options.legacy = false
options.hasAccess = true
options.isLoading = false
options.failRefresh = false
options.failEnable = false
mswServer.use(
http.get('http://localhost:3000/api/enabled-features-overrides', () =>
HttpResponse.json<{ disabled_features: string[] }>({ disabled_features: [] })
)
)
addAPIMock({
method: 'get',
path: '/platform/projects/:ref',
response: () => HttpResponse.json<APIErrorBody>({ message: 'Unavailable' }, { status: 404 }),
})
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/destinations',
response: () =>
HttpResponse.json<components['schemas']['DestinationsResponse_Output']>({ destinations: [] }),
})
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/pipelines',
response: () =>
HttpResponse.json<components['schemas']['PipelinesResponse_Output']>({ pipelines: [] }),
})
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/sources',
response: () =>
options.failRefresh && isEnabled
? HttpResponse.json<APIErrorBody>(
{ message: 'replication API URL is not configured' },
{ status: 503 }
)
: HttpResponse.json<components['schemas']['SourcesResponse_Output']>({
sources: isEnabled
? [
{
id: 42,
name: 'default',
tenant_id: 'tenant',
config: {
host: 'localhost',
port: 5432,
name: 'postgres',
username: 'postgres',
},
},
]
: [],
}),
})
addAPIMock({
method: 'post',
path: '/platform/replication/:ref/tenants-sources',
response: async () => {
await enableGate
if (options.failEnable) {
options.failEnable = false
return HttpResponse.json<APIErrorBody>({ message: 'Unavailable' }, { status: 503 })
}
isEnabled = true
return HttpResponse.json<components['schemas']['CreateTenantSourceResponse_Output']>({
source_id: 42,
tenant_id: 'tenant',
})
},
})
})
const renderList = async (waitForSources = true) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const view = customRender(<Destinations />, { queryClient, nuqs: { hasMemory: true } })
await screen.findByRole('heading', { name: 'Add a pipeline' })
if (waitForSources)
await waitFor(() =>
expect(queryClient.getQueryState(replicationKeys.sources('default'))?.status).toBe('success')
)
return view
}
const addPipeline = () =>
fireEvent.click(screen.getAllByRole('button', { name: 'Add pipeline' })[0])
test.each([true, false, 'retry', 'refresh error'])(
'opens creation after enabling when needed (enabled: %s)',
async (enabled) => {
isEnabled = enabled === true
options.failEnable = enabled === 'retry'
const view = await renderList()
options.failRefresh = enabled === 'refresh error'
options.isLoading = enabled !== true
options.hasAccess = !options.isLoading
addPipeline()
if (enabled !== true) {
expect(screen.getByText('Checking Pipelines access…')).toBeInTheDocument()
expect(screen.queryByText('Pipelines requires the Pro plan.')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Enable' })).toBeDisabled()
options.isLoading = false
options.hasAccess = true
view.rerender(<Destinations />)
fireEvent.click(screen.getByRole('button', { name: 'Enable' }))
if (enabled === 'retry') {
await waitFor(() => expect(options.failEnable).toBe(false))
await waitFor(() => expect(screen.getByRole('button', { name: 'Enable' })).toBeEnabled())
expect(screen.queryByRole('heading', { name: /Creation sheet/ })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Enable' }))
}
}
if (enabled === 'refresh error') {
await screen.findByRole('button', { name: 'Retry' })
expect(screen.queryByRole('heading', { name: /Creation sheet/ })).not.toBeInTheDocument()
options.failRefresh = false
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
await waitFor(() =>
expect(screen.getAllByRole('button', { name: 'Add pipeline' })[0]).not.toHaveAttribute(
'aria-disabled',
'true'
)
)
addPipeline()
}
await screen.findByRole('heading', { name: 'Creation sheet: BigQuery' })
}
)
test('dismissal during enablement prevents a late response opening creation', async () => {
let finish: (() => void) | undefined
enableGate = new Promise<void>((resolve) => {
finish = resolve
})
await renderList()
addPipeline()
fireEvent.click(await screen.findByRole('button', { name: 'Enable' }))
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' })
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
finish?.()
fireEvent.pointerDown(screen.getByRole('button', { name: 'More actions' }), {
button: 0,
ctrlKey: false,
})
expect(await screen.findByRole('menuitem', { name: 'Disable Pipelines' })).toBeInTheDocument()
expect(screen.queryByRole('heading', { name: /Creation sheet/ })).not.toBeInTheDocument()
})
test('creation defaults to BigQuery regardless of legacy destination flags', async () => {
options.legacy = true
isEnabled = true
await renderList()
addPipeline()
await screen.findByRole('heading', { name: 'Creation sheet: BigQuery' })
})
test.each(['loading', 'error'])('blocks creation when source status is %s', async (state) => {
let finish: (() => void) | undefined
let hasFailed = false
addAPIMock({
method: 'get',
path: '/platform/replication/:ref/sources',
response: async () => {
if (state === 'loading')
await new Promise<void>((resolve) => {
finish = resolve
})
if (state === 'error' && !hasFailed) {
hasFailed = true
return HttpResponse.json<APIErrorBody>(
{ message: 'replication API URL is not configured' },
{ status: 503 }
)
}
return HttpResponse.json<components['schemas']['SourcesResponse_Output']>({ sources: [] })
},
})
await renderList(false)
if (state === 'error') {
await screen.findByRole('button', { name: 'Retry' })
const button = screen.getAllByRole('button', { name: 'Add pipeline' })[0]
expect(button).toHaveAttribute('aria-disabled', 'true')
fireEvent.focus(button)
expect(await screen.findByRole('tooltip')).toHaveTextContent('Replication unavailable locally')
} else {
expect(screen.getAllByRole('button', { name: 'Add pipeline' })[0]).toBeDisabled()
expect(screen.queryByRole('tooltip')).not.toBeInTheDocument()
}
addPipeline()
fireEvent.keyDown(document, { key: 'N', shiftKey: true })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: /Creation sheet/ })).not.toBeInTheDocument()
if (state === 'error') fireEvent.click(await screen.findByRole('button', { name: 'Retry' }))
else {
await waitFor(() => expect(finish).toBeDefined())
finish?.()
}
await waitFor(() =>
expect(screen.getAllByRole('button', { name: 'Add pipeline' })[0]).toBeEnabled()
)
addPipeline()
expect(await screen.findByRole('dialog')).toHaveTextContent('Enable Pipelines')
})