Files
supabase/apps/studio/components/interfaces/Functions/EdgeFunctionOverview/EdgeFunctionRecentErrors.test.tsx
T
Saxon FletcherandClaude Opus 5.5 862c8634d9 feat(studio): show edge function Overview errors in the shared logs table
The Overview's recent errors section now reuses the Logs tab's queries
and the read-only EmbeddedLogsTable instead of its own SQL and table.
It previews the latest five errors since the last deploy (capped at
24h), notes how many more there are and links to them.

- Rows, "View logs" and "+N more" open the function's Logs tab with the
  same range and error filter. With the unified logs preview off, they
  open the Invocations or Logs tab with those tabs' own params
- "Explain with AI" and "Copy summary" hand the previewed errors to the
  Assistant with the same prompt as selected logs on the Logs page
- Removes the custom error grouping and its SQL

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:59:53 +10:00

213 lines
7.8 KiB
TypeScript

import { QueryClient } from '@tanstack/react-query'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { platformComponents } from 'api-types'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EdgeFunctionRecentErrors } from './EdgeFunctionRecentErrors'
import { miscKeys } from '@/data/misc/keys'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, type APIErrorBody } from '@/tests/lib/msw'
import { routerMock } from '@/tests/lib/route-mock'
type AnalyticsResponse = platformComponents['schemas']['AnalyticsResponse_Output']
const { mockIsUnifiedLogsEnabled } = vi.hoisted(() => ({ mockIsUnifiedLogsEnabled: vi.fn() }))
vi.mock('@/components/interfaces/App/FeaturePreview/FeaturePreviewContext', () => ({
useUnifiedLogsPreview: () => ({ isEnabled: mockIsUnifiedLogsEnabled() }),
}))
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return {
...actual,
useParams: () => ({ ref: 'default' }),
useFlag: (name: string) => name === 'otelUnifiedLogs',
}
})
const OTEL_ENDPOINT = '/platform/projects/:ref/analytics/endpoints/logs.all.otel'
const errorRow = (index: number, logType = 'edge function runtime') => ({
id: `log-${index}`,
timestamp: new Date(Date.now() - index * 60_000).toISOString(),
log_type: logType,
status: null,
level: 'error',
pathname: null,
event_message: `TypeError: failure ${index}`,
method: null,
log_count: null,
logs: null,
auth_user: null,
metadata: { level: 'error' },
})
const mockLogs = ({
rows,
totalErrors,
invocationCount,
}: {
rows: ReturnType<typeof errorRow>[]
totalErrors: number
invocationCount: number
}) => {
const rowListSql: string[] = []
addAPIMock({
method: 'post',
path: OTEL_ENDPOINT,
response: async ({ request }) => {
const { sql } = (await request.json()) as { sql: string }
if (sql.includes('-- unified logs: row list')) {
rowListSql.push(sql)
return HttpResponse.json<AnalyticsResponse>({ result: rows })
}
if (sql.includes('-- unified logs: sidebar facet counts')) {
return HttpResponse.json<AnalyticsResponse>({
result: [{ facet: 'total', value: 'all', count: totalErrors }],
})
}
return HttpResponse.json<APIErrorBody>({ message: 'Unexpected SQL' }, { status: 400 })
},
})
addAPIMock({
method: 'get',
path: OTEL_ENDPOINT,
response: () =>
HttpResponse.json<AnalyticsResponse>({ result: [{ count: String(invocationCount) }] }),
})
return { rowListSql }
}
const renderErrors = () => {
// The AI actions read the selected project and feature overrides
addAPIMock({
method: 'get',
path: '/platform/projects/:ref',
response: {
cloud_provider: 'AWS',
db_host: 'db.default.supabase.co',
high_availability: false,
id: 1,
inserted_at: '2026-01-01T00:00:00Z',
integration_source: null,
is_branch_enabled: false,
is_physical_backups_enabled: false,
name: 'Test project',
organization_id: 1,
ref: 'default',
region: 'us-east-1',
restUrl: 'https://default.supabase.co',
status: 'ACTIVE_HEALTHY',
subscription_id: 'subscription-1',
updated_at: '2026-01-01T00:00:00Z',
connectionString: 'postgresql://postgres:password@localhost:5432/postgres',
},
})
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
queryClient.setQueryData(miscKeys.enabledFeaturesOverride(), { disabled_features: [] })
return customRender(
<EdgeFunctionRecentErrors
functionId="fn-1"
functionSlug="hello-world"
projectRef="default"
updatedAt={new Date(Date.now() - 60 * 60 * 1000).toISOString()}
/>,
{ queryClient }
)
}
describe('EdgeFunctionRecentErrors', () => {
beforeEach(() => {
routerMock.setCurrentUrl('/project/default/functions/hello-world')
mockIsUnifiedLogsEnabled.mockReturnValue(true)
})
it("previews the function's latest errors and links the rest to the Logs tab", async () => {
// Installs the clipboard stub the spy attaches to
const user = userEvent.setup()
const copy = vi.spyOn(navigator.clipboard, 'writeText')
const { rowListSql } = mockLogs({
rows: [0, 1, 2, 3, 4, 5].map((index) => errorRow(index)),
totalErrors: 8,
invocationCount: 20,
})
renderErrors()
expect(await screen.findByText('TypeError: failure 0')).toBeInTheDocument()
expect(screen.getByText('TypeError: failure 4')).toBeInTheDocument()
// A read-only preview: no multi-select checkboxes
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
expect(screen.queryByText('TypeError: failure 5')).not.toBeInTheDocument()
expect(await screen.findByRole('button', { name: '+3 more' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Explain with AI' })).toBeInTheDocument()
// The summary is the Assistant prompt: the previewed errors plus how many are left out
await user.click(screen.getByRole('button', { name: 'Copy summary' }))
const summary = copy.mock.calls[0][0]
expect(summary).toContain('I have 5 Supabase log entries from the **Edge Functions** service')
expect(summary).toContain('TypeError: failure 4')
expect(summary).not.toContain('TypeError: failure 5')
expect(summary).toContain('+3 more matching log entries not included.')
// Scoped to this function's error rows, like the Logs tab it links to
expect(rowListSql[0]).toContain(`log_attributes['function_id'] = 'fn-1'`)
expect(rowListSql[0]).toContain(`IN ('error')`)
})
it('opens the clicked error in the Logs tab with the same filters', async () => {
mockLogs({ rows: [0, 1].map((index) => errorRow(index)), totalErrors: 2, invocationCount: 20 })
renderErrors()
await userEvent.click(await screen.findByText('TypeError: failure 1'))
const url = new URL(routerMock.asPath, 'http://localhost')
expect(url.pathname).toBe('/project/default/functions/hello-world/logs')
expect(url.searchParams.get('filter')).toBe('level:eq:error')
expect(url.searchParams.get('id')).toBe('log-1')
expect(url.searchParams.get('date')).toMatch(/^\d+-\d+$/)
expect(screen.queryByRole('button', { name: /more$/ })).not.toBeInTheDocument()
})
it('opens errors in the pre-unified Invocations or Logs tab when unified logs are off', async () => {
mockIsUnifiedLogsEnabled.mockReturnValue(false)
mockLogs({
rows: [errorRow(0), errorRow(1, 'edge function')],
totalErrors: 2,
invocationCount: 20,
})
renderErrors()
await userEvent.click(await screen.findByText('TypeError: failure 0'))
const runtimeUrl = new URL(routerMock.asPath, 'http://localhost')
expect(runtimeUrl.pathname).toBe('/project/default/functions/hello-world/logs')
expect(runtimeUrl.searchParams.get('log')).toBe('log-0')
expect(runtimeUrl.searchParams.get('its')).toBeTruthy()
expect(runtimeUrl.searchParams.get('ite')).toBeTruthy()
await userEvent.click(screen.getByText('TypeError: failure 1'))
const invocationUrl = new URL(routerMock.asPath, 'http://localhost')
expect(invocationUrl.pathname).toBe('/project/default/functions/hello-world/invocations')
expect(invocationUrl.searchParams.get('log')).toBe('log-1')
})
it('summarizes invocations since the last deploy when there are no errors', async () => {
mockLogs({ rows: [], totalErrors: 0, invocationCount: 3 })
renderErrors()
expect(await screen.findByText('3 invocations')).toBeInTheDocument()
expect(screen.getByText(/since last deploy and no errors/)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Copy summary' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Explain with AI' })).not.toBeInTheDocument()
})
})