Compare commits

...

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
f456aa07b0 test(span-login): split huge file tests in smaller ones 2026-05-07 14:24:13 -03:00
17 changed files with 2432 additions and 1797 deletions

View File

@@ -3,6 +3,7 @@ import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
maxWorkers: '50%',
silent: true,
clearMocks: true,
coverageDirectory: 'coverage',

View File

@@ -1,20 +1,31 @@
import ROUTES from 'constants/routes';
import history from 'lib/history';
/**
* Login - Authentication & Form Tests
*
* Split from Login.test.tsx for better parallelization.
* Tests password auth, callback auth, URL params, warnings, form state, and edge cases.
*/
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { ErrorV2 } from 'types/api';
import { Info } from 'types/api/v1/version/get';
import { SessionsContext } from 'types/api/v2/sessions/context/get';
import { Token } from 'types/api/v2/sessions/email_password/post';
import Login from '../index';
import {
CALLBACK_AUTHN_URL,
EMAIL_PASSWORD_ENDPOINT,
mockEmailPasswordResponse,
mockMultiOrgWithWarning,
mockOrgWithWarning,
mockSingleOrgCallbackAuth,
mockSingleOrgPasswordAuth,
mockVersionSetupCompleted,
PASSWORD_AUTHN_EMAIL,
SESSIONS_CONTEXT_ENDPOINT,
VERSION_ENDPOINT,
} from './Login.test-utils';
const VERSION_ENDPOINT = '*/api/v1/version';
const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
const CALLBACK_AUTHN_ORG = 'callback_authn_org';
const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
const PASSWORD_AUTHN_ORG = 'password_authn_org';
const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
// =============================================================================
// MOCKS
// =============================================================================
jest.mock('lib/history', () => ({
__esModule: true,
@@ -26,102 +37,13 @@ jest.mock('lib/history', () => ({
},
}));
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
// =============================================================================
// TESTS
// =============================================================================
// Mock data
const mockVersionSetupCompleted: Info = {
setupCompleted: true,
ee: 'Y',
version: '0.25.0',
};
const mockVersionSetupIncomplete: Info = {
setupCompleted: false,
ee: 'Y',
version: '0.25.0',
};
const mockSingleOrgPasswordAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
],
};
const mockSingleOrgCallbackAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
const mockMultiOrgMixedAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: PASSWORD_AUTHN_ORG,
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
{
id: 'org-2',
name: CALLBACK_AUTHN_ORG,
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
const mockOrgWithWarning: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Warning Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
const mockEmailPasswordResponse: Token = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
};
describe('Login Component', () => {
describe('Login - Authentication & Form', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
@@ -136,269 +58,6 @@ describe('Login Component', () => {
server.resetHandlers();
});
describe('Initial Render', () => {
it('renders login form with email input and next button', () => {
const { getByTestId, getByPlaceholderText } = render(<Login />);
expect(
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
).toBeInTheDocument();
expect(getByTestId('email')).toBeInTheDocument();
expect(getByTestId('initiate_login')).toBeInTheDocument();
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
});
it('shows loading state when version data is being fetched', () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
const { getByTestId } = render(<Login />);
expect(getByTestId('initiate_login')).toBeDisabled();
});
});
describe('Setup Check', () => {
it('redirects to signup when setup is not completed', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
});
});
it('stays on login page when setup is completed', async () => {
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
it('handles version API error gracefully', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Server error' })),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
});
describe('Session Context Fetching', () => {
it('fetches session context on next button click and enables password', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
});
});
it('handles session context API errors', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'internal_server',
message: 'couldnt fetch the sessions context',
url: '',
},
}),
),
),
);
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
});
});
it('auto-selects organization when only one exists', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
// Should show password field directly (no org selection needed)
expect(getByTestId('password')).toBeInTheDocument();
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
});
});
});
describe('Organization Selection', () => {
it('shows organization dropdown when multiple orgs exist', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('Organization Name')).toBeInTheDocument();
});
await screen.findByRole('combobox');
// Click on the dropdown to reveal the options
await user.click(screen.getByRole('combobox'));
await waitFor(() => {
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
});
});
it('updates selected organization on dropdown change', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await screen.findByRole('combobox');
// Select CALLBACK_AUTHN_ORG
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
await screen.findByRole('button', { name: /sign in with sso/i });
});
});
describe('Password Authentication', () => {
it('shows password field when password auth is supported', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -411,7 +70,6 @@ describe('Login Component', () => {
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -448,7 +106,6 @@ describe('Login Component', () => {
initialRoute: '/login?password=Y',
});
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -466,7 +123,6 @@ describe('Login Component', () => {
await user.click(nextButton);
await waitFor(() => {
// Should show password field even for SSO org due to password=Y override
expect(getByTestId('password')).toBeInTheDocument();
});
});
@@ -484,7 +140,6 @@ describe('Login Component', () => {
const { getByTestId, queryByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -510,10 +165,7 @@ describe('Login Component', () => {
it('redirects to callback URL on button click', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock window.location.href
const mockLocation = {
href: 'http://localhost/',
};
const mockLocation = { href: 'http://localhost/' };
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
@@ -527,7 +179,6 @@ describe('Login Component', () => {
const { getByTestId, queryByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -552,7 +203,6 @@ describe('Login Component', () => {
const callbackButton = getByTestId('callback_authn_submit');
await user.click(callbackButton);
// Check that window.location.href was set to the callback URL
await waitFor(() => {
expect(window.location.href).toBe(CALLBACK_AUTHN_URL);
});
@@ -567,7 +217,7 @@ describe('Login Component', () => {
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
rest.post('*/api/v2/sessions/email_password', async (_, res, ctx) =>
rest.post(EMAIL_PASSWORD_ENDPOINT, async (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockEmailPasswordResponse }),
@@ -577,7 +227,6 @@ describe('Login Component', () => {
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -604,8 +253,6 @@ describe('Login Component', () => {
await user.type(passwordInput, 'testpassword');
await user.click(loginButton);
// do not test for the request paramters here. Reference: https://mswjs.io/docs/best-practices/avoid-request-assertions
// rather test for the effects of the request
await waitFor(() => {
expect(localStorage.getItem('AUTH_TOKEN')).toBe('mock-access-token');
});
@@ -618,7 +265,7 @@ describe('Login Component', () => {
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
rest.post('*/api/v2/sessions/email_password', (_, res, ctx) =>
rest.post(EMAIL_PASSWORD_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(401),
ctx.json({
@@ -634,7 +281,6 @@ describe('Login Component', () => {
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -708,14 +354,13 @@ describe('Login Component', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockOrgWithWarning })),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -742,23 +387,6 @@ describe('Login Component', () => {
it('shows warning modal when a warning org is selected among multiple orgs', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock multiple orgs including one with a warning
const mockMultiOrgWithWarning = {
orgs: [
{ id: 'org1', name: 'Org 1' },
{
id: 'org2',
name: 'Org 2',
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgWithWarning })),
@@ -767,7 +395,6 @@ describe('Login Component', () => {
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -786,9 +413,8 @@ describe('Login Component', () => {
await screen.findByRole('combobox');
// Select the organization with a warning
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText('Org 2'));
await user.click(screen.getByText('Warning Organization'));
await waitFor(() => {
expect(
@@ -803,7 +429,7 @@ describe('Login Component', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
@@ -814,7 +440,6 @@ describe('Login Component', () => {
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -831,7 +456,6 @@ describe('Login Component', () => {
await user.click(nextButton);
// Button should be disabled during API call
expect(nextButton).toBeDisabled();
});
@@ -839,17 +463,15 @@ describe('Login Component', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
render(<Login />);
// Initially shows "Next" button
expect(screen.getByTestId('initiate_login')).toBeInTheDocument();
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -867,7 +489,6 @@ describe('Login Component', () => {
await user.click(nextButton);
await waitFor(() => {
// Should show "Sign in with Password" button for password auth
expect(screen.getByTestId('password_authn_submit')).toBeInTheDocument();
expect(screen.queryByTestId('initiate_login')).not.toBeInTheDocument();
});
@@ -884,14 +505,13 @@ describe('Login Component', () => {
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockNoOrgs })),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -909,7 +529,6 @@ describe('Login Component', () => {
await user.click(nextButton);
await waitFor(() => {
// Should not show any auth method buttons
expect(
screen.queryByTestId('password_authn_submit'),
).not.toBeInTheDocument();
@@ -937,14 +556,13 @@ describe('Login Component', () => {
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockNoAuthSupport })),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -962,7 +580,6 @@ describe('Login Component', () => {
await user.click(nextButton);
await waitFor(() => {
// Should not show any auth method buttons
expect(
screen.queryByTestId('password_authn_submit'),
).not.toBeInTheDocument();

View File

@@ -0,0 +1,241 @@
/**
* Login - Session Context & Organization Tests
*
* Split from Login.test.tsx for better parallelization.
* Tests session context fetching and organization selection.
*/
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import Login from '../index';
import {
CALLBACK_AUTHN_ORG,
mockMultiOrgMixedAuth,
mockSingleOrgPasswordAuth,
mockVersionSetupCompleted,
PASSWORD_AUTHN_EMAIL,
PASSWORD_AUTHN_ORG,
SESSIONS_CONTEXT_ENDPOINT,
VERSION_ENDPOINT,
} from './Login.test-utils';
// =============================================================================
// MOCKS
// =============================================================================
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '',
},
},
}));
// =============================================================================
// TESTS
// =============================================================================
describe('Login - Session Context & Organization', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('Session Context Fetching', () => {
it('fetches session context on next button click and enables password', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
const { getByTestId } = render(<Login />);
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
});
});
it('handles session context API errors', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'internal_server',
message: 'couldnt fetch the sessions context',
url: '',
},
}),
),
),
);
const { getByTestId, getByText } = render(<Login />);
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
});
});
it('auto-selects organization when only one exists', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
const { getByTestId } = render(<Login />);
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
});
});
});
describe('Organization Selection', () => {
it('shows organization dropdown when multiple orgs exist', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
const { getByTestId, getByText } = render(<Login />);
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('Organization Name')).toBeInTheDocument();
});
await screen.findByRole('combobox');
await user.click(screen.getByRole('combobox'));
await waitFor(() => {
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
});
});
it('updates selected organization on dropdown change', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
render(<Login />);
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await screen.findByRole('combobox');
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
await screen.findByRole('button', { name: /sign in with sso/i });
});
});
});

View File

@@ -0,0 +1,127 @@
/**
* Login - Initial Render & Setup Tests
*
* Split from Login.test.tsx for better parallelization.
* Tests initial render, loading states, and setup validation.
*/
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import Login from '../index';
import {
mockVersionSetupCompleted,
mockVersionSetupIncomplete,
VERSION_ENDPOINT,
} from './Login.test-utils';
// =============================================================================
// MOCKS
// =============================================================================
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '',
},
},
}));
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
// =============================================================================
// TESTS
// =============================================================================
describe('Login - Initial Render & Setup', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('Initial Render', () => {
it('renders login form with email input and next button', () => {
const { getByTestId, getByPlaceholderText } = render(<Login />);
expect(
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
).toBeInTheDocument();
expect(getByTestId('email')).toBeInTheDocument();
expect(getByTestId('initiate_login')).toBeInTheDocument();
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
});
it('shows loading state when version data is being fetched', () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
const { getByTestId } = render(<Login />);
expect(getByTestId('initiate_login')).toBeDisabled();
});
});
describe('Setup Check', () => {
it('redirects to signup when setup is not completed', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
});
});
it('stays on login page when setup is completed', async () => {
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
it('handles version API error gracefully', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Server error' })),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,172 @@
/**
* Shared test utilities for Login tests.
* Extract common mocks, data, and setup to avoid duplication across split test files.
*/
import { rest, server } from 'mocks-server/server';
import { Info } from 'types/api/v1/version/get';
import { SessionsContext } from 'types/api/v2/sessions/context/get';
import { Token } from 'types/api/v2/sessions/email_password/post';
import { ErrorV2 } from 'types/api';
// =============================================================================
// CONSTANTS
// =============================================================================
export const VERSION_ENDPOINT = '*/api/v1/version';
export const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
export const EMAIL_PASSWORD_ENDPOINT = '*/api/v2/sessions/email_password';
export const CALLBACK_AUTHN_ORG = 'callback_authn_org';
export const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
export const PASSWORD_AUTHN_ORG = 'password_authn_org';
export const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
// =============================================================================
// MOCK DATA
// =============================================================================
export const mockVersionSetupCompleted: Info = {
setupCompleted: true,
ee: 'Y',
version: '0.25.0',
};
export const mockVersionSetupIncomplete: Info = {
setupCompleted: false,
ee: 'Y',
version: '0.25.0',
};
export const mockSingleOrgPasswordAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
],
};
export const mockSingleOrgCallbackAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
export const mockMultiOrgMixedAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: PASSWORD_AUTHN_ORG,
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
{
id: 'org-2',
name: CALLBACK_AUTHN_ORG,
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
export const mockOrgWithWarning: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Warning Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
export const mockMultiOrgWithWarning: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Normal Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
{
id: 'org-2',
name: 'Warning Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
export const mockEmailPasswordResponse: Token = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
};
// =============================================================================
// MOCK SETUP HELPERS
// =============================================================================
export function setupVersionEndpoint(
data: Info = mockVersionSetupCompleted,
): void {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data, status: 'success' })),
),
);
}
export function setupSessionContextEndpoint(data: SessionsContext): void {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data })),
),
);
}
export function setupEmailPasswordEndpoint(
data: Token = mockEmailPasswordResponse,
): void {
server.use(
rest.post(EMAIL_PASSWORD_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data })),
),
);
}

View File

@@ -1,5 +1,6 @@
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
import { IAppContext, IUser } from 'providers/App/types';
import { IAppContext } from 'providers/App/types';
import { getAppContextMockMinimal } from 'tests/test-utils';
import { Channels } from 'types/api/channels/getAll';
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
@@ -78,49 +79,14 @@ export function getUseRoutingPoliciesMockData(
};
}
/**
* @deprecated Use getAppContextMockMinimal from 'tests/test-utils' directly.
* This is a backwards-compatible wrapper that will be removed in a future version.
*/
export function getAppContextMockState(
overrides?: Partial<IUser>,
overrides?: Partial<IAppContext['user']>,
): IAppContext {
return {
user: {
accessJwt: 'some-token',
refreshJwt: 'some-refresh-token',
id: 'some-user-id',
email: 'user@signoz.io',
displayName: 'John Doe',
createdAt: 1732544623,
organization: 'Nightswatch',
orgId: 'does-not-matter-id',
role: 'ADMIN',
...overrides,
},
activeLicense: null,
trialInfo: null,
featureFlags: null,
orgPreferences: null,
userPreferences: null,
isLoggedIn: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
featureFlagsFetchError: undefined,
orgPreferencesFetchError: undefined,
changelog: null,
showChangelogModal: false,
activeLicenseRefetch: jest.fn(),
updateUser: jest.fn(),
updateOrgPreferences: jest.fn(),
updateUserPreferenceInContext: jest.fn(),
updateOrg: jest.fn(),
updateChangelog: jest.fn(),
toggleChangelogModal: jest.fn(),
versionData: null,
hasEditPermission: false,
};
return getAppContextMockMinimal(overrides);
}
export function mockLocation(pathname: string): jest.Mock {

View File

@@ -51,7 +51,10 @@ import { Span } from 'types/api/trace/getTraceV2';
import { formatEpochTimestamp } from 'utils/timeUtils';
import Attributes from './Attributes/Attributes';
import { RelatedSignalsViews } from './constants';
import {
RelatedSignalsViews,
SPAN_PERCENTILE_INITIAL_DELAY_MS,
} from './constants';
import EventAttribute from './Events/components/EventAttribute';
import Events from './Events/Events';
import LinkedSpans from './LinkedSpans/LinkedSpans';
@@ -410,7 +413,7 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
const timer = setTimeout(() => {
setInitialWaitCompleted(true);
}, 2000); // 2-second delay
}, SPAN_PERCENTILE_INITIAL_DELAY_MS);
return (): void => {
// clean the old state around span percentile data

View File

@@ -9,6 +9,12 @@ import { Span } from 'types/api/trace/getTraceV2';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
// Mock delay constant for faster tests
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
// Mock external dependencies
const mockRedirectWithQueryBuilderData = jest.fn();
const mockNotifications = {

View File

@@ -1,3 +1,5 @@
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import ROUTES from 'constants/routes';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
@@ -6,6 +8,13 @@ import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import {
mockSpanPercentileResponse,
mockUserPreferenceResponse,
} from './SpanDetailsDrawer.test-utils';
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
const mockGetUserPreference = jest.mocked(getUserPreference);
import {
expectedHostOnlyMetadata,
expectedInfraMetadata,
@@ -22,6 +31,21 @@ import {
} from './infraMetricsTestData';
// Mock external dependencies
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
@@ -172,6 +196,10 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup default mocks for percentile APIs to avoid delays
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
// Setup API call tracking for infra metrics
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
apiCallHistory.push(query);

View File

@@ -0,0 +1,414 @@
/**
* SpanDetailsDrawer - Logs Tests
*
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
* Tests logs tab display, API queries, navigation, and highlighting.
*/
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor } from 'tests/test-utils';
import {
expectedAfterFilterExpression,
expectedBeforeFilterExpression,
expectedSpanFilterExpression,
expectedTraceOnlyFilterExpression,
mockAfterLogsResponse,
mockBeforeLogsResponse,
mockSpanLogsResponse,
} from './mockData';
import {
ApiCallHistory,
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
clearAllMocks,
createApiCallHistory,
mockSafeNavigate,
mockSpanPercentileResponse,
mockUpdateAllQueriesOperators,
mockUserPreferenceResponse,
mockWindowOpen,
renderSpanDetailsDrawer,
setupLogsApiMock,
setupSpanDetailsDrawerMocks,
} from './SpanDetailsDrawer.test-utils';
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
const mockGetUserPreference = jest.mocked(getUserPreference);
// =============================================================================
// MOCK SETUP
// =============================================================================
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: ROUTES.TRACE_DETAIL,
search: 'trace_id=test-trace-id',
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
}),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock(
'components/Logs/RawLogView',
() =>
function MockRawLogView({
data,
onLogClick,
isHighlighted,
helpTooltip,
}: {
data: any;
onLogClick: (data: any, event: React.MouseEvent) => void;
isHighlighted: boolean;
helpTooltip: string;
}): JSX.Element {
return (
<div
data-testid={`raw-log-${data.id}`}
className={isHighlighted ? 'log-highlighted' : 'log-context'}
title={helpTooltip}
onClick={(e): void => onLogClick?.(data, e)}
>
<div>{data.body}</div>
<div>{data.timestamp}</div>
</div>
);
},
);
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
}));
// =============================================================================
// TESTS
// =============================================================================
describe('SpanDetailsDrawer - Logs', () => {
let apiCallHistory: ApiCallHistory;
beforeEach(() => {
jest.useRealTimers();
clearAllMocks();
setupSpanDetailsDrawerMocks();
// Setup percentile API mocks to avoid delays
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
apiCallHistory = createApiCallHistory();
setupLogsApiMock(
apiCallHistory,
mockSpanLogsResponse,
mockBeforeLogsResponse,
mockAfterLogsResponse,
);
});
afterEach(() => {
server.resetHandlers();
});
it('should display logs tab in right sidebar when span is selected', async () => {
renderSpanDetailsDrawer();
const logsButton = screen.getByRole('button', { name: /logs/i });
expect(logsButton).toBeInTheDocument();
expect(logsButton).toBeVisible();
});
it(
'should open related logs view when logs tab is clicked',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(screen.getByTestId('overlay-scrollbar')).toBeInTheDocument();
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
expect(
screen.getByTestId('raw-log-context-log-before'),
).toBeInTheDocument();
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
});
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should make 3 API queries when logs tab is opened',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
});
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
trace_only_logs: traceOnlyQuery,
} = apiCallHistory;
expect((spanQuery as any).query.builder.queryData[0].filter.expression).toBe(
expectedSpanFilterExpression,
);
expect(
(beforeQuery as any).query.builder.queryData[0].filter.expression,
).toBe(expectedBeforeFilterExpression);
expect(
(afterQuery as any).query.builder.queryData[0].filter.expression,
).toBe(expectedAfterFilterExpression);
if (traceOnlyQuery) {
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
expectedTraceOnlyFilterExpression,
);
}
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should use correct timestamp ordering for different query types',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
});
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
} = apiCallHistory;
expect((spanQuery as any).query.builder.queryData[0].orderBy[0].order).toBe(
'desc',
);
expect(
(beforeQuery as any).query.builder.queryData[0].orderBy[0].order,
).toBe('desc');
expect((afterQuery as any).query.builder.queryData[0].orderBy[0].order).toBe(
'asc',
);
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should navigate to logs explorer with span filters when span log is clicked',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
});
const spanLog = screen.getByTestId('raw-log-span-log-1');
await user.click(spanLog);
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(ROUTES.LOGS_EXPLORER),
'_blank',
);
});
const navigationCall = mockWindowOpen.mock.calls[0][0];
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
expect(urlParams.get(QueryParams.activeLogId)).toBe('"span-log-1"');
expect(urlParams.get(QueryParams.startTime)).toBe('1640994900000');
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.builder.queryData[0].filter.expression).toContain(
"trace_id = 'test-trace-id'",
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should navigate to logs explorer with trace filter when context log is clicked',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(
screen.getByTestId('raw-log-context-log-before'),
).toBeInTheDocument();
});
const contextLog = screen.getByTestId('raw-log-context-log-before');
await user.click(contextLog);
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(ROUTES.LOGS_EXPLORER),
'_blank',
);
});
const navigationCall = mockWindowOpen.mock.calls[0][0];
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.builder.queryData[0].filter.expression).toContain(
"trace_id = 'test-trace-id'",
);
expect(compositeQuery.builder.queryData[0].filter.expression).not.toContain(
'span_id',
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should always open logs explorer in new tab regardless of click type',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
});
const spanLog = screen.getByTestId('raw-log-span-log-1');
await user.click(spanLog);
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(ROUTES.LOGS_EXPLORER),
'_blank',
);
});
expect(mockSafeNavigate).not.toHaveBeenCalled();
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should display span logs as highlighted and context logs as regular',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
});
await waitFor(() => {
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
expect(
screen.getByTestId('raw-log-context-log-before'),
).toBeInTheDocument();
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
});
const spanLog1 = screen.getByTestId('raw-log-span-log-1');
const spanLog2 = screen.getByTestId('raw-log-span-log-2');
expect(spanLog1).toHaveClass('log-highlighted');
expect(spanLog2).toHaveClass('log-highlighted');
expect(spanLog1).toHaveAttribute(
'title',
'This log belongs to the current span',
);
const contextLogBefore = screen.getByTestId('raw-log-context-log-before');
const contextLogAfter = screen.getByTestId('raw-log-context-log-after');
expect(contextLogBefore).toHaveClass('log-context');
expect(contextLogAfter).toHaveClass('log-context');
expect(contextLogBefore).not.toHaveAttribute('title');
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
});

View File

@@ -0,0 +1,565 @@
/**
* SpanDetailsDrawer - Span Percentile Tests
*
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
* Tests percentile display, expansion, time range selection, and resource attributes.
*/
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { SuccessResponseV2 } from 'types/api';
import { GetSpanPercentilesResponseDataProps } from 'types/api/trace/getSpanPercentiles';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import { mockEmptyLogsResponse, mockSpan } from './mockData';
// =============================================================================
// TYPED MOCKS (defined before jest.mock for proper hoisting)
// =============================================================================
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
const mockGetUserPreference = jest.mocked(getUserPreference);
const mockSafeNavigate = jest.fn();
// =============================================================================
// JEST MOCKS
// =============================================================================
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: '/trace',
search: 'trace_id=test-trace-id',
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filter: { expression: "trace_id = 'test-trace-id'" },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
});
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
}),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
}));
// =============================================================================
// MOCK DATA
// =============================================================================
const mockSpanPercentileResponse = {
httpStatusCode: 200 as const,
data: {
percentiles: {
p50: 500000000,
p90: 1000000000,
p95: 1500000000,
p99: 2000000000,
},
position: {
percentile: 75.5,
description: 'This span is in the 75th percentile',
},
},
};
const mockUserPreferenceResponse = {
statusCode: 200,
httpStatusCode: 200,
error: null,
message: 'Success',
data: {
name: 'span_percentile_resource_attributes',
description: 'Resource attributes for span percentile calculation',
valueType: 'array',
defaultValue: [],
value: ['service.name', 'name', 'http.method'],
allowedValues: [],
allowedScopes: [],
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
},
};
const mockSpanPercentileErrorResponse = {
httpStatusCode: 500,
data: null,
} as unknown as SuccessResponseV2<GetSpanPercentilesResponseDataProps>;
// =============================================================================
// CONSTANTS
// =============================================================================
const P75_TEXT = 'p75';
const SPAN_PERCENTILE_TEXT = 'Span Percentile';
const SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER = 'Search resource attributes';
// =============================================================================
// RENDER HELPER
// =============================================================================
const mockQueryBuilderContextValue = {
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
stagedQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
panelType: 'list',
redirectWithQuery: jest.fn(),
handleRunQuery: jest.fn(),
handleStageQuery: jest.fn(),
resetQuery: jest.fn(),
};
function renderSpanDetailsDrawer(): void {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpan}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
}
// =============================================================================
// TESTS
// =============================================================================
describe('SpanDetailsDrawer - Span Percentile Functionality', () => {
beforeEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
mockSafeNavigate.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup default mocks
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
Promise.resolve(mockEmptyLogsResponse),
);
});
afterEach(() => {
server.resetHandlers();
});
it('should display span percentile value after successful API call', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
});
it('should call API with correct parameters', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(mockGetSpanPercentiles).toHaveBeenCalled();
});
expect(mockGetSpanPercentiles).toHaveBeenCalledWith({
start: expect.any(Number),
end: expect.any(Number),
spanDuration: mockSpan.durationNano,
serviceName: mockSpan.serviceName,
name: mockSpan.name,
resourceAttributes: expect.any(Object),
});
});
it('should handle user preference loading', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(mockGetUserPreference).toHaveBeenCalledWith({
name: 'span_percentile_resource_attributes',
});
});
});
it('should show loading spinner while fetching percentile data', async () => {
mockGetSpanPercentiles.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve(mockSpanPercentileResponse), 1000);
}),
);
renderSpanDetailsDrawer();
await waitFor(() => {
const spinnerContainer = document.querySelector(
'.loading-spinner-container',
);
expect(spinnerContainer).toBeInTheDocument();
});
});
it('should handle API error gracefully', async () => {
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileErrorResponse);
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.queryByText(/p\d+/)).not.toBeInTheDocument();
});
});
it('should not display percentile value when API returns non-200 status', async () => {
mockGetSpanPercentiles.mockResolvedValue({
httpStatusCode: 500 as const,
data: null,
} as unknown as Awaited<ReturnType<typeof getSpanPercentiles>>);
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.queryByText(/p\d+/)).not.toBeInTheDocument();
});
});
it('should handle empty percentile data gracefully', async () => {
mockGetSpanPercentiles.mockResolvedValue({
httpStatusCode: 200,
data: {
percentiles: {},
position: {
percentile: 0,
description: '',
},
},
});
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText('p0')).toBeInTheDocument();
});
});
it('should display tooltip with correct content', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.mouseEnter(percentileValue);
await waitFor(() => {
expect(screen.getByText(/This span duration is/)).toBeInTheDocument();
expect(screen.getByText(/out of the distribution/)).toBeInTheDocument();
expect(
screen.getByText(/evaluated for 1 hour\(s\) since the span start time/),
).toBeInTheDocument();
expect(screen.getByText('Click to learn more')).toBeInTheDocument();
});
});
it('should expand percentile details when percentile value is clicked', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
expect(screen.getByText(/This span duration is/)).toBeInTheDocument();
expect(
screen.getByText(/out of the distribution for this resource/),
).toBeInTheDocument();
});
});
it('should display percentile table with correct values', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText('Percentile')).toBeInTheDocument();
expect(screen.getByText('Duration')).toBeInTheDocument();
});
expect(screen.getByText('p50')).toBeInTheDocument();
expect(screen.getByText('p90')).toBeInTheDocument();
expect(screen.getByText('p95')).toBeInTheDocument();
expect(screen.getByText('p99')).toBeInTheDocument();
expect(screen.getAllByText(P75_TEXT)).toHaveLength(3);
expect(screen.getAllByText(/this span/i).length).toBeGreaterThan(0);
});
it('should allow time range selection and trigger API call', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const timeRangeSelector = screen.getByRole('combobox');
expect(timeRangeSelector).toBeInTheDocument();
expect(screen.getByText(/1.*hour/i)).toBeInTheDocument();
await waitFor(() => {
expect(mockGetSpanPercentiles).toHaveBeenCalledWith(
expect.objectContaining({
start: expect.any(Number),
end: expect.any(Number),
spanDuration: mockSpan.durationNano,
serviceName: mockSpan.serviceName,
name: mockSpan.name,
resourceAttributes: expect.any(Object),
}),
);
});
});
it('should show resource attributes selector when plus icon is clicked', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const plusIcon = screen.getByTestId('plus-icon');
fireEvent.click(plusIcon);
await waitFor(() => {
expect(
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).toBeInTheDocument();
});
});
it('should filter resource attributes based on search query', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const plusIcon = screen.getByTestId('plus-icon');
fireEvent.click(plusIcon);
await waitFor(() => {
expect(
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER,
);
fireEvent.change(searchInput, { target: { value: 'http' } });
expect(screen.getAllByText('http.method').length).toBeGreaterThan(0);
expect(screen.getAllByText(SPAN_ATTRIBUTES.HTTP_URL).length).toBeGreaterThan(
0,
);
expect(screen.getAllByText('http.status_code').length).toBeGreaterThan(0);
});
it('should handle resource attribute selection and trigger API call', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const plusIcon = screen.getByTestId('plus-icon');
fireEvent.click(plusIcon);
await waitFor(() => {
expect(
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).toBeInTheDocument();
});
const httpMethodCheckbox = screen.getByRole('checkbox', {
name: /http\.method/i,
});
fireEvent.click(httpMethodCheckbox);
await waitFor(() => {
expect(mockGetSpanPercentiles).toHaveBeenCalledWith(
expect.objectContaining({
resourceAttributes: expect.objectContaining({
'http.method': 'GET',
}),
}),
);
});
});
it('should close resource attributes selector when check icon is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
await user.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const plusIcon = screen.getByTestId('plus-icon');
await user.click(plusIcon);
await waitFor(() => {
expect(
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).toBeInTheDocument();
});
const checkIcon = screen.getByTestId('check-icon');
await user.click(checkIcon);
await waitFor(() => {
expect(
screen.queryByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,188 @@
/**
* SpanDetailsDrawer - Search Visibility Tests
*
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
* Tests search functionality in the attributes tab.
*/
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { fireEvent, screen, userEvent, waitFor } from 'tests/test-utils';
import { mockEmptyLogsResponse } from './mockData';
import {
clearAllMocks,
mockSafeNavigate,
mockUpdateAllQueriesOperators,
renderSpanDetailsDrawer,
SEARCH_PLACEHOLDER,
setupSpanDetailsDrawerMocks,
} from './SpanDetailsDrawer.test-utils';
// =============================================================================
// MOCK SETUP
// =============================================================================
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
// Mock external dependencies
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: '/trace',
search: 'trace_id=test-trace-id',
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
}),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
// Mock getSpanPercentiles API
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
// Mock getUserPreference API
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
// Mock PreferenceContextProvider
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
}));
// =============================================================================
// TESTS
// =============================================================================
describe('SpanDetailsDrawer - Search Visibility User Flows', () => {
beforeEach(() => {
jest.useRealTimers();
clearAllMocks();
setupSpanDetailsDrawerMocks();
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
Promise.resolve(mockEmptyLogsResponse),
);
});
afterEach(() => {
server.resetHandlers();
});
// Journey 1: Default Search Visibility
it('should display search visible by default when user opens span details', () => {
renderSpanDetailsDrawer();
// User sees search input in the Attributes tab by default
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
expect(searchInput).toBeInTheDocument();
expect(searchInput).toBeVisible();
});
it('should filter attributes when user types in search', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderSpanDetailsDrawer();
// User sees all attributes initially
expect(screen.getByText('http.method')).toBeInTheDocument();
expect(screen.getByText(SPAN_ATTRIBUTES.HTTP_URL)).toBeInTheDocument();
expect(screen.getByText('http.status_code')).toBeInTheDocument();
// User types "method" in search
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
await user.type(searchInput, 'method');
// User sees only matching attributes
await waitFor(() => {
expect(screen.getByText('http.method')).toBeInTheDocument();
expect(screen.queryByText(SPAN_ATTRIBUTES.HTTP_URL)).not.toBeInTheDocument();
expect(screen.queryByText('http.status_code')).not.toBeInTheDocument();
});
});
// Journey 2: Search Toggle & Focus Management
it('should hide search when user clicks search icon', () => {
renderSpanDetailsDrawer();
// User sees search initially
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
// User clicks search icon to hide search
const tabBar = screen.getByRole('tablist');
const searchIcon = tabBar.querySelector('.search-icon');
if (searchIcon) {
fireEvent.click(searchIcon);
}
// Search is now hidden
expect(
screen.queryByPlaceholderText(SEARCH_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('should show and focus search when user clicks search icon again', () => {
renderSpanDetailsDrawer();
// User clicks search icon to hide
const tabBar = screen.getByRole('tablist');
const searchIcon = tabBar.querySelector('.search-icon');
if (searchIcon) {
fireEvent.click(searchIcon);
}
// Search is hidden
expect(
screen.queryByPlaceholderText(SEARCH_PLACEHOLDER),
).not.toBeInTheDocument();
// User clicks search icon again to show
if (searchIcon) {
fireEvent.click(searchIcon);
}
// Search appears and receives focus
const searchInput = screen.getByPlaceholderText(
SEARCH_PLACEHOLDER,
) as HTMLInputElement;
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveFocus();
});
});

View File

@@ -0,0 +1,228 @@
/**
* SpanDetailsDrawer - Status Message Truncation Tests
*
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
* Tests status message display and expandable popover functionality.
*/
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import {
mockEmptyLogsResponse,
mockSpanWithLongStatusMessage,
mockSpanWithShortStatusMessage,
} from './mockData';
import {
clearAllMocks,
mockQueryBuilderContextValue,
mockSafeNavigate,
mockUpdateAllQueriesOperators,
setupSpanDetailsDrawerMocks,
} from './SpanDetailsDrawer.test-utils';
// =============================================================================
// MOCK SETUP
// =============================================================================
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: '/trace',
search: 'trace_id=test-trace-id',
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
}),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock(
'container/SpanDetailsDrawer/Events/components/AttributeWithExpandablePopover',
() =>
function AttributeWithExpandablePopover({
attributeKey,
attributeValue,
onExpand,
}: {
attributeKey: string;
attributeValue: string;
onExpand: (title: string, content: string) => void;
}): JSX.Element {
return (
<div className="attribute-container" key={attributeKey}>
<div className="attribute-key">{attributeKey}</div>
<div className="wrapper">
<div className="attribute-value">{attributeValue}</div>
<div data-testid="popover-content">
<pre>{attributeValue}</pre>
<button
type="button"
onClick={(): void => onExpand(attributeKey, attributeValue)}
>
Expand
</button>
</div>
</div>
</div>
);
},
);
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
}));
// =============================================================================
// TESTS
// =============================================================================
describe('SpanDetailsDrawer - Status Message Truncation User Flows', () => {
beforeEach(() => {
jest.useRealTimers();
clearAllMocks();
setupSpanDetailsDrawerMocks();
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
Promise.resolve(mockEmptyLogsResponse),
);
});
afterEach(() => {
server.resetHandlers();
});
it('should display expandable popover with Expand button for long status message', () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithLongStatusMessage}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// User sees status message label
expect(screen.getByText('status message')).toBeInTheDocument();
// User sees the status message value
const statusMessageElements = screen.getAllByText(
mockSpanWithLongStatusMessage.statusMessage,
);
expect(statusMessageElements.length).toBeGreaterThan(0);
// User sees Expand button in popover
const expandButton = screen.getByRole('button', { name: /expand/i });
expect(expandButton).toBeInTheDocument();
});
it('should open modal with full status message when user clicks Expand button', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithLongStatusMessage}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// User clicks the Expand button
const expandButton = screen.getByRole('button', { name: /expand/i });
await fireEvent.click(expandButton);
// User sees modal with the full status message content
await waitFor(() => {
const modalTitle = document.querySelector('.ant-modal-title');
expect(modalTitle).toBeInTheDocument();
expect(modalTitle?.textContent).toBe('status message');
const preElement = document.querySelector(
'.attribute-with-expandable-popover__full-view',
);
expect(preElement).toBeInTheDocument();
expect(preElement?.textContent).toBe(
mockSpanWithLongStatusMessage.statusMessage,
);
});
});
it('should display short status message as simple text without popover', () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithShortStatusMessage}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// User sees status message label and value
expect(screen.getByText('status message')).toBeInTheDocument();
expect(
screen.getByText(mockSpanWithShortStatusMessage.statusMessage),
).toBeInTheDocument();
// User hovers over the status message value
const statusMessageValue = screen.getByText(
mockSpanWithShortStatusMessage.statusMessage,
);
fireEvent.mouseEnter(statusMessageValue);
// No Expand button should appear
expect(
screen.queryByRole('button', { name: /expand/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,249 @@
/**
* Shared test utilities for SpanDetailsDrawer tests.
* Extract common mocks, setup, and render helpers to avoid duplication across split test files.
*/
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { render } from 'tests/test-utils';
import { SuccessResponseV2 } from 'types/api';
import { GetSpanPercentilesResponseDataProps } from 'types/api/trace/getSpanPercentiles';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import { mockEmptyLogsResponse, mockSpan } from './mockData';
// =============================================================================
// TYPED MOCKS
// =============================================================================
export const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
export const mockGetUserPreference = jest.mocked(getUserPreference);
export const mockSafeNavigate = jest.fn();
export const mockWindowOpen = jest.fn();
// =============================================================================
// MOCK SETUP (call in beforeAll or at module level)
// =============================================================================
export function setupSpanDetailsDrawerMocks(): void {
// Mock window.open
Object.defineProperty(window, 'open', {
writable: true,
value: mockWindowOpen,
});
}
// =============================================================================
// MOCK UPDATE OPERATORS
// =============================================================================
export const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filter: { expression: "trace_id = 'test-trace-id'" },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
});
// =============================================================================
// QUERY BUILDER CONTEXT MOCK
// =============================================================================
export const mockQueryBuilderContextValue = {
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
stagedQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
panelType: 'list',
redirectWithQuery: jest.fn(),
handleRunQuery: jest.fn(),
handleStageQuery: jest.fn(),
resetQuery: jest.fn(),
};
// =============================================================================
// RENDER HELPER
// =============================================================================
interface RenderSpanDetailsDrawerProps {
selectedSpan?: typeof mockSpan;
traceStartTime?: number;
traceEndTime?: number;
isSpanDetailsDocked?: boolean;
setIsSpanDetailsDocked?: jest.Mock;
}
export const renderSpanDetailsDrawer = (
props: RenderSpanDetailsDrawerProps = {},
): void => {
const {
selectedSpan = mockSpan,
traceStartTime = 1640995200000,
traceEndTime = 1640995260000,
isSpanDetailsDocked = false,
setIsSpanDetailsDocked = jest.fn(),
} = props;
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={isSpanDetailsDocked}
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
</QueryBuilderContext.Provider>,
);
};
// =============================================================================
// CONSTANTS
// =============================================================================
export const CI_SENSITIVE_LOGS_TEST_TIMEOUT = 15000;
export const P75_TEXT = 'p75';
export const SPAN_PERCENTILE_TEXT = 'Span Percentile';
export const SEARCH_PLACEHOLDER = 'Search for attribute...';
export const SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER =
'Search resource attributes';
// =============================================================================
// MOCK DATA FOR PERCENTILES
// =============================================================================
export const mockSpanPercentileResponse = {
httpStatusCode: 200 as const,
data: {
percentiles: {
p50: 500000000,
p90: 1000000000,
p95: 1500000000,
p99: 2000000000,
},
position: {
percentile: 75.5,
description: 'This span is in the 75th percentile',
},
},
};
export const mockUserPreferenceResponse = {
statusCode: 200,
httpStatusCode: 200,
error: null,
message: 'Success',
data: {
name: 'span_percentile_resource_attributes',
description: 'Resource attributes for span percentile calculation',
valueType: 'array',
defaultValue: [],
value: ['service.name', 'name', 'http.method'],
allowedValues: [],
allowedScopes: [],
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
},
};
export const mockSpanPercentileErrorResponse = {
httpStatusCode: 500,
data: null,
} as unknown as SuccessResponseV2<GetSpanPercentilesResponseDataProps>;
// =============================================================================
// COMMON BEFOREEACH SETUP
// =============================================================================
export interface ApiCallHistory {
span_logs: any;
before_logs: any;
after_logs: any;
trace_only_logs: any;
}
export function createApiCallHistory(): ApiCallHistory {
return {
span_logs: null,
before_logs: null,
after_logs: null,
trace_only_logs: null,
};
}
export function setupLogsApiMock(
apiCallHistory: ApiCallHistory,
mockSpanLogsResponse: any,
mockBeforeLogsResponse: any,
mockAfterLogsResponse: any,
): void {
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
const filterExpression = (query as any)?.query?.builder?.queryData?.[0]
?.filter?.expression;
if (!filterExpression) {
return Promise.resolve(mockEmptyLogsResponse);
}
if (filterExpression.includes('span_id')) {
apiCallHistory.span_logs = query;
return Promise.resolve(mockSpanLogsResponse);
}
if (filterExpression.includes('id <')) {
apiCallHistory.before_logs = query;
return Promise.resolve(mockBeforeLogsResponse);
}
if (filterExpression.includes('id >')) {
apiCallHistory.after_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
if (filterExpression.includes('trace_id =')) {
apiCallHistory.trace_only_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
return Promise.resolve(mockEmptyLogsResponse);
});
}
export function clearAllMocks(): void {
jest.clearAllMocks();
mockSafeNavigate.mockClear();
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
mockGetSpanPercentiles.mockClear();
mockGetUserPreference.mockClear();
}

View File

@@ -9,3 +9,9 @@ export const RELATED_SIGNALS_VIEW_TYPES = {
// METRICS: RelatedSignalsViews.METRICS,
INFRA: RelatedSignalsViews.INFRA,
};
/**
* Delay in milliseconds before fetching span percentile data on initial load.
* Product requirement to avoid overwhelming API on rapid span selections.
*/
export const SPAN_PERCENTILE_INITIAL_DELAY_MS = 2000;

View File

@@ -348,6 +348,165 @@ const customRender = (
});
};
// =============================================================================
// TIERED RENDER FUNCTIONS
// =============================================================================
// Use the lightest wrapper that meets your test's needs:
// - renderMinimal: Router + QueryClient only (fastest, for pure components)
// - renderMedium: + AppContext + ErrorModal (for components using app state)
// - render: Full provider stack (for integration tests)
// =============================================================================
/**
* Minimal provider wrapper - Router + QueryClient only.
* Use for pure components that don't need app state, redux, or other contexts.
* ~5x faster than full render.
*/
function MinimalProviders({
children,
initialRoute = '/',
}: {
children: React.ReactNode;
initialRoute?: string;
}): ReactElement {
return (
<MemoryRouter initialEntries={[initialRoute]}>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</NuqsAdapter>
</MemoryRouter>
);
}
export const renderMinimal = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'> & { initialRoute?: string },
): RenderResult => {
const { initialRoute, ...renderOptions } = options || {};
return render(ui, {
wrapper: ({ children }) => (
<MinimalProviders initialRoute={initialRoute}>{children}</MinimalProviders>
),
...renderOptions,
});
};
/**
* Medium provider wrapper - Router + QueryClient + AppContext + ErrorModal.
* Use for components that need app context but not Redux, Timezone, or QueryBuilder.
* ~2x faster than full render.
*/
function MediumProviders({
children,
initialRoute = '/',
role = 'ADMIN',
appContextOverrides = {},
}: {
children: React.ReactNode;
initialRoute?: string;
role?: string;
appContextOverrides?: Partial<IAppContext>;
}): ReactElement {
return (
<MemoryRouter initialEntries={[initialRoute]}>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
<ErrorModalProvider>{children}</ErrorModalProvider>
</AppContext.Provider>
</QueryClientProvider>
</NuqsAdapter>
</MemoryRouter>
);
}
interface MediumProviderProps {
initialRoute?: string;
role?: string;
appContextOverrides?: Partial<IAppContext>;
}
export const renderMedium = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>,
providerProps: MediumProviderProps = {},
): RenderResult => {
const {
initialRoute = '/',
role = 'ADMIN',
appContextOverrides = {},
} = providerProps;
return render(ui, {
wrapper: ({ children }) => (
<MediumProviders
initialRoute={initialRoute}
role={role}
appContextOverrides={appContextOverrides}
>
{children}
</MediumProviders>
),
...options,
});
};
// =============================================================================
// SIMPLIFIED CONTEXT MOCK HELPERS
// =============================================================================
/**
* Simplified IAppContext mock with minimal defaults.
* Use this when you need a lightweight mock with mostly null/false values.
* Pass userOverrides to customize only the user object.
*
* This consolidates the duplicate getAppContextMockState functions that existed
* in various test utility files.
*/
export function getAppContextMockMinimal(
userOverrides?: Partial<IAppContext['user']>,
): IAppContext {
return {
user: {
accessJwt: 'some-token',
refreshJwt: 'some-refresh-token',
id: 'some-user-id',
email: 'user@signoz.io',
displayName: 'John Doe',
createdAt: 1732544623,
organization: 'Nightswatch',
orgId: 'does-not-matter-id',
role: 'ADMIN',
...userOverrides,
},
activeLicense: null,
trialInfo: null,
featureFlags: null,
orgPreferences: null,
userPreferences: null,
isLoggedIn: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
featureFlagsFetchError: undefined,
orgPreferencesFetchError: undefined,
changelog: null,
showChangelogModal: false,
activeLicenseRefetch: jest.fn(),
updateUser: jest.fn(),
updateOrgPreferences: jest.fn(),
updateUserPreferenceInContext: jest.fn(),
updateOrg: jest.fn(),
updateChangelog: jest.fn(),
toggleChangelogModal: jest.fn(),
versionData: null,
hasEditPermission: false,
};
}
export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';
export { customRender as render };