Files
supabase/apps/studio/components/interfaces/Explorer/NotebookPreview/NotebookPreview.utils.test.ts
T
Charis 071a07118a feat(studio): add NotebookPreview diff component (#49112)
## Summary

Stacked on #49109 (PR 1 — `deriveNotebookDiff`). This is PR 3 of the
notebook approval-preview stack: a pure presentational component that
renders the cell-level diff for a proposed notebook create/update, for
use in the assistant approval UI (wired in a later PR).

- `NotebookPreview` — header summary (`"6 cells"` for create, `"+2 −1 ~1
↕1"` for update) + entry list + "Show N more cells" for long notebooks.
- `NotebookPreviewCell` — dispatches per entry tag:
`unchanged`/`removed`/`moved` collapse to a muted badge row; `added`
renders source via `CodeBlock` (with a max-height/expand toggle);
`replaced` renders a `DiffEditor` diff, plus a before → after metadata
line when only `database_identifier`/`time_range` changed (SQL/text
identical).
- `NotebookPreview.utils` — pure helpers (labels, source/metadata
extraction, language mapping, summary formatting), unit tested.
- **Safety property**: cell content only ever renders through
`CodeBlock`/`DiffEditor` (literal source), never through a markdown
renderer — agent-authored text can't trigger image loads or link
navigation before the user approves. Covered by an adversarial test
(`![x](evil)`, `[y](evil)`, `<img onerror>` → zero
`img`/`[href]`/`[src]` DOM nodes).
- Adds `'markdown'` as a supported `CodeBlock` language (small, additive
change to `packages/ui-patterns`).

Towards FE-4143

## Test plan

- [x] `pnpm --filter studio test` — NotebookPreview suite (21 tests)
passes
- [x] `pnpm --filter studio exec eslint
components/interfaces/Explorer/NotebookPreview` — clean
- [x] `pnpm --filter studio exec tsc --noEmit` — no new errors
- [x] `pnpm exec prettier --check` — clean

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

## Summary by CodeRabbit

* **New Features**
  * Added notebook previews showing create and update summaries.
* Displayed added, removed, moved, unchanged, and replaced cells with
metadata and source diffs.
* Added expandable previews with truncation and a “Show more cells”
option.
  * Added Markdown syntax highlighting to code blocks.

* **Bug Fixes**
* Safely render adversarial agent-authored Markdown as literal content.

* **Tests**
* Added comprehensive coverage for notebook previews, summaries,
metadata, formatting, and truncation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-18 11:44:23 -04:00

183 lines
6.1 KiB
TypeScript
Raw 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,
getCellMetadataLine,
getEntryKey,
summarizeNotebookDiff,
} from './NotebookPreview.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,
text,
})
const agentMarkdownCell = (text = 'hello'): AgentCell => ({ _tag: 'markdown_cell', text })
const wireDatabaseCell = (id: string, title?: string, database_identifier?: string): CellWire => ({
_tag: 'database_cell',
id,
title,
sql: 'select 1',
row_limit: 100,
database_identifier,
})
const wireLogCell = (id: string): CellWire => ({
_tag: 'log_cell',
id,
sql: 'select 1',
time_range: { _tag: 'relative_time_range', unit: 'day', amount: 7 },
})
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('getCellMetadataLine', () => {
it('returns null for markdown cells', () => {
expect(getCellMetadataLine(wireMarkdownCell('cell-1'))).toBeNull()
})
it('returns null for a database cell with no database_identifier', () => {
expect(getCellMetadataLine(wireDatabaseCell('cell-1'))).toBeNull()
})
it('labels a database cell with a database_identifier', () => {
expect(getCellMetadataLine(wireDatabaseCell('cell-1', 'Signups', 'replica-3'))).toBe(
'Database: replica-3'
)
})
it('labels a log cell with its formatted time range', () => {
expect(getCellMetadataLine(wireLogCell('cell-1'))).toBe('Time range: Last 7 days')
})
})
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')
})
})