Files
supabase/apps/studio/components/ui/AIAssistantPanel/AssistantNotebookPreview.utils.test.ts
Charis b366496c8c [FE-4246] fix(studio): resolve notebook database labels (#49554)
## I have read the
[CONTRIBUTING.md](<https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md>)
file.

YES

## What kind of change does this PR introduce?

Bug fix for Studio notebook previews.

## What is the current behavior?

Notebook diffs display the raw database_identifier value. This exposes
opaque database IDs and does not communicate whether the cell targets
the primary database or a read replica.

Related issue:
[https://linear.app/supabase/issue/FE-4246](<https://linear.app/supabase/issue/FE-4246>)

## What is the new behavior?

Notebook database metadata is resolved independently from existing
selector formatting:

* an omitted database identifier is Primary
* an identifier matching the project ref is Primary
* other identifiers show a loading state while databases load
* a loaded non-primary match is Replica
* an unmatched identifier is Unknown
* database lookup failures use a neutral unavailable state

Labels are compact: Database: Primary, Database: Replica, and Database:
Unknown.

The databases query is enabled only when a preview contains an explicit
non-primary identifier.

## Additional context

Validation:

* 39 focused notebook preview tests
* Studio typecheck
* targeted ESLint
* Prettier check
* git diff --check

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

## Summary by CodeRabbit

* **New Features**
* Notebook previews now identify database targets as primary, read
replica, or unknown.
  * Database metadata is resolved automatically when needed.
  * Loading states display a clear “Loading database…” indicator.
* Database metadata now handles unavailable, hidden, and error states
more clearly.
* Notebook entries reflect updated database information before and after
replacement.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-26 17:58:35 +08:00

379 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import dayjs from 'dayjs'
import { describe, expect, it } from 'vitest'
import {
formatNotebookDiffSummary,
formatTimeRange,
getCellLabel,
getCellMetadata,
getEntryKey,
getEntryMetadata,
notebookEntriesNeedDatabaseLookup,
resolveNotebookDatabaseTarget,
summarizeNotebookDiff,
type NotebookDatabaseContext,
} from './AssistantNotebookPreview.utils'
import type { NotebookCellDiffEntry } from '@/data/content/notebooks/notebook-operations'
import type { AgentCell, CellWire } from '@/data/content/notebooks/notebook-schema'
import { isoDateTimeString } from '@/lib/iso-datetime'
const wireMarkdownCell = (id: string, text = 'hello'): CellWire => ({
_tag: 'markdown_cell',
_id: id,
text,
})
const agentMarkdownCell = (text = 'hello'): AgentCell => ({ _tag: 'markdown_cell', text })
const wireDatabaseCell = (id: string, title?: string, database_identifier?: string): CellWire => ({
_tag: 'database_cell',
_id: id,
title,
sql: 'select 1',
row_limit: 100,
database_identifier,
})
const wireLogCell = (id: string): CellWire => ({
_tag: 'log_cell',
_id: id,
sql: 'select 1',
time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 },
})
const agentDatabaseCell = (database_identifier?: string): AgentCell => ({
_tag: 'database_cell',
sql: 'select 1',
row_limit: 100,
database_identifier,
})
const successfulDatabaseContext = (
databases: Array<{ identifier: string; region: string }> = []
): NotebookDatabaseContext => ({
status: 'success',
projectRef: 'project-1',
databasesByIdentifier: new Map(databases.map((database) => [database.identifier, database])),
})
describe('getEntryKey', () => {
it('keys unchanged, removed, and moved entries off the cell id', () => {
expect(getEntryKey({ _tag: 'unchanged', cell: wireMarkdownCell('cell-1') })).toBe('cell-1')
expect(
getEntryKey({ _tag: 'removed', cell: wireMarkdownCell('cell-2'), operationIndex: 0 })
).toBe('cell-2')
expect(
getEntryKey({
_tag: 'moved',
cell: wireMarkdownCell('cell-3'),
fromIndex: 1,
operationIndex: 0,
})
).toBe('cell-3')
})
it('keys added and replaced entries off the operation index', () => {
expect(getEntryKey({ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 2 })).toBe(
'op-2'
)
expect(
getEntryKey({
_tag: 'replaced',
before: wireMarkdownCell('cell-4'),
after: agentMarkdownCell(),
operationIndex: 3,
})
).toBe('op-3')
})
})
describe('getCellLabel', () => {
it('labels markdown cells', () => {
expect(getCellLabel(wireMarkdownCell('cell-1'))).toBe('Markdown cell')
})
it('labels query cells with their title', () => {
expect(getCellLabel(wireDatabaseCell('cell-1', 'Signups'))).toBe('Query: Signups')
})
it('falls back to "Untitled query" when a query cell has no title', () => {
expect(getCellLabel(wireDatabaseCell('cell-1'))).toBe('Query: Untitled query')
})
})
describe('formatTimeRange', () => {
it('formats a relative range, pluralizing the unit', () => {
expect(formatTimeRange({ _tag: 'relative_time_range', unit: 'day', amount: 7 })).toBe(
'Last 7 days'
)
})
it('does not pluralize a relative range with amount 1', () => {
expect(formatTimeRange({ _tag: 'relative_time_range', unit: 'hour', amount: 1 })).toBe(
'Last 1 hour'
)
})
it('formats an absolute range as a start → end pair', () => {
const start = isoDateTimeString('2026-01-01T13:00:00.000Z')!
const end = isoDateTimeString('2026-01-02T09:30:00.000Z')!
const formatted = formatTimeRange({ _tag: 'absolute_time_range', start, end })
// Bounds are asserted via dayjs rather than a hardcoded string so this doesn't depend on
// the test runner's local timezone (formatTimeRange formats in local time).
const expectedBound = (value: string) => dayjs(value).format('MMM D, YYYY h:mm A')
expect(formatted).toBe(`${expectedBound(start)}${expectedBound(end)}`)
})
})
describe('resolveNotebookDatabaseTarget', () => {
it('resolves an omitted identifier as primary without waiting for databases', () => {
expect(
resolveNotebookDatabaseTarget(undefined, { status: 'loading', projectRef: 'project-1' })
).toEqual({ status: 'primary' })
})
it('resolves the project ref as primary without waiting for databases', () => {
expect(
resolveNotebookDatabaseTarget('project-1', {
status: 'loading',
projectRef: 'project-1',
})
).toEqual({ status: 'primary' })
})
it('still resolves the loaded database matching the project ref as primary', () => {
expect(
resolveNotebookDatabaseTarget(
'project-1',
successfulDatabaseContext([{ identifier: 'project-1', region: 'us-east-1' }])
)
).toEqual({ status: 'primary' })
})
it('keeps another identifier loading until databases resolve', () => {
expect(
resolveNotebookDatabaseTarget('replica-3', {
status: 'loading',
projectRef: 'project-1',
})
).toEqual({ status: 'loading' })
})
it('resolves a matching loaded database as a replica', () => {
expect(
resolveNotebookDatabaseTarget(
'replica-3',
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({ status: 'replica' })
})
it('does not call an identifier absent from the loaded database list a replica', () => {
expect(resolveNotebookDatabaseTarget('replica-3', successfulDatabaseContext())).toEqual({
status: 'unknown',
})
})
it('preserves database lookup errors', () => {
expect(
resolveNotebookDatabaseTarget('replica-3', {
status: 'error',
projectRef: 'project-1',
})
).toEqual({ status: 'error' })
})
})
describe('notebookEntriesNeedDatabaseLookup', () => {
it('skips lookup for implicit and explicit primary cells', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireDatabaseCell('cell-1') },
{ _tag: 'unchanged', cell: wireDatabaseCell('cell-2', undefined, 'project-1') },
]
expect(notebookEntriesNeedDatabaseLookup(entries, 'project-1')).toBe(false)
})
it('requires lookup when either side of a replacement has another identifier', () => {
const entries: NotebookCellDiffEntry[] = [
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1'),
after: agentDatabaseCell('replica-3'),
operationIndex: 0,
},
]
expect(notebookEntriesNeedDatabaseLookup(entries, 'project-1')).toBe(true)
})
})
describe('getCellMetadata', () => {
it('hides metadata for markdown cells', () => {
expect(getCellMetadata(wireMarkdownCell('cell-1'), successfulDatabaseContext())).toEqual({
status: 'hidden',
})
})
it('labels an implicit primary database', () => {
expect(getCellMetadata(wireDatabaseCell('cell-1'), successfulDatabaseContext())).toEqual({
status: 'ready',
text: 'Database: Primary',
})
})
it('labels a resolved replica descriptively', () => {
expect(
getCellMetadata(
wireDatabaseCell('cell-1', 'Signups', 'replica-3'),
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({
status: 'ready',
text: 'Database: Replica',
})
})
it('returns a loading state rather than guessing another identifier is a replica', () => {
expect(
getCellMetadata(wireDatabaseCell('cell-1', 'Signups', 'replica-3'), {
status: 'loading',
projectRef: 'project-1',
})
).toEqual({ status: 'loading' })
})
it('labels a missing loaded identifier as unknown', () => {
expect(
getCellMetadata(
wireDatabaseCell('cell-1', 'Signups', 'missing-database'),
successfulDatabaseContext()
)
).toEqual({ status: 'ready', text: 'Database: Unknown' })
})
it('labels a log cell with its formatted time range', () => {
expect(getCellMetadata(wireLogCell('cell-1'), successfulDatabaseContext())).toEqual({
status: 'ready',
text: 'Time range: Last 7 days',
})
})
})
describe('getEntryMetadata', () => {
it('uses the cell metadata for non-replaced entries', () => {
expect(
getEntryMetadata(
{
_tag: 'unchanged',
cell: wireDatabaseCell('cell-1', 'Signups', 'replica-3'),
},
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({
status: 'ready',
text: 'Database: Replica',
})
})
it('returns a before → after pair when a replacement changes only metadata', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1', 'Signups'),
after: agentDatabaseCell('replica-3'),
operationIndex: 0,
},
successfulDatabaseContext([{ identifier: 'replica-3', region: 'us-east-1' }])
)
).toEqual({
status: 'ready',
text: 'Database: Primary → Database: Replica',
})
})
it('returns a loading state when either side still needs database data', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1', 'Signups'),
after: agentDatabaseCell('replica-3'),
operationIndex: 0,
},
{ status: 'loading', projectRef: 'project-1' }
)
).toEqual({ status: 'loading' })
})
it('returns a single line when replacement metadata is unchanged', () => {
expect(
getEntryMetadata(
{
_tag: 'replaced',
before: wireDatabaseCell('cell-1', 'Signups'),
after: agentDatabaseCell(),
operationIndex: 0,
},
successfulDatabaseContext()
)
).toEqual({ status: 'ready', text: 'Database: Primary' })
})
})
describe('summarizeNotebookDiff / formatNotebookDiffSummary', () => {
it('formats create mode with pluralized cell count', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
{ _tag: 'unchanged', cell: wireMarkdownCell('b') },
]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'create'))).toBe('2 cells')
})
it('formats create mode singular for a single cell', () => {
const entries: NotebookCellDiffEntry[] = [{ _tag: 'unchanged', cell: wireMarkdownCell('a') }]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'create'))).toBe('1 cell')
})
it('formats update mode with a mix of categories, ignoring unchanged', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 0 },
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 1 },
{ _tag: 'removed', cell: wireMarkdownCell('d'), operationIndex: 2 },
{
_tag: 'replaced',
before: wireMarkdownCell('e'),
after: agentMarkdownCell(),
operationIndex: 3,
},
{ _tag: 'moved', cell: wireMarkdownCell('f'), fromIndex: 0, operationIndex: 4 },
]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('+2 1 ~1 ↕1')
})
it('omits zero categories from the update summary', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
{ _tag: 'added', cell: agentMarkdownCell(), operationIndex: 0 },
]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('+1')
})
it('falls back to "No changes" when every entry is unchanged', () => {
const entries: NotebookCellDiffEntry[] = [
{ _tag: 'unchanged', cell: wireMarkdownCell('a') },
{ _tag: 'unchanged', cell: wireMarkdownCell('b') },
]
expect(formatNotebookDiffSummary(summarizeNotebookDiff(entries, 'update'))).toBe('No changes')
})
})