Compare commits

..

2 Commits

Author SHA1 Message Date
Gaurav Tewari
fc34c26fcf refactor: review changes 2026-06-16 16:54:15 +05:30
Gaurav Tewari
41958f6b42 chore: remove legacy trace view redirect 2026-06-16 16:00:08 +05:30
24 changed files with 32 additions and 2018 deletions

View File

@@ -64,10 +64,17 @@ export const TraceDetail = Loadable(
),
);
export const TraceDetailOldRedirect = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailOldRedirect" */ 'pages/TraceDetailOldRedirect/index'
),
);
export const TraceDetailV3 = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailsV3/index'
),
);

View File

@@ -47,7 +47,7 @@ import {
SomethingWentWrong,
StatusPage,
SupportPage,
TraceDetail,
TraceDetailOldRedirect,
TraceDetailV3,
TraceFilter,
TracesExplorer,
@@ -139,13 +139,11 @@ const routes: AppRoutes[] = [
exact: true,
key: 'LOGS_SAVE_VIEWS',
},
// V3 trace details is gated until release: /trace serves V2 (public),
// /trace-old serves V3 (URL-only access). Flip the two `component`
// values back to release V3.
// Legacy /trace-old/:id redirects to the current /trace/:id view.
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetail,
component: TraceDetailOldRedirect,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
},

View File

@@ -38,8 +38,8 @@ export enum LOCALSTORAGE {
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',

View File

@@ -68,10 +68,6 @@
border-left: unset;
border-radius: 0px 4px 4px 0px;
}
.new-view-btn {
margin-left: 8px;
}
}
.second-row {

View File

@@ -0,0 +1,18 @@
import { Redirect, useParams } from 'react-router-dom';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
// Legacy /trace-old/:id now redirects to the current /trace/:id view,
// preserving the query string and hash.
export default function TraceDetailOldRedirect(): JSX.Element {
const { id } = useParams<TraceDetailV2URLProps>();
return (
<Redirect
to={{
pathname: `/trace/${id}`,
search: window.location.search,
hash: window.location.hash,
}}
/>
);
}

View File

@@ -1,25 +0,0 @@
import { Redirect, useParams } from 'react-router-dom';
import getLocalStorageKey from 'api/browser/localstorage/get';
import { LOCALSTORAGE } from 'constants/localStorage';
import { TraceDetailV2URLProps } from 'types/api/trace/getTraceV2';
import TraceDetailsV3 from '../TraceDetailsV3';
export default function TraceDetailV3Page(): JSX.Element {
const { id } = useParams<TraceDetailV2URLProps>();
const preferOld =
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true';
if (preferOld) {
return (
<Redirect
to={{
pathname: `/trace-old/${id}`,
search: window.location.search,
}}
/>
);
}
return <TraceDetailsV3 />;
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react';
import { useCallback, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import {
@@ -8,11 +8,9 @@ import {
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import { Skeleton } from 'antd';
import setLocalStorageKey from 'api/browser/localstorage/set';
import cx from 'classnames';
import FieldsSelector from 'components/FieldsSelector';
import HttpStatusBadge from 'components/HttpStatusBadge/HttpStatusBadge';
import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes';
import { convertTimeToRelevantUnit } from 'container/TraceDetail/utils';
import dayjs from 'dayjs';
@@ -21,7 +19,6 @@ import {
ArrowLeft,
CalendarClock,
ChartPie,
CornerUpLeft,
Server,
Timer,
} from '@signozhq/icons';
@@ -93,18 +90,6 @@ function TraceDetailsHeader({
const setPreviewFields = useTraceStore((s) => s.setPreviewFields);
const logTraceEvent = useTraceDetailLogEvent('v3', traceID || '');
const pageLoadedAtRef = useRef(Date.now());
const handleSwitchToOldView = useCallback((): void => {
logTraceEvent(TraceDetailEvents.ViewSwitched, {
[TraceDetailEventKeys.From]: 'v3',
[TraceDetailEventKeys.To]: 'v2',
[TraceDetailEventKeys.DwellMs]: Date.now() - pageLoadedAtRef.current,
});
setLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW, 'true');
const oldUrl = `/trace-old/${traceID}${window.location.search}`;
history.replace(oldUrl);
}, [traceID, logTraceEvent]);
const handleToggleAnalytics = useCallback((): void => {
logTraceEvent(TraceDetailEvents.AnalyticsPanelToggled, {
@@ -172,20 +157,6 @@ function TraceDetailsHeader({
{!isFilterExpanded && (
<TooltipProvider>
<div className={styles.headerActions}>
<TooltipRoot>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Switch to legacy trace view"
onClick={handleSwitchToOldView}
>
<CornerUpLeft size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>Switch to legacy trace view</TooltipContent>
</TooltipRoot>
<TooltipRoot>
<TooltipTrigger asChild>
<Button

View File

@@ -26,13 +26,6 @@ jest.mock('react-router-dom', () => ({
useParams: (): { id: string } => ({ id: 'trace-123' }),
}));
const mockSetLocalStorageKey = jest.fn();
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void =>
mockSetLocalStorageKey(key, value),
}));
jest.mock(
'../../TraceWaterfall/TraceWaterfallStates/Success/Filters/Filters',
() => ({
@@ -97,15 +90,11 @@ describe('TraceDetailsHeader back button', () => {
describe('TraceDetailsHeader action cluster', () => {
beforeEach(() => {
mockReplace.mockClear();
mockSetLocalStorageKey.mockClear();
});
it('does not render the action buttons while data is still loading', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded={false} />);
expect(
screen.queryByRole('button', { name: /switch to legacy trace view/i }),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /^analytics$/i }),
).not.toBeInTheDocument();
@@ -114,12 +103,9 @@ describe('TraceDetailsHeader action cluster', () => {
).not.toBeInTheDocument();
});
it('renders Legacy View, Analytics, and Settings action buttons once data is loaded', () => {
it('renders Analytics and Settings action buttons once data is loaded', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
expect(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /^analytics$/i }),
).toBeInTheDocument();
@@ -128,23 +114,6 @@ describe('TraceDetailsHeader action cluster', () => {
).toBeInTheDocument();
});
it('routes to the legacy trace view and persists the preference on click', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);
fireEvent.click(
screen.getByRole('button', { name: /switch to legacy trace view/i }),
);
expect(mockSetLocalStorageKey).toHaveBeenCalledWith(
'TRACE_DETAILS_PREFER_OLD_VIEW',
'true',
);
expect(mockReplace).toHaveBeenCalledTimes(1);
expect(mockReplace).toHaveBeenCalledWith(
expect.stringContaining('/trace-old/trace-123'),
);
});
it('toggles the AnalyticsPanel open state when the Analytics button is clicked', () => {
render(<TraceDetailsHeader {...baseProps} isDataLoaded />);

View File

@@ -22,7 +22,6 @@ export enum TraceDetailEventKeys {
SpanPanelVariant = 'spanPanelVariant',
ColorByField = 'colorByField',
PreviewFieldsCount = 'previewFieldsCount',
EntryPreferOldView = 'entryPreferOldView',
// View switched
From = 'from',
To = 'to',

View File

@@ -179,8 +179,6 @@ function TraceDetailsV3(): JSX.Element {
SpanDetailVariant.DOCKED_RIGHT,
[TraceDetailEventKeys.ColorByField]: colorByField.name,
[TraceDetailEventKeys.PreviewFieldsCount]: previewFieldsCount,
[TraceDetailEventKeys.EntryPreferOldView]:
getLocalStorageKey(LOCALSTORAGE.TRACE_DETAILS_PREFER_OLD_VIEW) === 'true',
});
}, [
traceId,

View File

@@ -6,13 +6,6 @@ import {
type Page,
} from '@playwright/test';
import {
detectPersona,
detectSettingsEnv,
type Persona,
type SettingsEnv,
} from '../helpers/persona';
export type User = { email: string; password: string };
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
@@ -27,11 +20,6 @@ export const ADMIN: User = {
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
const storageByUser = new Map<string, Promise<StorageState>>();
// Per-worker persona/env caches by user email. Detection is constant for a
// given backend + user, so it runs once per worker.
const personaByUser = new Map<string, Promise<Persona>>();
const envByUser = new Map<string, Promise<SettingsEnv>>();
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
const cached = storageByUser.get(user.email);
if (cached) {
@@ -84,10 +72,6 @@ export const test = base.extend<{
* storageState is held in memory and reused for all later requests.
*/
authedPage: Page;
persona: Persona;
env: SettingsEnv;
}>({
user: [ADMIN, { option: true }],
@@ -109,24 +93,6 @@ export const test = base.extend<{
await use(page);
await ctx.close();
},
persona: async ({ authedPage, user }, use) => {
let task = personaByUser.get(user.email);
if (!task) {
task = detectPersona(authedPage);
personaByUser.set(user.email, task);
}
await use(await task);
},
env: async ({ authedPage, user }, use) => {
let task = envByUser.get(user.email);
if (!task) {
task = detectSettingsEnv(authedPage);
envByUser.set(user.email, task);
}
await use(await task);
},
});
export { expect };

View File

@@ -1,124 +0,0 @@
import type { Page } from '@playwright/test';
import { authToken } from './dashboards';
export type Tier =
| 'cloud'
| 'enterprise'
| 'community'
| 'community-enterprise';
export type Role = 'ADMIN' | 'EDITOR' | 'VIEWER' | 'ANONYMOUS';
export interface Persona {
tier: Tier;
role: Role;
}
export interface SettingsEnv {
isGatewayEnabled: boolean;
}
interface AuthzCheckItem {
authorized?: boolean;
object?: { selector?: string };
}
interface FeatureFlag {
name?: string;
active?: boolean;
}
const LICENSE_URL = '/api/v3/licenses/active';
const AUTHZ_CHECK_URL = '/api/v1/authz/check';
const FEATURES_URL = '/api/v1/features';
// Mirrors IsAdmin/Editor/Viewer in frontend/src/hooks/useAuthZ/legacy.ts:
// relation 'assignee' on resource kind/type 'role', selector = preset role id.
const ROLE_PROBES: { role: Exclude<Role, 'ANONYMOUS'>; selector: string }[] = [
{ role: 'ADMIN', selector: 'signoz-admin' },
{ role: 'EDITOR', selector: 'signoz-editor' },
{ role: 'VIEWER', selector: 'signoz-viewer' },
];
function authHeaders(token: string): Record<string, string> {
return { Authorization: `Bearer ${token}` };
}
function parseOverride(): Persona | null {
const raw = process.env.SIGNOZ_E2E_PERSONA;
if (!raw) {
return null;
}
const parts = raw.toLowerCase().split('-');
const roleRaw = parts.pop();
const tier = parts.join('-') as Tier;
const role = roleRaw?.toUpperCase() as Role;
return { tier, role };
}
async function detectTier(page: Page, token: string): Promise<Tier> {
const res = await page.request.get(LICENSE_URL, {
headers: authHeaders(token),
});
if (res.status() === 404) {
return 'community-enterprise';
}
if (res.status() === 501) {
return 'community';
}
const body = await res.json();
const platform = body?.data?.platform;
if (platform === 'CLOUD') {
return 'cloud';
}
return 'enterprise';
}
async function detectRole(page: Page, token: string): Promise<Role> {
const payload = ROLE_PROBES.map((p) => ({
relation: 'assignee',
object: {
resource: { kind: 'role', type: 'role' },
selector: p.selector,
},
}));
const res = await page.request.post(AUTHZ_CHECK_URL, {
headers: authHeaders(token),
data: payload,
});
const body = await res.json();
const items: AuthzCheckItem[] = body?.data ?? [];
const granted = new Set(
items.filter((i) => i?.authorized).map((i) => i?.object?.selector),
);
for (const p of ROLE_PROBES) {
if (granted.has(p.selector)) {
return p.role;
}
}
return 'ANONYMOUS';
}
export async function detectPersona(page: Page): Promise<Persona> {
const override = parseOverride();
if (override) {
return override;
}
const token = await authToken(page);
const [tier, role] = await Promise.all([
detectTier(page, token),
detectRole(page, token),
]);
return { tier, role };
}
export async function detectSettingsEnv(page: Page): Promise<SettingsEnv> {
const token = await authToken(page);
const res = await page.request.get(FEATURES_URL, {
headers: authHeaders(token),
});
const body = await res.json();
const flags: FeatureFlag[] = body?.data ?? [];
const gateway = flags.find((f) => f?.name === 'gateway');
return { isGatewayEnabled: !!gateway?.active };
}

View File

@@ -1,52 +0,0 @@
import type { Page } from '@playwright/test';
import { expect } from '../fixtures/auth';
// Verbatim from frontend/src/constants/routes.ts
export const SETTINGS_ROUTES = {
WORKSPACE: '/settings',
MY_SETTINGS: '/settings/my-settings',
ORG_SETTINGS: '/settings/org-settings',
ALL_CHANNELS: '/settings/channels',
INGESTION: '/settings/ingestion-settings',
BILLING: '/settings/billing',
ROLES: '/settings/roles',
MEMBERS: '/settings/members',
SERVICE_ACCOUNTS: '/settings/service-accounts',
SHORTCUTS: '/settings/shortcuts',
MCP_SERVER: '/settings/mcp-server',
INTEGRATIONS: '/integrations',
} as const;
export type SettingsRoute =
(typeof SETTINGS_ROUTES)[keyof typeof SETTINGS_ROUTES];
// Sidenav item data-testid == itemKey in menuItems.tsx settingsNavSections.
export const NAV_TESTID: Record<string, string> = {
[SETTINGS_ROUTES.WORKSPACE]: 'workspace',
[SETTINGS_ROUTES.MY_SETTINGS]: 'account',
[SETTINGS_ROUTES.ALL_CHANNELS]: 'notification-channels',
[SETTINGS_ROUTES.BILLING]: 'billing',
[SETTINGS_ROUTES.INTEGRATIONS]: 'integrations',
[SETTINGS_ROUTES.MCP_SERVER]: 'mcp-server',
[SETTINGS_ROUTES.ROLES]: 'roles',
[SETTINGS_ROUTES.MEMBERS]: 'members',
[SETTINGS_ROUTES.SERVICE_ACCOUNTS]: 'service-accounts',
[SETTINGS_ROUTES.INGESTION]: 'ingestion',
[SETTINGS_ROUTES.ORG_SETTINGS]: 'sso',
[SETTINGS_ROUTES.SHORTCUTS]: 'keyboard-shortcuts',
};
export async function gotoSettings(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.WORKSPACE);
await expect(page.getByTestId('settings-page-title')).toBeVisible();
}
export async function openSettingsTab(
page: Page,
route: SettingsRoute,
): Promise<void> {
const testid = NAV_TESTID[route];
await page.getByTestId('settings-page-sidenav').getByTestId(testid).click();
await expect(page).toHaveURL(new RegExp(route.replace(/\//g, '\\/')));
}

View File

@@ -1,156 +0,0 @@
import type { Persona, SettingsEnv, Tier } from './persona';
import { SETTINGS_ROUTES, NAV_TESTID } from './settings';
// Mirrors the isEnabled effect in frontend/src/pages/Settings/Settings.tsx.
// Returns the set of sidenav item testids (itemKeys) that should be visible.
export function visibleNavItems(
persona: Persona,
_env: SettingsEnv,
): Set<string> {
const { tier, role } = persona;
const isAdmin = role === 'ADMIN';
const isEditor = role === 'EDITOR';
const isViewer = role === 'VIEWER';
// Defaults that start enabled in menuItems.tsx settingsNavSections.
const s = new Set<string>([
'workspace',
'account',
'notification-channels',
'keyboard-shortcuts',
]);
const enableForAllUsers = (): void => {
s.add('roles');
s.add('service-accounts');
};
if (tier === 'cloud') {
enableForAllUsers();
if (isAdmin) {
[
'billing',
'integrations',
'ingestion',
'sso',
'members',
'mcp-server',
].forEach((k) => s.add(k));
}
if (isEditor) {
['ingestion', 'integrations', 'mcp-server'].forEach((k) => s.add(k));
}
if (isViewer) {
s.add('mcp-server');
}
return s;
}
if (tier === 'enterprise') {
enableForAllUsers();
if (isAdmin) {
[
'billing',
'integrations',
'sso',
'members',
'ingestion',
'mcp-server',
].forEach((k) => s.add(k));
}
if (isEditor) {
['integrations', 'ingestion', 'mcp-server'].forEach((k) => s.add(k));
}
if (isViewer) {
s.add('mcp-server');
}
return s;
}
// community / community-enterprise (!cloud && !enterprise)
enableForAllUsers();
if (isAdmin) {
s.add('sso');
s.add('members');
}
// billing & integrations explicitly disabled for non-cloud users.
s.delete('billing');
s.delete('integrations');
return s;
}
// Mirrors getRoutes() in frontend/src/pages/Settings/utils.ts.
// Returns the set of /settings route paths that are mounted (navigable).
export function registeredRoutes(
persona: Persona,
env: SettingsEnv,
): Set<string> {
const { tier, role } = persona;
const isAdmin = role === 'ADMIN';
const isEditor = role === 'EDITOR';
const isCloud = tier === 'cloud';
const isEnterprise = tier === 'enterprise';
const r = new Set<string>([
SETTINGS_ROUTES.WORKSPACE, // generalSettings — always
SETTINGS_ROUTES.ALL_CHANNELS, // always
SETTINGS_ROUTES.SERVICE_ACCOUNTS, // always
SETTINGS_ROUTES.ROLES, // always
SETTINGS_ROUTES.MY_SETTINGS, // always
SETTINGS_ROUTES.SHORTCUTS, // always
SETTINGS_ROUTES.MCP_SERVER, // always
]);
// organizationSettings — gated by current_org_settings; mirrored as admin-only.
if (isAdmin) {
r.add(SETTINGS_ROUTES.ORG_SETTINGS);
}
// multiIngestionSettings if gateway && (admin||editor); cloud read-only if cloud && !gateway.
if (
(env.isGatewayEnabled && (isAdmin || isEditor)) ||
(isCloud && !env.isGatewayEnabled)
) {
r.add(SETTINGS_ROUTES.INGESTION);
}
// membersSettings if admin.
if (isAdmin) {
r.add(SETTINGS_ROUTES.MEMBERS);
}
// billing if (cloud||enterprise) && admin.
if ((isCloud || isEnterprise) && isAdmin) {
r.add(SETTINGS_ROUTES.BILLING);
}
return r;
}
// Skip reason when a route's nav item is hidden for the persona; null when
// visible. Centralised so every skip reads identically and is greppable.
export function personaSkipReason(
persona: Persona,
env: SettingsEnv,
route: string,
): string | null {
const visible = visibleNavItems(persona, env);
const testid = NAV_TESTID[route];
if (testid && visible.has(testid)) {
return null;
}
return `PERSONA_SKIP: ${route} hidden for ${persona.tier}×${persona.role}`;
}
// Second skip axis: a route is visible but renders tier-specific CONTENT (e.g.
// /settings shows a cloud support card vs self-hosted retention controls).
// Gates a test to the tiers whose content it asserts. Shares the PERSONA_SKIP:
// prefix.
export function tierSkipReason(
persona: Persona,
allowedTiers: Tier[],
label: string,
): string | null {
if (allowedTiers.includes(persona.tier)) {
return null;
}
return `PERSONA_SKIP: ${label} not applicable for tier ${persona.tier} (needs ${allowedTiers.join(
'|',
)})`;
}

View File

@@ -1,151 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import {
personaSkipReason,
tierSkipReason,
} from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Workspace (/settings) has two views: cloud (retention inputs disabled, no Save,
// GeneralSettingsCloud support card) and self-hosted (interactive inputs, per-row Save).
// Retention inputs in compact mode have no data-testid — role/text/CSS fallback.
async function gotoWorkspace(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.WORKSPACE);
// Retention data is fetched server-side; allow margin for the API response.
await expect(page.locator('.retention-controls-container')).toBeVisible({
timeout: 15_000,
});
}
function retentionRow(page: Page, signal: string) {
return page.locator('.retention-row').filter({ hasText: signal });
}
function retentionInput(page: Page, signal: string) {
return retentionRow(page, signal).locator('input[type="number"]').first();
}
function saveButton(page: Page, signal: string) {
return retentionRow(page, signal).getByRole('button', { name: /^save$/i });
}
// Tier sets for the two Workspace content variants.
const CLOUD_TIERS = ['cloud'] as const;
const SELF_HOSTED_TIERS = [
'enterprise',
'community',
'community-enterprise',
] as const;
test.describe('Settings — Workspace / General page', () => {
test('TC-01 page renders retention controls and license-key row', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
);
await gotoWorkspace(page);
// Scoped to avoid strict-mode conflict with the sidenav item.
await expect(page.locator('.general-settings-title')).toContainText(
'Workspace',
);
await expect(page.locator('.general-settings-subtitle')).toContainText(
'Manage your workspace settings.',
);
await expect(page.getByText('Retention Controls')).toBeVisible();
await expect(retentionRow(page, 'Metrics')).toBeVisible();
await expect(retentionRow(page, 'Traces')).toBeVisible();
await expect(retentionRow(page, 'Logs')).toBeVisible();
await expect(retentionInput(page, 'Metrics')).toBeVisible();
await expect(retentionInput(page, 'Traces')).toBeVisible();
await expect(retentionInput(page, 'Logs')).toBeVisible();
await expect(page.getByTestId('license-key-row-copy-btn')).toBeVisible();
});
// RISK MODE: read-only — only asserts disabled state, nothing is mutated.
test('TC-02 cloud view — retention inputs are disabled and support card is visible', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
);
test.skip(
!!tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view'),
tierSkipReason(persona, [...CLOUD_TIERS], 'cloud retention view') ??
undefined,
);
await gotoWorkspace(page);
await expect(retentionInput(page, 'Metrics')).toBeDisabled();
await expect(retentionInput(page, 'Traces')).toBeDisabled();
await expect(retentionInput(page, 'Logs')).toBeDisabled();
await expect(saveButton(page, 'Metrics')).toHaveCount(0);
await expect(saveButton(page, 'Traces')).toHaveCount(0);
await expect(saveButton(page, 'Logs')).toHaveCount(0);
await expect(
page.getByText(/please.*email us.*or connect.*via chat support/i),
).toBeVisible();
});
// RISK MODE: never clicks Save — only asserts enable-on-change / disable-on-clear; no PUT/POST.
test('TC-03 self-hosted view — retention input enables/disables Save — no save triggered', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE),
personaSkipReason(persona, env, SETTINGS_ROUTES.WORKSPACE) ?? undefined,
);
test.skip(
!!tierSkipReason(
persona,
[...SELF_HOSTED_TIERS],
'self-hosted retention controls',
),
tierSkipReason(
persona,
[...SELF_HOSTED_TIERS],
'self-hosted retention controls',
) ?? undefined,
);
await gotoWorkspace(page);
const metricsInput = retentionInput(page, 'Metrics');
const metricsSaveBtn = saveButton(page, 'Metrics');
const originalValue = await metricsInput.inputValue();
try {
await metricsInput.fill('9999');
await expect(metricsSaveBtn).toBeEnabled();
await metricsInput.fill('');
await expect(metricsSaveBtn).toBeDisabled();
await expect(
page.getByText(/retention period for .+ is not set yet/i),
).toBeVisible();
} finally {
// Restore so unsaved UI state does not leak to other workers sharing this stack.
await metricsInput.fill(originalValue);
}
});
});

View File

@@ -1,117 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import {
personaSkipReason,
tierSkipReason,
} from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Ingestion page, two variants gated by env.isGatewayEnabled / tier:
// MultiIngestionSettings (gateway ON) vs read-only IngestionSettings (cloud, gateway OFF).
// RISK MODE — READ-ONLY: never create/edit/delete keys or rate limits; create
// button and copy affordances asserted for presence only, never clicked.
// Each TC guards its variant via test.skip so bodies stay branch-free
// (playwright/no-conditional-in-test).
test.describe.configure({ mode: 'serial' });
async function gotoIngestion(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.INGESTION);
// Ingestion keys/settings are fetched server-side; allow margin for the API response.
await expect(
page
.locator('.ingestion-key-container, .ingestion-settings-container')
.first(),
).toBeVisible({ timeout: 15_000 });
}
test.describe('Settings — Ingestion page', () => {
test('TC-01 MultiIngestionSettings — page chrome, search, table, and create affordance render', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
);
test.skip(
!!tierSkipReason(
persona,
['cloud', 'enterprise'],
'MultiIngestionSettings (gateway)',
) || !env.isGatewayEnabled,
!env.isGatewayEnabled
? 'PERSONA_SKIP: gateway feature flag is OFF — MultiIngestionSettings does not render'
: (tierSkipReason(
persona,
['cloud', 'enterprise'],
'MultiIngestionSettings (gateway)',
) ?? undefined),
);
await gotoIngestion(page);
const container = page.locator('.ingestion-key-container');
await expect(container).toBeVisible();
// Exact name match avoids the subtitle partial match.
await expect(
container.getByRole('heading', { name: 'Ingestion Keys' }),
).toBeVisible();
await expect(
container.getByText(/Create and manage ingestion keys/i),
).toBeVisible();
await expect(
container.getByPlaceholder('Search for ingestion key...'),
).toBeVisible();
await expect(
container.getByRole('button', { name: /new ingestion key/i }),
).toBeVisible();
await expect(container.locator('.ingestion-keys-table')).toBeVisible();
await expect(
container.locator('.ingestion-key-url-label', { hasText: 'Ingestion URL' }),
).toBeVisible();
});
test('TC-02 IngestionSettings (read-only) — table rows for URL, key, and region render', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION),
personaSkipReason(persona, env, SETTINGS_ROUTES.INGESTION) ?? undefined,
);
// This view only renders on cloud when gateway is disabled
test.skip(
env.isGatewayEnabled,
'PERSONA_SKIP: gateway is ON — MultiIngestionSettings renders instead of read-only table',
);
test.skip(
!!tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table'),
tierSkipReason(persona, ['cloud'], 'IngestionSettings read-only table') ??
undefined,
);
await gotoIngestion(page);
const container = page.locator('.ingestion-settings-container');
await expect(container).toBeVisible();
await expect(
container.getByText(/start sending your telemetry data/i),
).toBeVisible();
const table = container.locator('.ant-table');
await expect(table).toBeVisible();
await expect(table.getByText('Ingestion URL')).toBeVisible();
await expect(table.getByText('Ingestion Key')).toBeVisible();
await expect(table.getByText('Ingestion Region')).toBeVisible();
});
});

View File

@@ -1,153 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import { authToken } from '../../helpers/dashboards';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// MCP Server settings, two variants gated by mcp_url in /api/v1/global/config:
// full page (mcp_url present, cloud) vs NotCloudFallback (absent, community/self-hosted).
// RISK MODE — READ-ONLY: never create a service account; copy/create/install
// buttons asserted for presence only, never clicked.
// mcpEndpointPresent is probed in beforeAll (real backend state) so TC-01/TC-02
// skip via test.skip rather than branching in bodies (playwright/no-conditional-in-test).
test.describe.configure({ mode: 'serial' });
let mcpEndpointPresent = false;
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
const res = await page.request.get('/api/v1/global/config', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok()) {
const body = await res.json();
const mcpUrl: unknown = body?.data?.mcp_url;
mcpEndpointPresent = typeof mcpUrl === 'string' && mcpUrl.length > 0;
}
} finally {
await ctx.close();
}
});
async function gotoMcpServer(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.MCP_SERVER);
// Spinner gone => either full page or fallback has rendered.
await expect(page.locator('.ant-spin-spinning')).toHaveCount(0);
}
test.describe('Settings — MCP Server page', () => {
// Locators below use CSS classes / role-text; only mcp-settings has a data-testid.
test('TC-01 full page renders: header, client tabs, auth card, use-cases card', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
);
// Full-page content requires mcp_url to be configured. If not present the
// NotCloudFallback renders instead — TC-02 covers that path.
test.skip(
!mcpEndpointPresent,
'PERSONA_SKIP: mcp_url not configured on this stack — NotCloudFallback renders; see TC-02',
);
await gotoMcpServer(page);
await expect(page.getByTestId('mcp-settings')).toBeVisible();
await expect(page.locator('.mcp-settings__header-title')).toContainText(
'SigNoz MCP Server',
);
await expect(page.locator('.mcp-settings__header-subtitle')).toContainText(
'Model Context Protocol',
);
await expect(page.locator('.mcp-settings__card')).toBeVisible();
await expect(page.locator('.mcp-settings__card-title')).toContainText(
'Configure your client',
);
const tabsRoot = page.locator('.mcp-client-tabs-root');
await expect(tabsRoot).toBeVisible();
await expect(tabsRoot.getByRole('tab', { name: /cursor/i })).toBeVisible();
await expect(
tabsRoot.getByRole('tab', { name: /claude code/i }),
).toBeVisible();
await expect(tabsRoot.getByRole('tab', { name: /vs code/i })).toBeVisible();
await expect(
page.locator('.mcp-client-tabs__snippet-pre').first(),
).toBeVisible();
await expect(
page.getByRole('button', { name: /copy cursor config/i }),
).toBeVisible();
const authCard = page.locator('.mcp-auth-card');
await expect(authCard).toBeVisible();
await expect(authCard.locator('.mcp-auth-card__title')).toContainText(
'Authenticate from your client',
);
await expect(
authCard.locator('.mcp-auth-card__field-label').first(),
).toContainText('SigNoz Instance URL');
await expect(
authCard.getByRole('button', { name: /copy signoz instance url/i }),
).toBeVisible();
await expect(
authCard.locator('.mcp-auth-card__field-label').nth(1),
).toContainText('API Key');
await expect(
authCard.getByRole('button', { name: /create service account/i }),
).toBeVisible();
const useCasesCard = page.locator('.mcp-use-cases-card');
await expect(useCasesCard).toBeVisible();
await expect(
useCasesCard.locator('.mcp-use-cases-card__title'),
).toContainText('What you can do with it');
await expect(useCasesCard.locator('.mcp-use-cases-card__list')).toBeVisible();
await expect(
useCasesCard.getByRole('button', { name: /see more use cases/i }),
).toBeVisible();
});
// Skipped when the beforeAll probe found mcp_url — full page renders instead.
test('TC-02 NotCloudFallback renders when MCP endpoint is not configured', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER),
personaSkipReason(persona, env, SETTINGS_ROUTES.MCP_SERVER) ?? undefined,
);
test.skip(
mcpEndpointPresent,
'PERSONA_SKIP: mcp_url is configured on this stack — NotCloudFallback does not render',
);
await gotoMcpServer(page);
await expect(page.locator('.not-cloud-fallback')).toBeVisible();
await expect(page.locator('.not-cloud-fallback__title')).toContainText(
'MCP Server is available on SigNoz',
);
await expect(
page.getByRole('button', { name: /view mcp server docs/i }),
).toBeVisible();
await expect(page.getByTestId('mcp-settings')).toHaveCount(0);
});
});

View File

@@ -1,205 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// RISK MODE: read-only plus one non-submitting invite-modal check — no member is
// created/edited/deleted/role-changed. The fresh bootstrap stack has exactly one
// member (seeded admin, active), so filter/search coverage is limited to that row.
// No data-testid exists in MembersSettings/Table/InviteModal — role/placeholder/text/CSS fallback.
test.describe.configure({ mode: 'serial' });
const ADMIN_EMAIL = process.env.SIGNOZ_E2E_USERNAME ?? 'admin@integration.test';
const SEARCH_PLACEHOLDER = 'Search by name or email...';
async function gotoMembers(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.MEMBERS);
// Members list is fetched server-side; allow margin for the API response.
await expect(page.locator('.members-table-wrapper')).toBeVisible({
timeout: 15_000,
});
}
test.describe('Settings — Members page', () => {
test('TC-01 list renders with columns and the bootstrap admin user row', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
);
await gotoMembers(page);
await expect(
page.getByRole('heading', { name: 'Members', level: 1 }),
).toBeVisible();
await expect(
page.getByText('Overview of people added to this workspace.'),
).toBeVisible();
await expect(page.locator('.members-filter-trigger')).toBeVisible();
await expect(page.getByPlaceholder(SEARCH_PLACEHOLDER)).toBeVisible();
await expect(
page.getByRole('button', { name: /invite member/i }),
).toBeVisible();
const table = page.locator('.members-table');
await expect(
table.getByRole('columnheader', { name: 'Name / Email' }),
).toBeVisible();
await expect(
table.getByRole('columnheader', { name: 'Status' }),
).toBeVisible();
await expect(
table.getByRole('columnheader', { name: 'Joined On' }),
).toBeVisible();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
const adminRow = page
.locator('tr')
.filter({ has: page.locator('.member-email', { hasText: ADMIN_EMAIL }) });
await expect(adminRow.getByText('ACTIVE')).toBeVisible();
});
// On the single-member stack, Pending/Deleted both yield the empty state.
test('TC-02 filter dropdown — cycles All / Pending / Deleted and updates the list', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
);
await gotoMembers(page);
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
await page.locator('.members-filter-trigger').click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByText(/pending invites/i).click();
await expect(page.locator('.members-empty-state')).toBeVisible();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toHaveCount(0);
await page.locator('.members-filter-trigger').click();
await expect(page.getByRole('menu')).toBeVisible();
await page
.getByRole('menu')
.getByText(/^deleted/i)
.click();
await expect(page.locator('.members-empty-state')).toBeVisible();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toHaveCount(0);
await page.locator('.members-filter-trigger').click();
await expect(page.getByRole('menu')).toBeVisible();
await page
.getByRole('menu')
.getByText(/all members/i)
.click();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
await expect(page.locator('.members-empty-state')).toHaveCount(0);
});
test('TC-03 search filters by email match and shows empty state on no match', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
);
await gotoMembers(page);
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await searchInput.fill(ADMIN_EMAIL);
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
await expect(page.locator('.members-empty-state')).toHaveCount(0);
await searchInput.fill('xyznonexistentuser999@nowhere.invalid');
await expect(page.locator('.members-empty-state')).toBeVisible();
await expect(
page
.locator('.members-empty-state__text')
.getByText('xyznonexistentuser999@nowhere.invalid'),
).toBeVisible();
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toHaveCount(0);
await searchInput.fill('');
await expect(
page.locator('.member-email', { hasText: ADMIN_EMAIL }),
).toBeVisible();
await expect(page.locator('.members-empty-state')).toHaveCount(0);
});
// RISK MODE: submit is never clicked; no invite is sent.
test('TC-04 invite modal — renders correctly, submit disabled on untouched rows, Cancel dismisses', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MEMBERS) ?? undefined,
);
await gotoMembers(page);
await page.getByRole('button', { name: /invite member/i }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(
modal.getByRole('heading', { name: 'Invite Team Members' }),
).toBeVisible();
// Header cells scoped to class selectors to avoid matching input placeholders.
await expect(modal.locator('.email-header')).toBeVisible();
await expect(modal.locator('.role-header')).toBeVisible();
// Modal starts with 3 empty rows.
const emailInputs = modal.locator('input[type="email"]');
await expect(emailInputs.first()).toBeVisible();
await expect(emailInputs).toHaveCount(3);
await expect(
modal.getByRole('button', { name: /add another/i }),
).toBeVisible();
// Submit is disabled while all rows are untouched.
const submitBtn = modal.getByRole('button', { name: 'Invite Team Members' });
await expect(submitBtn).toBeVisible();
await expect(submitBtn).toBeDisabled();
await modal.getByRole('button', { name: /cancel/i }).click();
await expect(modal).not.toBeVisible();
});
});

View File

@@ -1,262 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { authToken } from '../../helpers/dashboards';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
test.describe.configure({ mode: 'serial' });
// Runtime branching lives in these helpers, not test() bodies — playwright/no-conditional-in-test.
async function gotoMySettings(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.MY_SETTINGS);
await expect(page.getByTestId('theme-selector')).toBeVisible();
}
async function readThemeState(
page: Page,
): Promise<{ theme: string; autoSwitch: string }> {
// globalThis cast: the evaluate callback runs in the browser, but the e2e
// tsconfig uses the ES2020 lib (no DOM), so `localStorage` isn't typed here.
return page.evaluate(() => ({
theme: (globalThis as any).localStorage.getItem('THEME') ?? 'dark',
autoSwitch:
(globalThis as any).localStorage.getItem('THEME_AUTO_SWITCH') ?? 'false',
}));
}
async function restoreTheme(
page: Page,
theme: string,
autoSwitch: string,
): Promise<void> {
await page.evaluate(
([t, a]) => {
(globalThis as any).localStorage.setItem('THEME', t);
(globalThis as any).localStorage.setItem('THEME_AUTO_SWITCH', a);
},
[theme, autoSwitch],
);
}
async function restoreSideNavPinned(
page: Page,
originalChecked: string,
): Promise<void> {
const token = await authToken(page);
await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: originalChecked === 'true' },
headers: { Authorization: `Bearer ${token}` },
});
}
function flipAriaChecked(current: string): string {
if (current === 'true') {
return 'false';
}
return 'true';
}
test.describe('My Settings — Account page', () => {
test('TC-01 page renders with all expected controls', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
await expect(
page.getByRole('button', { name: /update name/i }),
).toBeVisible();
await expect(
page.getByRole('button', { name: /reset password/i }).first(),
).toBeVisible();
await expect(page.getByTestId('theme-selector')).toBeVisible();
await expect(page.getByTestId('timezone-adaptation-switch')).toBeVisible();
await expect(page.getByTestId('side-nav-pinned-switch')).toBeVisible();
// License copy button renders because bootstrap issues an enterprise license on cloud.
await expect(page.getByTestId('license-key-copy-btn')).toBeVisible();
});
test('TC-02 theme toggle cycles dark → light → auto and applies', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
const originalTheme = await readThemeState(page);
try {
// Radix ToggleGroup renders items as role="radio" within a radiogroup.
const selector = page.getByTestId('theme-selector');
const darkRadio = selector.getByRole('radio', { name: /dark/i });
const lightRadio = selector.getByRole('radio', { name: /light/i });
const systemRadio = selector.getByRole('radio', { name: /system/i });
await lightRadio.click();
await expect(lightRadio).toBeChecked();
await systemRadio.click();
await expect(systemRadio).toBeChecked();
await darkRadio.click();
await expect(darkRadio).toBeChecked();
} finally {
await restoreTheme(page, originalTheme.theme, originalTheme.autoSwitch);
}
});
test('TC-03 sidebar pin toggle flips checked state', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
const switchEl = page.getByTestId('side-nav-pinned-switch');
const originalChecked =
(await switchEl.getAttribute('aria-checked')) ?? 'false';
const expectedAfterToggle = flipAriaChecked(originalChecked);
try {
await switchEl.click();
// Pin state persists server-side; allow margin for the update under
// parallel-worker CPU contention (default 5s expect timeout flakes).
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
timeout: 15_000,
});
} finally {
await restoreSideNavPinned(page, originalChecked);
}
});
test('TC-04 timezone adaptation toggle flips checked state', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
const switchEl = page.getByTestId('timezone-adaptation-switch');
const originalChecked =
(await switchEl.getAttribute('aria-checked')) ?? 'true';
const expectedAfterToggle = flipAriaChecked(originalChecked);
try {
await switchEl.click();
await expect(switchEl).toHaveAttribute('aria-checked', expectedAfterToggle, {
timeout: 15_000,
});
} finally {
// isAdaptationEnabled is not persisted — toggle back to restore session state.
await switchEl.click();
}
});
// note: PUT /api/v2/users/me returns root_user_operation_unsupported for the
// bootstrap admin user. Only the modal open/input/submit-button UI is tested
// here; the "name reflects in card after save" assertion cannot be verified
// against this stack.
test('TC-05 update name modal — opens, pre-fills, submit button active', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
const currentName = await page.locator('.user-name').first().innerText();
await page.getByRole('button', { name: /update name/i }).click();
const nameInput = page.getByPlaceholder('e.g. John Doe');
await expect(nameInput).toBeVisible();
await expect(nameInput).toHaveValue(currentName);
const submitBtn = page.getByTestId('update-name-btn');
await expect(submitBtn).toBeVisible();
await expect(submitBtn).toBeEnabled();
// Close via × button — Ant Modal's Escape handler can race with input focus in headless mode.
await page
.locator('.update-name-modal')
.getByRole('button', { name: 'Close' })
.click();
await expect(nameInput).not.toBeVisible();
});
test('TC-06 reset-password modal — validation only, never submits', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.MY_SETTINGS) ?? undefined,
);
await gotoMySettings(page);
// The button that OPENS the modal has no testid; reset-password-btn is the SUBMIT button inside.
await page
.getByRole('button', { name: /reset password/i })
.first()
.click();
const currentPasswordInput = page.getByTestId('current-password-textbox');
const newPasswordInput = page.getByTestId('new-password-textbox');
const submitBtn = page.getByTestId('reset-password-btn');
await expect(currentPasswordInput).toBeVisible();
await expect(newPasswordInput).toBeVisible();
await expect(submitBtn).toBeDisabled();
await currentPasswordInput.fill('somepassword');
await expect(submitBtn).toBeDisabled();
// Same value → passwords match → validation error + disabled
await newPasswordInput.fill('somepassword');
await expect(page.getByText(/new password must be different/i)).toBeVisible();
await expect(submitBtn).toBeDisabled();
// Stop at enabled — clicking would rotate the admin password and break every other worker.
await newPasswordInput.fill('differentpassword!1');
await expect(submitBtn).toBeEnabled();
await page
.locator('.reset-password-modal')
.getByRole('button', { name: 'Close' })
.click();
await expect(currentPasswordInput).not.toBeVisible();
});
});

View File

@@ -1,106 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// OrganizationSettings (/settings/org-settings): DisplayName form + AuthDomain section.
// Invite coverage lives in members.spec.ts — the #invite-team-members hash is ignored here.
//
// note: PUT /api/v2/orgs returns root_user_operation_unsupported for the bootstrap
// admin user. TC-02 only asserts the field is editable and the Submit button enables;
// it does NOT submit the form. The original org name is never mutated.
test.describe.configure({ mode: 'serial' });
async function gotoOrgSettings(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.ORG_SETTINGS);
await expect(page.getByLabel('Display name')).toBeVisible();
}
test.describe('Organization Settings — SSO & Org page', () => {
test('TC-01 page renders display-name field and authenticated-domains section', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
);
await gotoOrgSettings(page);
await expect(page.getByLabel('Display name')).toBeVisible();
await expect(page.getByRole('button', { name: 'Submit' })).toBeVisible();
await expect(
page.getByRole('heading', { name: 'Authenticated Domains' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Add Domain' })).toBeVisible();
});
// note: root_user_operation_unsupported on save (see header) — never clicks Submit; value restored in finally.
test('TC-02 org display name — field is editable and Submit enables on change', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
);
await gotoOrgSettings(page);
const nameInput = page.getByLabel('Display name');
const submitBtn = page.getByRole('button', { name: 'Submit' });
const originalValue = await nameInput.inputValue();
try {
// Submit is disabled when the value equals the current saved name.
await expect(submitBtn).toBeDisabled();
await nameInput.fill('org-sso-spec-temp');
await expect(nameInput).toHaveValue('org-sso-spec-temp');
await expect(submitBtn).toBeEnabled();
await nameInput.fill('');
await expect(submitBtn).toBeDisabled();
} finally {
// Restored value equals the saved one, so Submit stays disabled — no API call.
await nameInput.fill(originalValue);
await expect(submitBtn).toBeDisabled();
}
});
// RISK MODE: never enable SSO/SAML or click Save — that changes auth for the whole stack.
test('TC-03 SSO config — Add Domain opens provider-selector modal, close dismisses it', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS),
personaSkipReason(persona, env, SETTINGS_ROUTES.ORG_SETTINGS) ?? undefined,
);
await gotoOrgSettings(page);
await page.getByRole('button', { name: 'Add Domain' }).click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
await expect(
modal.getByText('Configure Authentication Method'),
).toBeVisible();
await expect(modal.getByText('Google Apps Authentication')).toBeVisible();
// SAML/OIDC visibility depends on the SSO flag — only assert Google Auth, always enabled.
await modal.getByRole('button', { name: /close/i }).click();
await expect(modal).not.toBeVisible();
});
});

View File

@@ -1,172 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import { authToken } from '../../helpers/dashboards';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Roles page. RISK MODE — READ-ONLY: never create/edit/delete a role; TC-03
// only views a managed role's detail page and navigates back.
// rolesEnabled probes /api/v1/features for USE_FINE_GRAINED_AUTHZ — real backend
// state, not a guess; row navigation is only wired up when it is on, so TC-03 skips otherwise.
test.describe.configure({ mode: 'serial' });
let rolesEnabled = false;
async function gotoRolesList(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.ROLES);
await expect(page.getByTestId('roles-settings')).toBeVisible();
}
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
const res = await page.request.get('/api/v1/features', {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
const flag = flags.find((f) => f?.name === 'use_fine_grained_authz');
rolesEnabled = !!flag?.active;
} finally {
await ctx.close();
}
});
test.describe('Settings — Roles page', () => {
test('TC-01 list renders with container, header, search, and managed-role rows', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
);
await gotoRolesList(page);
await expect(page.locator('.roles-settings-header-title')).toContainText(
'Roles',
);
await expect(
page.locator('.roles-settings-header-description'),
).toContainText('Create and manage custom roles for your team.');
await expect(page.locator('input[type="search"]')).toBeVisible();
await expect(
page.locator('input[placeholder="Search for roles..."]'),
).toBeVisible();
const table = page.locator('.roles-listing-table');
await expect(table).toBeVisible();
await expect(table.locator('.roles-table-header-cell--name')).toContainText(
'Name',
);
await expect(
table.locator('.roles-table-header-cell--description'),
).toContainText('Description');
await expect(
table.locator('.roles-table-header-cell--updated-at'),
).toContainText('Updated At');
await expect(
table.locator('.roles-table-header-cell--created-at'),
).toContainText('Created At');
await expect(
table.locator('.roles-table-section-header', { hasText: 'Managed roles' }),
).toBeVisible();
await expect(table.locator('.roles-table-row').first()).toBeVisible();
});
test('TC-02 search filters roles by match and shows empty state on no match', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
);
await gotoRolesList(page);
const searchInput = page.locator('input[placeholder="Search for roles..."]');
const table = page.locator('.roles-listing-table');
await searchInput.fill('Admin');
await expect(
table.locator('.roles-table-cell--name', { hasText: /admin/i }).first(),
).toBeVisible();
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
await searchInput.fill('xyznonexistentrole999');
await expect(table.locator('.roles-table-empty')).toBeVisible();
await expect(table.locator('.roles-table-empty')).toContainText(
'No roles match your search.',
);
await expect(table.locator('.roles-table-row')).toHaveCount(0);
await searchInput.fill('');
await expect(table.locator('.roles-table-row').first()).toBeVisible();
await expect(table.locator('.roles-table-empty')).toHaveCount(0);
});
// Read-only: views a managed role, asserts no edit/delete, navigates back.
// Skipped when USE_FINE_GRAINED_AUTHZ is off — rows have no click handler.
test('TC-03 role detail page — clicking a managed role navigates to its detail view', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES),
personaSkipReason(persona, env, SETTINGS_ROUTES.ROLES) ?? undefined,
);
test.skip(
!rolesEnabled,
'PERSONA_SKIP: USE_FINE_GRAINED_AUTHZ feature flag is off — role rows are not clickable',
);
await gotoRolesList(page);
const table = page.locator('.roles-listing-table');
const firstRow = table.locator('.roles-table-row').first();
await firstRow.scrollIntoViewIfNeeded();
await firstRow.click();
await expect(page).toHaveURL(/\/settings\/roles\/[^/]+/);
const detailPage = page.locator('.role-details-page');
await expect(detailPage).toBeVisible();
await expect(detailPage.locator('.role-details-title')).toBeVisible();
await expect(detailPage.locator('.role-details-title')).toContainText(
'Role —',
);
await expect(
detailPage.getByText(
'This is a managed role. Permissions and settings are view-only and cannot be modified.',
),
).toBeVisible();
await expect(
detailPage.getByRole('button', { name: 'Edit Role Details' }),
).toHaveCount(0);
await expect(
detailPage.locator('.role-details-section-label', {
hasText: 'Permissions',
}),
).toBeVisible();
await page.goto(SETTINGS_ROUTES.ROLES);
await expect(page.getByTestId('roles-settings')).toBeVisible();
});
});

View File

@@ -1,191 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import { authToken } from '../../helpers/dashboards';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Service Accounts page. RISK MODE — READ-ONLY: never create/edit/delete an
// account or generate a token; the create modal is never opened.
// listAccessible probes the real authz/check backend state in beforeAll (when
// use_fine_grained_authz is on the admin may lack serviceaccount:list, rendering
// PermissionDeniedFullPage); the functional TCs skip when it is false.
test.describe.configure({ mode: 'serial' });
let listAccessible = false;
async function gotoServiceAccounts(page: Page): Promise<void> {
await page.goto(SETTINGS_ROUTES.SERVICE_ACCOUNTS);
await expect(page.locator('.sa-settings__title')).toBeVisible();
}
function buildSkipReason(
persona: Parameters<typeof personaSkipReason>[0],
env: Parameters<typeof personaSkipReason>[1],
): string | null {
return personaSkipReason(persona, env, SETTINGS_ROUTES.SERVICE_ACCOUNTS);
}
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
const res = await page.request.get('/api/v1/features', {
headers: { Authorization: `Bearer ${token}` },
});
const body = await res.json();
const flags: { name?: string; active?: boolean }[] = body?.data ?? [];
const fgAuthz = flags.find((f) => f?.name === 'use_fine_grained_authz');
if (!fgAuthz?.active) {
// Without fine-grained authz the SA list is always accessible.
listAccessible = true;
return;
}
// Probe the authz check endpoint for serviceaccount:list (wildcard).
const authzRes = await page.request.post('/api/v1/authz/check', {
headers: { Authorization: `Bearer ${token}` },
data: [
{
relation: 'list',
object: {
resource: { kind: 'serviceaccount', type: 'serviceaccount' },
selector: '*',
},
},
],
});
const authzBody = await authzRes.json();
const items: { authorized?: boolean }[] = authzBody?.data ?? [];
listAccessible = items.some((i) => i?.authorized);
} finally {
await ctx.close();
}
});
test.describe('Settings — Service Accounts page', () => {
test('TC-01 page chrome and empty-state render', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!buildSkipReason(persona, env),
buildSkipReason(persona, env) ?? undefined,
);
test.skip(
!listAccessible,
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
);
await gotoServiceAccounts(page);
await expect(page.locator('.sa-settings__title')).toContainText(
'Service Accounts',
);
await expect(page.locator('.sa-settings__subtitle')).toContainText(
'Overview of service accounts added to this workspace.',
);
await expect(
page.locator('.sa-settings__subtitle a[href*="signoz.io/docs"]'),
).toBeVisible();
const controls = page.locator('.sa-settings__controls');
await expect(controls).toBeVisible();
await expect(
controls.getByRole('button', { name: /All accounts/i }),
).toBeVisible();
await expect(
controls.locator('input[placeholder="Search by name or email..."]'),
).toBeVisible();
await expect(
controls.getByRole('button', { name: /New Service Account/i }),
).toBeVisible();
await expect(page.locator('.sa-table-wrapper')).toBeVisible();
await expect(page.locator('.sa-empty-state')).toBeVisible();
await expect(page.locator('.sa-empty-state__text')).toContainText(
'No service accounts.',
);
});
test('TC-02 filter dropdown writes URL param and shows empty-state per mode', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!buildSkipReason(persona, env),
buildSkipReason(persona, env) ?? undefined,
);
test.skip(
!listAccessible,
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
);
await gotoServiceAccounts(page);
const filterTrigger = page.getByRole('button', { name: /All accounts/i });
await filterTrigger.click();
await page.getByText(/^Active ⎯/).click();
await expect(page).toHaveURL(/[?&]filter=active/);
await expect(page.locator('.sa-empty-state')).toBeVisible();
await page.getByRole('button', { name: /Active ⎯/i }).click();
await page.getByText(/^Deleted ⎯/).click();
await expect(page).toHaveURL(/[?&]filter=deleted/);
await expect(page.locator('.sa-empty-state')).toBeVisible();
await page.getByRole('button', { name: /Deleted ⎯/i }).click();
await page.getByText(/^All accounts ⎯/).click();
await expect(page).not.toHaveURL(/[?&]filter=active/);
await expect(page).not.toHaveURL(/[?&]filter=deleted/);
await expect(page.locator('.sa-empty-state')).toBeVisible();
});
test('TC-03 search updates URL and empty-state; create button enabled', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!buildSkipReason(persona, env),
buildSkipReason(persona, env) ?? undefined,
);
test.skip(
!listAccessible,
'PERSONA_SKIP: serviceaccount:list permission not granted for this persona — PermissionDeniedFullPage renders instead',
);
await gotoServiceAccounts(page);
const searchInput = page.locator(
'input[placeholder="Search by name or email..."]',
);
await searchInput.fill('xyznonexistent999');
await expect(page).toHaveURL(/[?&]search=xyznonexistent999/);
await expect(page.locator('.sa-empty-state__text')).toContainText(
'No results for',
);
await expect(page.locator('.sa-empty-state__text strong')).toContainText(
'xyznonexistent999',
);
await searchInput.fill('');
await expect(page).not.toHaveURL(/[?&]search=xyznonexistent999/);
await expect(page.locator('.sa-empty-state__text')).toContainText(
'No service accounts.',
);
const createBtn = page.getByRole('button', { name: /New Service Account/i });
await expect(createBtn).toBeVisible();
await expect(createBtn).toBeEnabled();
});
});

View File

@@ -1,125 +0,0 @@
import type { Persona, SettingsEnv } from '../../helpers/persona';
import { expect, test } from '../../fixtures/auth';
import {
registeredRoutes,
visibleNavItems,
} from '../../helpers/settingsAccess';
import {
NAV_TESTID,
SETTINGS_ROUTES,
gotoSettings,
} from '../../helpers/settings';
// Branching lives in module-level helpers, not test bodies — the repo's
// playwright/no-conditional-in-test rule forbids `if` inside `test()`.
function partitionNavTestids(
persona: Persona,
env: SettingsEnv,
): { visible: string[]; hidden: string[] } {
const all = Object.values(NAV_TESTID);
const expected = visibleNavItems(persona, env);
return {
visible: all.filter((testid) => expected.has(testid)),
hidden: all.filter((testid) => !expected.has(testid)),
};
}
// Visible nav items whose /settings route is not registered (mounted).
// INTEGRATIONS is excluded — it is a top-level page, not a RouteTab route.
function navRouteMismatches(persona: Persona, env: SettingsEnv): string[] {
const visible = visibleNavItems(persona, env);
const registered = registeredRoutes(persona, env);
const routeByTestid = Object.fromEntries(
Object.entries(NAV_TESTID).map(([route, testid]) => [testid, route]),
);
return [...visible]
.map((testid) => routeByTestid[testid])
.filter((route) => !!route && route !== SETTINGS_ROUTES.INTEGRATIONS)
.filter((route) => !registered.has(route))
.map((route) => `${route} is nav-visible but route not registered`);
}
test.describe('Settings — shell, gating matrix & integrity', () => {
test('TC-01 settings shell chrome renders with no JS pageerror', async ({
authedPage: page,
}) => {
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
await gotoSettings(page);
await expect(page.getByTestId('settings-page-title')).toBeVisible();
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
expect(errors, errors.map((e) => e.message).join('\n')).toHaveLength(0);
});
test('TC-02 sidenav shows exactly the matrix-predicted items', async ({
authedPage: page,
persona,
env,
}) => {
await gotoSettings(page);
const sidenav = page.getByTestId('settings-page-sidenav');
const { visible, hidden } = partitionNavTestids(persona, env);
for (const testid of visible) {
await expect(
sidenav.getByTestId(testid),
`${testid} should be visible`,
).toBeVisible();
}
for (const testid of hidden) {
await expect(
sidenav.getByTestId(testid),
`${testid} should be hidden`,
).toHaveCount(0);
}
});
test('TC-03 every registered route deep-links with no JS pageerror', async ({
authedPage: page,
persona,
env,
}) => {
const routes = [...registeredRoutes(persona, env)];
for (const route of routes) {
const errors: Error[] = [];
const onError = (err: Error): void => {
errors.push(err);
};
page.on('pageerror', onError);
await page.goto(route);
await expect(page.getByTestId('settings-page-title')).toBeVisible();
page.off('pageerror', onError);
expect(
errors,
`pageerror on ${route}: ${errors.map((e) => e.message).join('\n')}`,
).toHaveLength(0);
}
});
test('TC-04 every visible nav item resolves to a registered route', async ({
persona,
env,
}) => {
const mismatches = navRouteMismatches(persona, env);
expect(mismatches, mismatches.join('\n')).toHaveLength(0);
});
test('TC-05 clicking a nav item navigates and marks active', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!visibleNavItems(persona, env).has('account'),
'PERSONA_SKIP: account nav hidden',
);
await gotoSettings(page);
const sidenav = page.getByTestId('settings-page-sidenav');
await sidenav.getByTestId('account').click();
await expect(page).toHaveURL(/\/settings\/my-settings/);
});
});

View File

@@ -1,69 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { personaSkipReason } from '../../helpers/settingsAccess';
import { SETTINGS_ROUTES } from '../../helpers/settings';
// Keyboard Shortcuts — static read-only page (RISK MODE: nothing mutated).
// No testids here, so locators are CSS classes (.keyboard-shortcuts,
// .shortcut-section-heading) and role/text.
const ROUTE = SETTINGS_ROUTES.SHORTCUTS;
async function gotoShortcuts(page: Page): Promise<void> {
await page.goto(ROUTE);
await expect(page.locator('.keyboard-shortcuts')).toBeVisible();
}
test.describe('Settings — Keyboard Shortcuts page', () => {
test('TC-01 shortcuts page renders all four grouped sections with entries', async ({
authedPage: page,
persona,
env,
}) => {
test.skip(
!!personaSkipReason(persona, env, ROUTE),
personaSkipReason(persona, env, ROUTE) ?? undefined,
);
await gotoShortcuts(page);
await expect(page.getByTestId('settings-page-title')).toBeVisible();
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
await expect(
page.getByTestId('settings-page-sidenav').getByTestId('keyboard-shortcuts'),
).toBeVisible();
const sections = page.locator('.shortcut-section-heading');
await expect(sections).toHaveCount(4);
await expect(sections.nth(0)).toHaveText('Global Shortcuts');
await expect(sections.nth(1)).toHaveText('Logs Explorer Shortcuts');
await expect(sections.nth(2)).toHaveText('Query Builder Shortcuts');
await expect(sections.nth(3)).toHaveText('Dashboard Shortcuts');
await expect(page.locator('.shortcut-section-table')).toHaveCount(4);
const firstTable = page.locator('.shortcut-section-table').first();
await expect(
firstTable.getByRole('columnheader', { name: 'Keyboard Shortcut' }),
).toBeVisible();
await expect(
firstTable.getByRole('columnheader', { name: 'Description' }),
).toBeVisible();
// "shift+d" chosen as it is stable across OS variants (no cmd/ctrl).
const globalTable = page.locator('.shortcut-section-table').nth(0);
await expect(
globalTable.getByRole('cell', { name: 'shift+d' }),
).toBeVisible();
await expect(
globalTable.getByRole('cell', { name: 'Navigate to Dashboards List' }),
).toBeVisible();
for (let i = 0; i < 4; i++) {
const table = page.locator('.shortcut-section-table').nth(i);
await expect(table.locator('tbody tr').first()).toBeVisible();
}
});
});