You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2026/03/26 11:52:21 UTC

(superset) branch master updated: test(sqllab): add SQL Lab SDK API contract tests (#38860)

This is an automated email from the ASF dual-hosted git repository.

michaelsmolina pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 38d3a39c06a test(sqllab): add SQL Lab SDK API contract tests (#38860)
38d3a39c06a is described below

commit 38d3a39c06abb9fb39664c7e96a3c61eb9349ef2
Author: Joe Li <jo...@preset.io>
AuthorDate: Thu Mar 26 04:52:14 2026 -0700

    test(sqllab): add SQL Lab SDK API contract tests (#38860)
    
    Co-authored-by: Claude Opus 4.6 <no...@anthropic.com>
---
 superset-frontend/src/core/sqlLab/sqlLab.test.ts | 576 +++++++++++++++++++++++
 1 file changed, 576 insertions(+)

diff --git a/superset-frontend/src/core/sqlLab/sqlLab.test.ts b/superset-frontend/src/core/sqlLab/sqlLab.test.ts
new file mode 100644
index 00000000000..e5e7219805e
--- /dev/null
+++ b/superset-frontend/src/core/sqlLab/sqlLab.test.ts
@@ -0,0 +1,576 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import {
+  configureStore,
+  createListenerMiddleware,
+} from '@reduxjs/toolkit';
+import type { QueryEditor } from 'src/SqlLab/types';
+import sqlLabReducer from 'src/SqlLab/reducers/sqlLab';
+import {
+  START_QUERY,
+  QUERY_SUCCESS,
+  STOP_QUERY,
+  QUERY_FAILED,
+  QUERY_EDITOR_SETDB,
+  QUERY_EDITOR_SET_SCHEMA,
+  QUERY_EDITOR_SET_TITLE,
+  SET_ACTIVE_SOUTHPANE_TAB,
+  REMOVE_QUERY_EDITOR,
+  SET_ACTIVE_QUERY_EDITOR,
+  ADD_QUERY_EDITOR,
+} from 'src/SqlLab/actions/sqlLab';
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+
+const EDITOR_ID = 'editor-1';
+const IMMUTABLE_ID = 'immutable-1';
+
+// ---------------------------------------------------------------------------
+// Test infrastructure — real reducer + real listener middleware
+// ---------------------------------------------------------------------------
+
+const mockListenerMiddleware = createListenerMiddleware();
+
+function makeInitialSqlLabState() {
+  return {
+    queryEditors: [
+      {
+        id: EDITOR_ID,
+        immutableId: IMMUTABLE_ID,
+        name: 'Untitled Query 1',
+        dbId: 1,
+        catalog: null,
+        schema: 'public',
+        sql: 'SELECT 1',
+        autorun: false,
+        remoteId: null,
+        loaded: true,
+        inLocalStorage: true,
+      } as unknown as QueryEditor,
+    ],
+    tabHistory: [EDITOR_ID],
+    unsavedQueryEditor: {} as Record<string, unknown>,
+    queries: {} as Record<string, unknown>,
+    tables: [] as unknown[],
+    databases: {
+      1: { id: 1, database_name: 'test_db', allow_run_async: false },
+    } as Record<string, unknown>,
+    activeSouthPaneTab: 'Results' as string | number,
+    queriesLastUpdate: 0,
+    errorMessage: null as string | null,
+    alerts: [] as unknown[],
+    dbConnect: false,
+    offline: false,
+    editorTabLastUpdatedAt: 0,
+    lastUpdatedActiveTab: EDITOR_ID,
+    destroyedQueryEditors: {} as Record<string, number>,
+  };
+}
+
+function createMockStore() {
+  return configureStore({
+    reducer: { sqlLab: sqlLabReducer },
+    middleware: getDefault =>
+      getDefault({ serializableCheck: false, immutableCheck: false }).prepend(
+        mockListenerMiddleware.middleware,
+      ),
+    preloadedState: { sqlLab: makeInitialSqlLabState() as any },
+  });
+}
+
+let mockStore = createMockStore();
+
+// ---------------------------------------------------------------------------
+// Mocks — must use "mock" prefix for jest.mock() scoping rules
+// ---------------------------------------------------------------------------
+
+jest.mock('nanoid', () => {
+  let counter = 0;
+  return {
+    nanoid: jest.fn(() => {
+      counter += 1;
+      return `mock-nanoid-${counter}`;
+    }),
+  };
+});
+
+jest.mock('src/views/store', () => ({
+  get store() {
+    return mockStore;
+  },
+  get listenerMiddleware() {
+    return mockListenerMiddleware;
+  },
+  setupStore: jest.fn(),
+}));
+
+// Module under test — imported after mocks
+// eslint-disable-next-line import/first
+import { sqlLab } from '.';
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function resetStore() {
+  mockStore = createMockStore();
+}
+
+function makeQueryPayload(overrides: Record<string, unknown> = {}) {
+  return {
+    id: 'q-1',
+    sql: 'SELECT 1',
+    sqlEditorId: EDITOR_ID,
+    sqlEditorImmutableId: IMMUTABLE_ID,
+    tab: 'Untitled Query 1',
+    dbId: 1,
+    catalog: null,
+    schema: 'public',
+    startDttm: 1000,
+    runAsync: false,
+    ...overrides,
+  };
+}
+
+function makeSecondEditor() {
+  return {
+    id: 'editor-2',
+    immutableId: 'immutable-2',
+    name: 'Query 2',
+    dbId: 2,
+    catalog: null,
+    schema: 'test_schema',
+    sql: 'SELECT 2',
+    autorun: false,
+    remoteId: null,
+    loaded: true,
+    inLocalStorage: true,
+  } as unknown as QueryEditor;
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle
+// ---------------------------------------------------------------------------
+
+afterEach(() => {
+  mockListenerMiddleware.clearListeners();
+  resetStore();
+});
+
+// ---------------------------------------------------------------------------
+// Event Listeners (11 tests) + Predicate scoping (2 tests)
+// ---------------------------------------------------------------------------
+
+test('onDidQueryRun fires with QueryContext on START_QUERY', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidQueryRun(listener);
+
+  const query = makeQueryPayload();
+  mockStore.dispatch({ type: START_QUERY, query });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  const ctx = listener.mock.calls[0][0];
+  expect(ctx.clientId).toBe('q-1');
+  expect(ctx.tab.id).toBe(EDITOR_ID);
+  expect(ctx.tab.databaseId).toBe(1);
+  expect(ctx.runAsync).toBe(false);
+
+  disposable.dispose();
+});
+
+test('onDidQuerySuccess fires with QueryResultContext on QUERY_SUCCESS', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidQuerySuccess(listener);
+
+  const query = makeQueryPayload({ state: 'running' });
+  // Add query to state first (reducer requires it for alterInObject)
+  mockStore.dispatch({ type: START_QUERY, query });
+  listener.mockClear();
+
+  const results = {
+    status: 'success',
+    columns: [{ name: 'id', type: 'INT', column_name: 'id' }],
+    data: [{ id: 1 }],
+    query_id: 42,
+    query: {
+      endDttm: 2000,
+      executedSql: 'SELECT 1',
+      tempTable: null,
+      limit: 100,
+      limitingFactor: 'QUERY',
+      rows: 1,
+    },
+  };
+
+  mockStore.dispatch({ type: QUERY_SUCCESS, query, results });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  const ctx = listener.mock.calls[0][0];
+  expect(ctx.remoteId).toBe(42);
+  expect(ctx.executedSql).toBe('SELECT 1');
+  expect(ctx.result.columns).toHaveLength(1);
+  expect(ctx.result.data).toEqual([{ id: 1 }]);
+  expect(ctx.endDttm).toBe(2000);
+
+  disposable.dispose();
+});
+
+test('onDidQueryStop fires with QueryContext on STOP_QUERY', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidQueryStop(listener);
+
+  const query = makeQueryPayload();
+  mockStore.dispatch({ type: STOP_QUERY, query });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  const ctx = listener.mock.calls[0][0];
+  expect(ctx.clientId).toBe('q-1');
+  expect(ctx.tab.id).toBe(EDITOR_ID);
+
+  disposable.dispose();
+});
+
+test('onDidQueryFail fires with QueryErrorResultContext on QUERY_FAILED', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidQueryFail(listener);
+
+  const query = makeQueryPayload({ state: 'running' });
+  // Add query to state first
+  mockStore.dispatch({ type: START_QUERY, query });
+  listener.mockClear();
+
+  const errors = [
+    {
+      message: 'table not found',
+      error_type: 'GENERIC_DB_ENGINE_ERROR',
+      level: 'error',
+      extra: { engine_name: 'PostgreSQL' },
+    },
+  ];
+
+  mockStore.dispatch({
+    type: QUERY_FAILED,
+    query,
+    msg: 'table not found',
+    errors,
+  });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  const ctx = listener.mock.calls[0][0];
+  expect(ctx.errorMessage).toBe('table not found');
+  expect(ctx.errors).toHaveLength(1);
+
+  disposable.dispose();
+});
+
+test('onDidChangeEditorDatabase fires with dbId on QUERY_EDITOR_SETDB', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidChangeEditorDatabase(listener);
+
+  const queryEditor = { id: EDITOR_ID, immutableId: IMMUTABLE_ID };
+  mockStore.dispatch({ type: QUERY_EDITOR_SETDB, queryEditor, dbId: 5 });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  expect(listener).toHaveBeenCalledWith(5);
+
+  disposable.dispose();
+});
+
+test('onDidChangeEditorSchema fires with schema on QUERY_EDITOR_SET_SCHEMA', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidChangeEditorSchema(listener);
+
+  const queryEditor = { id: EDITOR_ID, immutableId: IMMUTABLE_ID };
+  mockStore.dispatch({
+    type: QUERY_EDITOR_SET_SCHEMA,
+    queryEditor,
+    schema: 'information_schema',
+  });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  expect(listener).toHaveBeenCalledWith('information_schema');
+
+  disposable.dispose();
+});
+
+test('onDidChangeActivePanel fires with Panel on SET_ACTIVE_SOUTHPANE_TAB', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidChangeActivePanel(listener);
+
+  mockStore.dispatch({ type: SET_ACTIVE_SOUTHPANE_TAB, tabId: 'History' });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  expect(listener.mock.calls[0][0].id).toBe('History');
+
+  disposable.dispose();
+});
+
+test('onDidChangeTabTitle fires with title on QUERY_EDITOR_SET_TITLE', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidChangeTabTitle(listener);
+
+  const queryEditor = { id: EDITOR_ID, immutableId: IMMUTABLE_ID };
+  mockStore.dispatch({
+    type: QUERY_EDITOR_SET_TITLE,
+    queryEditor,
+    name: 'My Query',
+  });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  expect(listener).toHaveBeenCalledWith('My Query');
+
+  disposable.dispose();
+});
+
+test('onDidCloseTab fires with Tab on REMOVE_QUERY_EDITOR', async () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidCloseTab(listener);
+
+  const queryEditor = {
+    id: EDITOR_ID,
+    immutableId: IMMUTABLE_ID,
+    name: 'Untitled Query 1',
+    dbId: 1,
+    catalog: null,
+    schema: 'public',
+  } as QueryEditor;
+
+  mockStore.dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  const tab = listener.mock.calls[0][0];
+  expect(tab.id).toBe(EDITOR_ID);
+  expect(tab.title).toBe('Untitled Query 1');
+  expect(tab.databaseId).toBe(1);
+
+  // Closed-tab contract: getEditor() rejects immediately instead of waiting forever
+  await expect(tab.getEditor()).rejects.toThrow(/closed/i);
+
+  disposable.dispose();
+});
+
+test('onDidChangeActiveTab fires with Tab on SET_ACTIVE_QUERY_EDITOR', () => {
+  // Add a second editor so switching back is a real change
+  mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: makeSecondEditor() });
+
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidChangeActiveTab(listener);
+
+  mockStore.dispatch({
+    type: SET_ACTIVE_QUERY_EDITOR,
+    queryEditor: { id: EDITOR_ID },
+  });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  const tab = listener.mock.calls[0][0];
+  expect(tab.id).toBe(EDITOR_ID);
+  expect(tab.title).toBe('Untitled Query 1');
+  expect(tab.databaseId).toBe(1);
+
+  disposable.dispose();
+});
+
+test('onDidCreateTab fires with Tab on ADD_QUERY_EDITOR', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidCreateTab(listener);
+
+  const newEditor = {
+    id: 'new-tab',
+    immutableId: 'new-immutable',
+    name: 'New Query',
+    dbId: 1,
+    catalog: null,
+    schema: 'public',
+  } as QueryEditor;
+
+  mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: newEditor });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  const tab = listener.mock.calls[0][0];
+  expect(tab.id).toBe('new-tab');
+  expect(tab.title).toBe('New Query');
+  expect(tab.databaseId).toBe(1);
+
+  disposable.dispose();
+});
+
+// ---------------------------------------------------------------------------
+// Predicate scoping — editor-scoped listeners ignore other editors' events
+// ---------------------------------------------------------------------------
+
+test('editor-scoped listener does not fire for a different editor', () => {
+  // Add a second editor (ADD_QUERY_EDITOR makes it active)
+  mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: makeSecondEditor() });
+
+  // Switch back to editor-1 so the predicate captures immutable-1
+  mockStore.dispatch({
+    type: SET_ACTIVE_QUERY_EDITOR,
+    queryEditor: { id: EDITOR_ID },
+  });
+
+  // Register listener while editor-1 is active (predicate captures immutable-1)
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidQueryRun(listener);
+
+  // Dispatch START_QUERY for editor-2 — should be filtered out by predicate
+  const query = makeQueryPayload({
+    sqlEditorId: 'editor-2',
+    sqlEditorImmutableId: 'immutable-2',
+  });
+  mockStore.dispatch({ type: START_QUERY, query });
+
+  expect(listener).not.toHaveBeenCalled();
+
+  disposable.dispose();
+});
+
+test('editor-scoped predicate filters tab events via queryEditor lookup', () => {
+  // Add a second editor and switch back to editor-1
+  mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: makeSecondEditor() });
+  mockStore.dispatch({
+    type: SET_ACTIVE_QUERY_EDITOR,
+    queryEditor: { id: EDITOR_ID },
+  });
+
+  // Register listener while editor-1 is active (predicate captures immutable-1)
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidChangeEditorDatabase(listener);
+
+  // Dispatch QUERY_EDITOR_SETDB for editor-2 — predicate uses findQueryEditor lookup (Path B)
+  mockStore.dispatch({
+    type: QUERY_EDITOR_SETDB,
+    queryEditor: { id: 'editor-2', immutableId: 'immutable-2' },
+    dbId: 99,
+  });
+
+  expect(listener).not.toHaveBeenCalled();
+
+  disposable.dispose();
+});
+
+test('globalPredicate listener fires for a non-active tab', () => {
+  // Add editor-2 and switch to it
+  mockStore.dispatch({ type: ADD_QUERY_EDITOR, queryEditor: makeSecondEditor() });
+  mockStore.dispatch({
+    type: SET_ACTIVE_QUERY_EDITOR,
+    queryEditor: { id: 'editor-2' },
+  });
+
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidCloseTab(listener);
+
+  // Close editor-1 while editor-2 is active — globalPredicate should still fire
+  const queryEditor = {
+    id: EDITOR_ID,
+    immutableId: IMMUTABLE_ID,
+    name: 'Untitled Query 1',
+    dbId: 1,
+    catalog: null,
+    schema: 'public',
+  } as QueryEditor;
+  mockStore.dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor });
+
+  expect(listener).toHaveBeenCalledTimes(1);
+  expect(listener.mock.calls[0][0].id).toBe(EDITOR_ID);
+
+  disposable.dispose();
+});
+
+// ---------------------------------------------------------------------------
+// Disposable pattern
+// ---------------------------------------------------------------------------
+
+test('dispose() stops the listener from receiving events', () => {
+  const listener = jest.fn();
+  const disposable = sqlLab.onDidChangeActivePanel(listener);
+
+  // Dispatch a real change (initial state is 'Results', switch to 'History')
+  mockStore.dispatch({ type: SET_ACTIVE_SOUTHPANE_TAB, tabId: 'History' });
+  expect(listener).toHaveBeenCalledTimes(1);
+
+  disposable.dispose();
+
+  mockStore.dispatch({ type: SET_ACTIVE_SOUTHPANE_TAB, tabId: 'Query' });
+  expect(listener).toHaveBeenCalledTimes(1);
+});
+
+// ---------------------------------------------------------------------------
+// Imperative Functions (7 tests)
+// ---------------------------------------------------------------------------
+
+test('getCurrentTab returns the active tab with correct properties', () => {
+  const tab = sqlLab.getCurrentTab();
+
+  expect(tab).toBeDefined();
+  expect(tab!.id).toBe(EDITOR_ID);
+  expect(tab!.title).toBe('Untitled Query 1');
+  expect(tab!.databaseId).toBe(1);
+  expect(tab!.schema).toBe('public');
+});
+
+test('getActivePanel returns the active south pane tab', () => {
+  const panel = sqlLab.getActivePanel();
+  expect(panel.id).toBe('Results');
+});
+
+test('getTabs returns all open tabs', () => {
+  const tabs = sqlLab.getTabs();
+
+  expect(tabs).toHaveLength(1);
+  expect(tabs[0].id).toBe(EDITOR_ID);
+  expect(tabs[0].title).toBe('Untitled Query 1');
+});
+
+test('getDatabases returns all available databases', () => {
+  const databases = sqlLab.getDatabases();
+
+  expect(databases).toHaveLength(1);
+  expect(databases[0].id).toBe(1);
+  expect(databases[0].name).toBe('test_db');
+});
+
+test('createTab dispatches ADD_QUERY_EDITOR and returns the new tab', async () => {
+  const tab = await sqlLab.createTab({ title: 'Custom Tab', sql: 'SELECT 42' });
+
+  expect(tab).toBeDefined();
+  expect(tab.title).toBe('Custom Tab');
+  const tabs = sqlLab.getTabs();
+  expect(tabs.length).toBeGreaterThanOrEqual(2);
+});
+
+test('setActiveTab switches the active tab', async () => {
+  // Create a second tab first
+  await sqlLab.createTab({ title: 'Second Tab' });
+
+  // Switch back to the original tab
+  await sqlLab.setActiveTab(EDITOR_ID);
+
+  const currentTab = sqlLab.getCurrentTab();
+  expect(currentTab).toBeDefined();
+  expect(currentTab!.id).toBe(EDITOR_ID);
+});
+
+test('setActivePanel dispatches SET_ACTIVE_SOUTHPANE_TAB', async () => {
+  await sqlLab.setActivePanel('History');
+
+  const panel = sqlLab.getActivePanel();
+  expect(panel.id).toBe('History');
+});