Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 141 additions & 10 deletions superset-frontend/src/embedded/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,104 @@
* specific language governing permissions and limitations
* under the License.
*/
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
// Mark this file as a module so its top-level declarations stay file-scoped
// (the file has no imports; modules are loaded via require() inside tests).
export {};

// Stable mock references so they survive jest.resetModules() between tests
// (a factory-created jest.fn() would otherwise be replaced on each reset,
// leaving these imported handles pointing at a stale instance).
const mockSetupAGGridModules = jest.fn();
const mockLogging = { debug: jest.fn(), warn: jest.fn(), error: jest.fn() };

jest.mock('src/public-path', () => ({}));

jest.mock('query-string', () => ({}));

jest.mock('@superset-ui/core/components/ThemedAgGridReact', () => ({
setupAGGridModules: jest.fn(),
setupAGGridModules: mockSetupAGGridModules,
}));

jest.mock('src/preamble', () => jest.fn().mockResolvedValue(true));
jest.mock('@apache-superset/core/utils', () => ({
logging: mockLogging,
}));

jest.mock('src/setup/setupPlugins', () => jest.fn(), { virtual: true });
// setupPlugins is driven per-test so the retry path can force a rejection.
const mockSetupPlugins = jest.fn();
jest.mock('src/setup/setupPlugins', () => mockSetupPlugins, { virtual: true });

jest.mock('src/setup/setupCodeOverrides', () => jest.fn(), { virtual: true });

jest.mock('src/preamble', () => jest.fn().mockResolvedValue(true));

// makeApi returns a callable that resolves with the current user roles.
const mockGetMeWithRole = jest.fn();
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
makeApi: () => mockGetMeWithRole,
}));

jest.mock('src/components/UiConfigContext', () => ({
useUiConfig: () => ({}),
}));

// Capture the guestToken handler that start() is wired to, so tests can
// re-trigger the handshake and assert the retry behavior.
const mockSwitchboard = {
handler: undefined as ((arg: { guestToken: string }) => void) | undefined,
};
jest.mock('@superset-ui/switchboard', () => ({
__esModule: true,
default: {
init: jest.fn(),
start: jest.fn(),
defineMethod: (name: string, fn: (...args: any[]) => any) => {
if (name === 'guestToken') {
Comment on lines +69 to +71

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Replace the any-typed switchboard callback signature with a concrete function type that matches the mocked method contract. [custom_rule]

Severity Level: Minor ⚠️

Why it matters? 🤔

The final file still contains a new TypeScript callback typed with any[] and any, which directly violates the no-any-types rule for changed TypeScript code.

Fix in Cursor Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/src/embedded/index.test.tsx
**Line:** 69:71
**Comment:**
	*Custom Rule: Replace the `any`-typed switchboard callback signature with a concrete function type that matches the mocked method contract.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

mockSwitchboard.handler = fn;
}
},
emit: jest.fn(),
},
}));

jest.mock('src/setup/setupClient', () => jest.fn(), { virtual: true });

jest.mock('src/views/store', () => ({
store: {
dispatch: jest.fn(),
getState: () => ({ dataMask: {} }),
subscribe: jest.fn(),
},
USER_LOADED: 'USER_LOADED',
}));

jest.mock('src/components/MessageToasts/actions', () => ({
addDangerToast: jest.fn(() => ({ type: 'ADD_TOAST' })),
}));

jest.mock('src/components', () => ({ ErrorBoundary: () => null }));

jest.mock('src/components/MessageToasts/ToastContainer', () => () => null);

jest.mock('./EmbeddedContextProviders', () => ({
EmbeddedContextProviders: () => null,
getThemeController: () => null,
}));

jest.mock('./api', () => ({ embeddedApi: {} }));

jest.mock('./originValidation', () => ({ validateMessageEvent: () => true }));

jest.mock('./utils', () => ({ getDataMaskChangeTrigger: () => ({}) }));

jest.mock('react-dom/client', () => ({
createRoot: () => ({ render: jest.fn(), unmount: jest.fn() }),
}));

jest.mock('src/utils/getBootstrapData', () => ({
__esModule: true,
default: () => ({
embedded: { dashboard_id: '123' },
embedded: { dashboard_id: '123', allowed_domains: [] },
common: {
application_root: '/',
static_assets_prefix: '/',
Expand All @@ -49,18 +127,71 @@ jest.mock('src/utils/getBootstrapData', () => ({
staticAssetsPrefix: () => '/',
}));

const flush = async () => {
// Several microtask hops: initPreamble -> dynamic import() -> setup calls.
for (let i = 0; i < 20; i += 1) {
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => setTimeout(resolve, 0));
}
};

// Drive the postMessage handshake so index.tsx registers its Switchboard methods.
// jsdom lacks MessageChannel, so a minimal fake port is enough here.
function sendHandshake() {
const port = {} as MessagePort;
window.dispatchEvent(
new MessageEvent('message', {
data: { handshake: 'port transfer' },
ports: [port],
}),
);
}

describe('embedded/index.tsx', () => {
beforeAll(() => {
beforeEach(() => {
jest.resetModules();
mockSwitchboard.handler = undefined;
mockSetupPlugins.mockReset();
mockSetupAGGridModules.mockReset();
mockLogging.error.mockClear();
mockGetMeWithRole.mockReset();
mockGetMeWithRole.mockResolvedValue({ result: { roles: {} } });
document.body.innerHTML = '<div id="app"></div>';
});

test('initializes AG Grid modules on bootstrap', async () => {
mockSetupPlugins.mockImplementation(() => undefined);
require('./index');
await flush();

// index.tsx uses initPreamble().then(...) to initialize plugins and AG grid
// Wait for the promise chain to resolve
await new Promise(resolve => setTimeout(resolve, 0));
expect(mockSetupAGGridModules).toHaveBeenCalled();
});

test('retries plugin setup after setupPlugins rejects, then bootstraps the user', async () => {
// First plugin setup throws; the second attempt (after a re-handshake) succeeds.
mockSetupPlugins
.mockImplementationOnce(() => {
throw new Error('setupPlugins failed');
})
.mockImplementation(() => undefined);

require('./index');
await flush();

sendHandshake();
expect(mockSwitchboard.handler).toBeDefined();

// First guest token: plugin setup rejects, start() resets the guard and
// recreates pluginsReady so a retry can re-run setup.
mockSwitchboard.handler!({ guestToken: 'token-1' });
await flush();
expect(mockLogging.error).toHaveBeenCalled();
expect(mockGetMeWithRole).not.toHaveBeenCalled();

expect(setupAGGridModules).toHaveBeenCalled();
// Second guest token retries: plugin setup now succeeds and the user loads.
mockSwitchboard.handler!({ guestToken: 'token-2' });
await flush();
expect(mockSetupPlugins).toHaveBeenCalledTimes(2);
expect(mockGetMeWithRole).toHaveBeenCalled();
});
});
116 changes: 71 additions & 45 deletions superset-frontend/src/embedded/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,23 +55,39 @@ import { validateMessageEvent } from './originValidation';
// Dynamic imports (webpackMode: "eager") keep modules in the same bundle chunk but defer
// their evaluation until after initPreamble() resolves, so module-level t() calls in plugin
// control panels and setup code run only after translations are available.
const pluginsReady = initPreamble()
.catch(err => {
logging.warn(
'Preamble initialization failed, loading plugins without translations.',
err,
);
})
.then(async () => {
const [{ default: setupPlugins }, { default: setupCodeOverrides }] =
await Promise.all([
import(/* webpackMode: "eager" */ 'src/setup/setupPlugins'),
import(/* webpackMode: "eager" */ 'src/setup/setupCodeOverrides'),
]);
setupPlugins();
setupCodeOverrides({ embedded: true });
setupAGGridModules();
});
function loadPlugins() {
return initPreamble()
.catch(err => {
logging.warn(
'Preamble initialization failed, loading plugins without translations.',
err,
);
})
.then(async () => {
const [{ default: setupPlugins }, { default: setupCodeOverrides }] =
await Promise.all([
import(/* webpackMode: "eager" */ 'src/setup/setupPlugins'),
import(/* webpackMode: "eager" */ 'src/setup/setupCodeOverrides'),
]);
setupPlugins();
setupCodeOverrides({ embedded: true });
setupAGGridModules();
});
}

// Kick off plugin setup and attach a no-op rejection handler. If the promise
// settles before start() attaches its own handler, this keeps it from surfacing
// as an unhandled-rejection warning; start() still handles the rejection itself.
function schedulePlugins() {
const promise = loadPlugins();
promise.catch(() => {});
return promise;
}

// Kick off plugin setup eagerly at module load so it overlaps the handshake.
// If it rejects, start() recreates the promise so a retry can re-run setup
// instead of chaining off a permanently rejected promise.
let pluginsReady = schedulePlugins();

const debugMode = process.env.WEBPACK_MODE === 'development';
const bootstrapData = getBootstrapData();
Expand Down Expand Up @@ -192,34 +208,44 @@ function start() {
method: 'GET',
endpoint: '/api/v1/me/roles/',
});
return pluginsReady.then(() =>
getMeWithRole().then(
({ result }) => {
// fill in some missing bootstrap data
// (because at pageload, we don't have any auth yet)
// this allows the frontend's permissions checks to work.
bootstrapData.user = result;
store.dispatch({
type: USER_LOADED,
user: result,
});
if (!root) {
root = createRoot(appMountPoint);
}
root.render(<EmbeddedApp />);
},
err => {
// something is most likely wrong with the guest token; reset the guard
// so a rehandshake with a valid token can retry.
logging.error(err);
showFailureMessage(
t(
'Something went wrong with embedded authentication. Check the dev console for details.',
),
);
started = false;
},
),
return pluginsReady.then(
() =>
getMeWithRole().then(
({ result }) => {
// fill in some missing bootstrap data
// (because at pageload, we don't have any auth yet)
// this allows the frontend's permissions checks to work.
bootstrapData.user = result;
store.dispatch({
type: USER_LOADED,
user: result,
});
if (!root) {
root = createRoot(appMountPoint);
}
root.render(<EmbeddedApp />);
},
err => {
// something is most likely wrong with the guest token; reset the guard
// so a rehandshake with a valid token can retry.
logging.error(err);
showFailureMessage(
t(
'Something went wrong with embedded authentication. Check the dev console for details.',
),
);
started = false;
},
),
err => {
// setupPlugins() or setupCodeOverrides() threw while preparing plugins;
// reset the guard and recreate pluginsReady so a retry actually re-runs
// plugin setup instead of chaining off this rejected promise and leaving
// the dashboard stuck in a failed state.
logging.error(err);
pluginsReady = schedulePlugins();
started = false;
},
Comment thread
rusackas marked this conversation as resolved.
);
Comment thread
rusackas marked this conversation as resolved.
}

Expand Down
Loading
Loading