test(sql-editor): e2e coverage + delete jsdom test + merge Results.utils tests (Steps 5-6) (#48217)

## Summary

Steps 5 and 6 of the SQL editor test refactor plan (the final two
steps).

**Step 5** — extends `e2e/studio/features/sql-editor.spec.ts` (real
browser, zero mocks) with cases that need the real Monaco editor / full
app render:
- destructive-query warning modal: confirm actually re-runs the forced
query (previously only `Cancel` was exercised)
- debug button opens the AI Assistant with the query error pre-filled

Deletes `apps/studio/tests/components/SQLEditor/SQLEditor.test.tsx` —
its logic-level cases are now covered mock-free by the Step 4 hook
tests, and its integration cases by e2e. Deleting rather than narrowing
is the honest consequence of "no mocking": every remaining assertion it
could make in jsdom requires a Monaco mock.

**Step 6** — merges
`apps/studio/tests/components/SQLEditor/Results.utils.test.ts`
(`formatClipboardValue`/`formatCellValue`) into the colocated
`apps/studio/components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts`
(`formatResults`/`convertResultsToMarkdown`/`convertResultsToJSON`/`getResultsHeaders`/`isLargeValue`/`convertResultsToCSV`)
— both tested disjoint exports of the same source file. Deletes the
`tests/` copy.

This is the last step in the plan.

## Test plan

- [x] `pnpm --filter studio typecheck` — no new errors in changed files
- [x] `npx prettier --check` on all changed files
- [x] Ran the new/changed e2e cases locally end-to-end against a live
local stack — both pass
- [x] `cd apps/studio && npx vitest run
components/interfaces/SQLEditor/UtilityPanel/Results.utils.test.ts` —
42/42 passing after the merge

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

* **Tests**
* Added end-to-end coverage for destructive SQL query warning modal flow
before forced execution.
* Added end-to-end coverage for the AI Assistant debug flow when SQL
execution fails.
* Expanded unit test coverage for SQL editor results formatting
utilities (clipboard and cell value formatting).
* Removed the prior SQLEditor unit test suite and the older
results-formatting unit tests.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-07-22 16:55:17 -04:00
1 parent e4f75bf74c
commit 0bef8e7d90
4 files changed
+122 -542

No files matched your search

@@ -4,12 +4,56 @@ import {
convertResultsToCSV,
convertResultsToJSON,
convertResultsToMarkdown,
formatCellValue,
formatClipboardValue,
formatResults,
getResultsHeaders,
isLargeValue,
} from './Results.utils'
describe('Results.utils', () => {
describe('formatClipboardValue', () => {
it('returns empty string for null', () => {
expect(formatClipboardValue(null)).toBe('')
})
it('stringifies objects', () => {
expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}')
})
it('stringifies arrays', () => {
expect(formatClipboardValue([1, 2])).toBe('[1,2]')
})
it('converts primitives to string', () => {
expect(formatClipboardValue('hello')).toBe('hello')
expect(formatClipboardValue(42)).toBe('42')
expect(formatClipboardValue(false)).toBe('false')
})
})
describe('formatCellValue', () => {
it('returns NULL for null', () => {
expect(formatCellValue(null)).toBe('NULL')
})
it('returns strings as-is', () => {
expect(formatCellValue('hello')).toBe('hello')
})
it('stringifies objects', () => {
expect(formatCellValue({ a: 1 })).toBe('{"a":1}')
})
it('stringifies numbers', () => {
expect(formatCellValue(42)).toBe('42')
})
it('stringifies booleans', () => {
expect(formatCellValue(true)).toBe('true')
})
})
describe('formatResults', () => {
it('should stringify object values', () => {
const results = [{ id: 1, data: { nested: true } }]
@@ -1,48 +0,0 @@
import { describe, expect, test } from 'vitest'
import {
formatCellValue,
formatClipboardValue,
} from '@/components/interfaces/SQLEditor/UtilityPanel/Results.utils'
describe('formatClipboardValue', () => {
test('returns empty string for null', () => {
expect(formatClipboardValue(null)).toBe('')
})
test('stringifies objects', () => {
expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}')
})
test('stringifies arrays', () => {
expect(formatClipboardValue([1, 2])).toBe('[1,2]')
})
test('converts primitives to string', () => {
expect(formatClipboardValue('hello')).toBe('hello')
expect(formatClipboardValue(42)).toBe('42')
expect(formatClipboardValue(false)).toBe('false')
})
})
describe('formatCellValue', () => {
test('returns NULL for null', () => {
expect(formatCellValue(null)).toBe('NULL')
})
test('returns strings as-is', () => {
expect(formatCellValue('hello')).toBe('hello')
})
test('stringifies objects', () => {
expect(formatCellValue({ a: 1 })).toBe('{"a":1}')
})
test('stringifies numbers', () => {
expect(formatCellValue(42)).toBe('42')
})
test('stringifies booleans', () => {
expect(formatCellValue(true)).toBe('true')
})
})
@@ -1,494 +0,0 @@
import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { SQLEditor } from '@/components/interfaces/SQLEditor/SQLEditor'
import { DiffType } from '@/components/interfaces/SQLEditor/SQLEditor.types'
import { sqlEditorDiffRequestState } from '@/state/sql-editor/sql-editor-diff-request'
import { sqlEditorSessionState } from '@/state/sql-editor/sql-editor-session-state'
import { sqlEditorState } from '@/state/sql-editor/sql-editor-state'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
/**
* Characterization tests for the (pre-decomposition) `SQLEditor` monolith.
*
* These tests are not intended to be a model of best practices; they
* over-mock and assert on implementation details. They are temporary to
* capture regressions while the editor is being decomposed into smaller, more
* testable components.
*/
const SNIPPET_ID = 'test-snippet-id'
// A single mutable fake-editor state so tests can drive selection / value /
// decoration ids, shared into the hoisted module mocks below.
const mocks = vi.hoisted(() => {
const state = {
value: 'select 1;',
selection: null as null | { startLineNumber: number },
selectionValue: undefined as string | undefined,
decorations: ['decoration-1'] as string[],
scrollHandler: null as null | ((e: { scrollTop: number }) => void),
diffModifiedValue: 'select 2;',
}
const editor = {
getValue: () => state.value,
getSelection: () => state.selection,
getModel: () => ({
getValueInRange: (_selection: unknown) => state.selectionValue,
getFullModelRange: () => ({ __fullRange: true }),
}),
executeEdits: vi.fn(),
deltaDecorations: vi.fn(
(_oldDecorations: string[], _newDecorations: any[]) => state.decorations
),
revealLineInCenter: vi.fn(),
onDidScrollChange: vi.fn((cb: (e: { scrollTop: number }) => void) => {
state.scrollHandler = cb
}),
setScrollTop: vi.fn(),
focus: vi.fn(),
}
class FakeRange {
startLineNumber: number
startColumn: number
endLineNumber: number
endColumn: number
constructor(a: number, b: number, c: number, d: number) {
this.startLineNumber = a
this.startColumn = b
this.endLineNumber = c
this.endColumn = d
}
}
const monaco = { Range: FakeRange }
const diffEditor = {
getModel: () => ({
original: { setValue: vi.fn(), getValue: () => '' },
modified: { setValue: vi.fn(), getValue: () => state.diffModifiedValue },
}),
getModifiedEditor: () => ({ revealLineInCenter: vi.fn() }),
}
return { state, editor, monaco, diffEditor }
})
// --- Editor fakes -----------------------------------------------------------
vi.mock('@/components/interfaces/SQLEditor/MonacoEditor', async () => {
const { useEffect } = await vi.importActual<typeof import('react')>('react')
return {
MonacoEditor: (props: any) => {
useEffect(() => {
props.editorRef.current = mocks.editor
props.monacoRef.current = mocks.monaco
props.onMount?.(mocks.editor)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div data-testid="monaco-editor">
<div data-testid="monaco-placeholder">{props.placeholder}</div>
<button tabIndex={0} data-testid="editor-run" onClick={() => props.executeQuery()}>
run
</button>
<button
tabIndex={0}
data-testid="editor-prompt"
onClick={() =>
props.onPrompt?.({
selection: '',
beforeSelection: '',
afterSelection: '',
startLineNumber: 1,
endLineNumber: 1,
})
}
>
prompt
</button>
</div>
)
},
}
})
vi.mock('@/components/ui/DiffEditor', async () => {
const { useEffect } = await vi.importActual<typeof import('react')>('react')
return {
DiffEditor: (props: any) => {
useEffect(() => {
props.onMount?.(mocks.diffEditor)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <div data-testid="diff-editor" />
},
}
})
vi.mock('@/components/ui/AIEditor/ResizableAIWidget', () => ({
default: (props: any) => (
<div data-testid={props.id}>
<button tabIndex={0} data-testid={`${props.id}-accept`} onClick={props.onAccept}>
accept
</button>
<button tabIndex={0} data-testid={`${props.id}-reject`} onClick={props.onReject}>
reject
</button>
<button tabIndex={0} data-testid={`${props.id}-cancel`} onClick={props.onCancel}>
cancel
</button>
</div>
),
}))
// --- Child panel stubs (not under test) -------------------------------------
vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityActions', () => ({
UtilityActions: (props: any) => (
<div data-testid="utility-actions">
<button
tabIndex={0}
data-testid="run-button"
disabled={props.isDisabled}
onClick={() => props.executeQuery()}
>
Run
</button>
<button tabIndex={0} data-testid="prettify-button" onClick={() => props.prettifyQuery()}>
Prettify
</button>
<span data-testid="is-executing">{String(props.isExecuting)}</span>
</div>
),
}))
vi.mock('@/components/interfaces/SQLEditor/UtilityPanel/UtilityPanel', () => ({
UtilityPanel: (props: any) => (
<div data-testid="utility-panel">
<span data-testid="active-tab">{props.activeTab}</span>
<button tabIndex={0} data-testid="debug-button" onClick={() => props.onDebug()}>
Debug
</button>
</div>
),
}))
vi.mock('@/components/interfaces/SQLEditor/RunQueryWarningModal', () => ({
RunQueryWarningModal: (props: any) =>
props.visible ? (
<div data-testid="warning-modal">
<button tabIndex={0} data-testid="warn-confirm" onClick={props.onConfirm}>
Confirm
</button>
<button tabIndex={0} data-testid="warn-confirm-rls" onClick={props.onConfirmWithRLS}>
Confirm RLS
</button>
<button tabIndex={0} data-testid="warn-cancel" onClick={props.onCancel}>
Cancel
</button>
</div>
) : null,
}))
// --- Context / selection hook stubs (orthogonal infra, not under test) ------
vi.mock('@/components/interfaces/SQLEditor/useAddDefinitions', () => ({
useAddDefinitions: () => {},
}))
vi.mock('@/hooks/misc/useSelectedProject', () => ({
useSelectedProjectQuery: () => ({
data: {
id: 1,
ref: 'default',
connectionString: 'postgresql://postgres@localhost:5432/postgres',
},
}),
}))
vi.mock('@/hooks/misc/useSelectedOrganization', () => ({
useSelectedOrganizationQuery: () => ({ data: { slug: 'test-org' } }),
}))
vi.mock('@/hooks/misc/useOrgOptedIntoAi', () => ({
useOrgAiOptInLevel: () => ({
aiOptInLevel: 'disabled',
includeSchemaMetadata: false,
isHipaaProjectDisallowed: false,
}),
}))
vi.mock('@/data/read-replicas/replicas-query', () => ({
useReadReplicasQuery: () => ({
data: [
{
identifier: 'default',
connectionString: 'postgresql://postgres@localhost:5432/postgres',
},
],
isSuccess: true,
}),
}))
vi.mock('@/data/database-event-triggers/database-event-triggers-query', () => ({
useDatabaseEventTriggersQuery: () => ({ data: undefined }),
}))
// `common` is globally mocked in vitestSetup to `{ ref: 'default' }`. Re-mock it
// here to also provide a snippet id (so `id` is stable and points at our seeded
// snippet) and a stable `useFlag`.
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<typeof import('common')>()
return {
...actual,
useParams: () => ({ ref: 'default', id: SNIPPET_ID }),
useFlag: () => false,
}
})
// --- Helpers ----------------------------------------------------------------
function seedSnippet(sql: string, name = 'My query') {
;(sqlEditorState.snippets as any)[SNIPPET_ID] = {
projectRef: 'default',
splitSizes: [50, 50],
snippet: {
id: SNIPPET_ID,
name,
project_id: 1,
owner_id: 1,
content: { sql, unchecked_sql: sql, schema_version: '1.0', favorite: false },
},
}
}
function resetStores() {
for (const key of Object.keys(sqlEditorState.snippets)) {
delete (sqlEditorState.snippets as any)[key]
}
for (const key of Object.keys(sqlEditorSessionState.results)) {
delete (sqlEditorSessionState.results as any)[key]
}
sqlEditorDiffRequestState.pending = undefined
}
const NON_EXPLAIN_ROWS = [{ id: 1, name: 'row-1' }]
function mockQuerySuccess(rows: unknown[] = NON_EXPLAIN_ROWS) {
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: () => HttpResponse.json<any>(rows),
})
}
function mockQueryError(body: Record<string, unknown>) {
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: () => HttpResponse.json<any>(body, { status: 400 }),
})
}
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
return setTimeout(() => cb(0), 0) as unknown as number
})
resetStores()
seedSnippet('select 1;')
mocks.state.value = 'select 1;'
mocks.state.selection = null
mocks.state.selectionValue = undefined
mocks.state.decorations = ['decoration-1']
mocks.editor.executeEdits.mockClear()
mocks.editor.deltaDecorations.mockClear()
mocks.editor.revealLineInCenter.mockClear()
mocks.editor.focus.mockClear()
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
async function renderEditor() {
const utils = customRender(<SQLEditor />)
// The Monaco editor is loaded via next/dynamic; wait for the fake to mount.
await screen.findByTestId('monaco-editor')
return utils
}
describe('SQLEditor characterization', () => {
test('1a. successful run adds result and keeps Results tab active', async () => {
const addResult = vi.spyOn(sqlEditorSessionState, 'addResult')
mockQuerySuccess(NON_EXPLAIN_ROWS)
await renderEditor()
expect(screen.getByTestId('active-tab')).toHaveTextContent('results')
fireEvent.click(screen.getByTestId('editor-run'))
await waitFor(() => expect(addResult).toHaveBeenCalled())
// The snippet id and rows are the load-bearing part; autoLimit is derived
// from the (SELECT) query + configured row limit and is asserted loosely.
expect(addResult.mock.calls[0][0]).toBe(SNIPPET_ID)
expect(addResult.mock.calls[0][1]).toEqual(NON_EXPLAIN_ROWS)
expect(screen.getByTestId('active-tab')).toHaveTextContent('results')
})
test('2. run error with position highlights the computed line and reveals it', async () => {
const addResultError = vi.spyOn(sqlEditorSessionState, 'addResultError')
mockQueryError({
message: 'syntax error',
position: '8',
formattedError: 'ERROR: syntax error at or near "slect"\nLINE 3: slect 1;\n ^',
})
await renderEditor()
fireEvent.click(screen.getByTestId('editor-run'))
await waitFor(() => expect(mocks.editor.deltaDecorations).toHaveBeenCalled())
// With no selection, startLineNumber is 0 → highlighted line === parsed LINE (3).
const [oldDecorations, newDecorations] = mocks.editor.deltaDecorations.mock.calls[0]
expect(oldDecorations).toEqual([])
expect(newDecorations[0].range.startLineNumber).toBe(3)
expect(newDecorations[0].range.endLineNumber).toBe(3)
expect(mocks.editor.revealLineInCenter).toHaveBeenCalledWith(3)
await waitFor(() => expect(addResultError).toHaveBeenCalled())
})
test('2b. the next run clears the previously-set line highlights', async () => {
// First: produce a highlight.
mockQueryError({
message: 'syntax error',
position: '8',
formattedError: 'ERROR: syntax error\nLINE 2: foo\n',
})
await renderEditor()
fireEvent.click(screen.getByTestId('editor-run'))
await waitFor(() => expect(mocks.editor.deltaDecorations).toHaveBeenCalledTimes(1))
// Second run should clear the stored decorations before running again.
mockQuerySuccess(NON_EXPLAIN_ROWS)
fireEvent.click(screen.getByTestId('editor-run'))
await waitFor(() =>
expect(mocks.editor.deltaDecorations).toHaveBeenCalledWith(['decoration-1'], [])
)
})
test('3. running via the run button refocuses the editor', async () => {
mockQuerySuccess(NON_EXPLAIN_ROWS)
await renderEditor()
fireEvent.click(screen.getByTestId('run-button'))
await waitFor(() => expect(mocks.editor.focus).toHaveBeenCalled())
})
test('3b. run button is disabled and short-circuits while a diff is open', async () => {
// Queue a diff request against a non-empty editor so a diff opens on mount.
mocks.state.value = 'select 1;'
sqlEditorDiffRequestState.requestDiff('select 42;', DiffType.Modification)
await renderEditor()
// The queued request drains on mount and opens a diff, which disables Run.
// (Assert via the run button rather than the dynamically-imported diff editor.)
await waitFor(() => expect(screen.getByTestId('run-button')).toBeDisabled())
const addResult = vi.spyOn(sqlEditorSessionState, 'addResult')
// The run button is disabled while diffing; clicking should not execute.
fireEvent.click(screen.getByTestId('run-button'))
// Give any async work a chance to (not) happen.
await new Promise((r) => setTimeout(r, 20))
expect(addResult).not.toHaveBeenCalled()
})
test('4. a diff request queued before mount drains exactly once', async () => {
// Empty editor → drain copies the SQL in via executeEdits('apply-ai-message').
mocks.state.value = ''
sqlEditorDiffRequestState.requestDiff('select 100;', DiffType.Modification)
const { unmount } = await renderEditor()
await waitFor(() =>
expect(mocks.editor.executeEdits).toHaveBeenCalledWith('apply-ai-message', expect.any(Array))
)
expect(mocks.editor.executeEdits).toHaveBeenCalledTimes(1)
// Request was consumed (drained), so it cannot re-apply.
expect(sqlEditorDiffRequestState.pending).toBeUndefined()
// A fresh mount must NOT re-apply the already-consumed request.
unmount()
await renderEditor()
await new Promise((r) => setTimeout(r, 20))
expect(mocks.editor.executeEdits).toHaveBeenCalledTimes(1)
})
test('5. the ask-ai widget renders only while the prompt is open', async () => {
mocks.state.value = 'select 1;'
await renderEditor()
expect(screen.queryByTestId('ask-ai')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('editor-prompt'))
expect(await screen.findByTestId('ask-ai')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('ask-ai-cancel'))
await waitFor(() => expect(screen.queryByTestId('ask-ai')).not.toBeInTheDocument())
})
test('6. a destructive query opens the warning modal; confirm re-runs forced', async () => {
mocks.state.value = 'drop table foo;'
let forcedQuery = ''
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const body = (await request.json()) as { query: string }
forcedQuery = body.query
return HttpResponse.json<any>(NON_EXPLAIN_ROWS)
},
})
await renderEditor()
fireEvent.click(screen.getByTestId('editor-run'))
// Destructive query → confirmation modal, and no query is sent yet.
expect(await screen.findByTestId('warning-modal')).toBeInTheDocument()
expect(forcedQuery).toBe('')
// Confirming forces the (same) destructive query to actually run.
fireEvent.click(screen.getByTestId('warn-confirm'))
await waitFor(() => expect(forcedQuery).toMatch(/drop table foo/i))
})
test('6b. confirm-with-RLS re-runs with appended enable-RLS statements', async () => {
mocks.state.value = 'create table foo (id int);'
let capturedQuery = ''
addAPIMock({
method: 'post',
path: '/platform/pg-meta/:ref/query',
response: async ({ request }) => {
const body = (await request.json()) as { query: string }
capturedQuery = body.query
return HttpResponse.json<any>(NON_EXPLAIN_ROWS)
},
})
await renderEditor()
fireEvent.click(screen.getByTestId('editor-run'))
expect(await screen.findByTestId('warning-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('warn-confirm-rls'))
await waitFor(() => expect(capturedQuery).toMatch(/enable row level security/i))
})
})
+78
View File
@@ -406,6 +406,84 @@ test.describe('SQL Editor', () => {
}
})
test('destructive query warning modal: confirm re-runs the forced query', async ({ ref }) => {
await expect(page.getByText('Loading...')).not.toBeVisible()
await page.locator('.view-lines').click()
await page.keyboard.press('ControlOrMeta+KeyA')
await page.keyboard.type(`drop table pw_sql_editor_confirm_dne_e2e;`)
// Track whether the SQL editor dispatches this specific query to pg-meta
let queryDispatched = false
const listener = (request: any) => {
if (
request.url().includes('query?key=') &&
request.method() === 'POST' &&
request.postData()?.includes('drop table pw_sql_editor_confirm_dne_e2e')
) {
queryDispatched = true
}
}
page.on('request', listener)
await page.getByTestId('sql-run-button').click()
// Destructive query -> confirmation modal, and no query is sent yet.
await expect(page.getByRole('heading', { name: 'Potential issue detected' })).toBeVisible()
expect(queryDispatched).toBe(false)
// Confirming forces the (same) destructive query to actually run.
const sqlMutationPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
method: 'POST',
})
await page.getByRole('button', { name: 'Run query' }).click()
await sqlMutationPromise
expect(queryDispatched).toBe(true)
page.removeListener('request', listener)
// clear SQL snippet
if (!isCLI()) {
await deleteSqlSnippet(page, ref, newSqlSnippetName)
} else {
await page.reload()
}
})
test('debug button opens the AI Assistant with the query error pre-filled', async ({ ref }) => {
await expect(page.getByText('Loading...')).not.toBeVisible()
await page.locator('.view-lines').click()
await page.keyboard.press('ControlOrMeta+KeyA')
await page.keyboard.type(`select * from pw_sql_editor_missing_table_e2e;`)
const sqlMutationPromise = waitForApiResponse(page, 'pg-meta', ref, 'query?key=', {
method: 'POST',
})
await page.getByTestId('sql-run-button').click()
await sqlMutationPromise
const debugButton = page.getByRole('button', { name: 'Debug with Assistant' })
await expect(debugButton, 'Debug button should appear once the query errors').toBeVisible()
await debugButton.click()
await expect(
page.getByRole('button', { name: 'Close Assistant' }),
'AI Assistant panel should open'
).toBeVisible()
await expect(
page.getByRole('button', { name: 'Debug SQL snippet' }),
'Assistant chat should be pre-named for the debug flow'
).toBeVisible()
await page.getByRole('button', { name: 'Close Assistant' }).click()
// clear SQL snippet
if (!isCLI()) {
await deleteSqlSnippet(page, ref, newSqlSnippetName)
} else {
await page.reload()
}
})
test('should not show warning modal for safe alter database statement', async ({ ref }) => {
await expect(page.getByText('Loading...')).not.toBeVisible()
await page.locator('.view-lines').click()