Compare commits

...

10 Commits

Author SHA1 Message Date
Vinícius Lourenço
179e607b6b test(e2e): stop Google Fonts from stalling page loads 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
8ad3410c34 test(e2e): wait for loaded alert rows before clicking 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
534c06d781 fix(tanstack-table): ignore clicks on skeleton rows 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
ee4dde1736 test(e2e): lift the per-user session cap on the e2e stack 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
ec4bb0dff9 test(e2e): compare against the served URL in base path mode 2026-10-08 00:19:29 -03:00
Vinícius Lourenço
b19ee361df test(e2e): add a model pattern before saving a pricing rule 2026-10-08 00:19:28 -03:00
Vinícius Lourenço
490a2fccc7 test(router): add the E2E routing safety net 2026-10-08 00:19:28 -03:00
Vinícius Lourenço
de635f8baf test(e2e): add the driver kit and the routing driver 2026-10-08 00:19:28 -03:00
Vinícius Lourenço
f33b887426 test(e2e): add a base path mode 2026-10-08 00:19:07 -03:00
Vinícius Lourenço
5cafe15b17 test(e2e): expose testids for the time picker, NotFound and the discard dialog 2026-10-07 23:34:13 -03:00
38 changed files with 1389 additions and 62 deletions

1
.gitattributes vendored
View File

@@ -1,3 +1,4 @@
*.css linguist-detectable=false
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true
tests/e2e/drivers/** linguist-generated=true

View File

@@ -86,6 +86,9 @@ tests/
├── .env.local # generated by bootstrap/setup.py (gitignored)
├── bootstrap/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── drivers/ # the driver kit and one driver per feature
│ ├── kit.ts
│ └── routing.ts
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
@@ -112,6 +115,24 @@ These two folders look similar but mean different things:
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
### Drivers
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
```ts
import { expect, PATH, test } from '../../drivers/routing';
test('TC-09 RouteTab switches the url', async ({ routing }) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
});
```
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
- A driver reuses helpers by calling them, never by reaching for another fixture.
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
### Extended fixtures
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.

View File

@@ -72,6 +72,8 @@ interface CustomTimePickerProps {
maxTime: number;
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
isModalTimeSelection?: boolean;
/** Lands on the trigger input. Not spread — the component takes no rest props. */
'data-testid'?: string;
}
function CustomTimePicker({
@@ -95,6 +97,7 @@ function CustomTimePicker({
minTime,
maxTime,
isModalTimeSelection = false,
'data-testid': dataTestId,
}: CustomTimePickerProps): JSX.Element {
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
useState('Select / Enter Time Range');
@@ -596,6 +599,7 @@ function CustomTimePicker({
>
<Input
ref={inputRef}
data-testid={dataTestId}
autoComplete="off"
className={cx(
'timeSelection-input',
@@ -682,4 +686,5 @@ CustomTimePicker.defaultProps = {
onExitLiveLogs: noop,
showLiveLogs: false,
showRecentlyUsed: true,
'data-testid': undefined,
};

View File

@@ -180,6 +180,7 @@ function CustomTimePickerPopoverContent({
type="text"
className="time-btns"
key={option.label + option.value}
data-testid={`time-chip-${option.value}`}
onClick={(): void => {
handleExitLiveLogs();
onSelectHandler(option.label, option.value);
@@ -259,6 +260,7 @@ function CustomTimePickerPopoverContent({
<Button
type="text"
key={option.label + option.value}
data-testid={`time-option-${option.value}`}
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
e.preventDefault();

View File

@@ -98,6 +98,7 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
<div
class="c0"
data-testid="not-found"
>
<img
alt="not-found"

View File

@@ -6,7 +6,7 @@ import { Button, Container, Text, TextContainer } from './styles';
function NotFound({ text = defaultText }: Props): JSX.Element {
return (
<Container>
<Container data-testid="not-found">
<NotFoundImage />
<TextContainer>

View File

@@ -35,7 +35,8 @@ function TanStackRowCellInner<TData>({
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
onClick={onClick}
// While loading, rowData may be a placeholder ({ id: 'skeleton-N' }).
onClick={showSkeleton ? undefined : onClick}
>
{showSkeleton ? (
<Skeleton.Input

View File

@@ -83,6 +83,18 @@ describe('TanStackTableView Integration', () => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('does not call onRowClick for skeleton rows', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
renderTanStackTable({
props: { data: [], isLoading: true, onRowClick },
});
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).not.toHaveBeenCalled();
});
it('shows loading spinner for infinite scroll when loading', () => {
renderTanStackTable({
props: { isLoading: true, onEndReached: jest.fn() },

View File

@@ -308,7 +308,7 @@ function CreateEditRolePageContent(): JSX.Element {
cancelText="Keep editing"
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
data-testid="discard-changes-dialog"
testId="discard-changes-dialog"
>
<Typography>
{isCreateMode

View File

@@ -13,3 +13,10 @@ SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud
# Test credentials (required only when SIGNOZ_E2E_BASE_URL is set — i.e. staging mode)
SIGNOZ_E2E_USERNAME=
SIGNOZ_E2E_PASSWORD=
# Base path mode. `pnpm env:start:base-path` serves SigNoz under /signoz, the
# way SIGNOZ_GLOBAL_EXTERNAL__URL deploys it, and writes this to .env.local;
# specs then run unchanged (see helpers/base-path.ts). The root and base path
# stacks share one database, so stop one and run `pnpm env:clean` before
# starting the other.
# SIGNOZ_E2E_BASE_PATH=/signoz

View File

View File

@@ -0,0 +1,55 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.auth import license_applier, register_admin
from fixtures.signoz import create_signoz
# The e2e stack served under a URL path prefix, as SIGNOZ_GLOBAL_EXTERNAL__URL
# deploys it. These shadow the root fixtures of the same name; the separate
# cache keys keep the two stacks from restoring each other's containers.
BASE_PATH = "/signoz"
@pytest.fixture(name="base_path", scope="package")
def base_path() -> str:
return BASE_PATH
@pytest.fixture(name="signoz", scope="package")
def signoz_base_path( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
e2e_env: dict,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_base_path",
env_overrides=e2e_env | {"SIGNOZ_GLOBAL_EXTERNAL__URL": f"http://localhost:8080{BASE_PATH}"},
)
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin_base_path(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_base_path", base_path=BASE_PATH)
@pytest.fixture(name="apply_license", scope="package")
def apply_license_base_path(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
return license_applier(signoz, request, pytestconfig, cache_key="apply_license_base_path", base_path=BASE_PATH)

View File

@@ -0,0 +1,5 @@
# The root bootstrap's tests, collected again here so they resolve the
# base-path fixtures from this package's conftest.py.
from e2e.bootstrap.setup import test_setup, test_teardown
__all__ = ["test_setup", "test_teardown"]

View File

@@ -0,0 +1,45 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="base_path", scope="package")
def base_path() -> str:
"""URL path prefix SigNoz is served under. Empty serves it at the root;
basepath/conftest.py overrides it."""
return ""
@pytest.fixture(name="e2e_env", scope="package")
def e2e_env() -> dict:
"""Environment every e2e SigNoz gets. Each Playwright worker logs in once,
and the default cap of 5 sessions per user evicts the oldest, which turns a
worker's requests into 401s mid-run."""
return {"SIGNOZ_TOKENIZER_OPAQUE_TOKEN_MAX__PER__USER": 100}
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
tls: types.TLS,
e2e_env: dict,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
tls=tls,
env_overrides=e2e_env,
)

View File

@@ -15,11 +15,12 @@ def _env_file(pytestconfig: pytest.Config) -> Path:
return pytestconfig.rootpath / "e2e" / ".env.local"
def test_setup(
def test_setup( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
apply_license: types.Operation, # pylint: disable=unused-argument
seeder: types.TestContainerDocker,
base_path: str,
pytestconfig: pytest.Config,
) -> None:
"""Bring the backend up and write e2e coordinates to .env.local."""
@@ -33,6 +34,8 @@ def test_setup(
f.write(f"SIGNOZ_E2E_USERNAME={USER_ADMIN_EMAIL}\n")
f.write(f"SIGNOZ_E2E_PASSWORD={USER_ADMIN_PASSWORD}\n")
f.write(f"SIGNOZ_E2E_SEEDER_URL={seeder_cfg.base()}\n")
if base_path:
f.write(f"SIGNOZ_E2E_BASE_PATH={base_path}\n")
def test_teardown(

93
tests/e2e/drivers/kit.ts generated Normal file
View File

@@ -0,0 +1,93 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import type { Browser, Page, TestType } from '@playwright/test';
import { newAdminContext } from '../helpers/auth';
export { expect, test as kit } from '../fixtures/auth';
export interface DriverContext {
page: Page;
}
type Fixtures = Record<string, unknown>;
/**
* Adds test-scoped fixtures to any test carrying `authedPage`. `T` is inferred
* from the argument: TypeScript cannot invert `T & { authedPage: Page }`, so
* constraining that way widens every existing fixture to `unknown`.
*/
export type Layer<Adds> = <T extends { authedPage: Page }, W extends object>(
base: TestType<T, W>,
) => TestType<T & Adds, W>;
export type WorkerLayer<Adds> = <T extends object, W extends object>(
base: TestType<T, W>,
) => TestType<T, W & Adds>;
/** A `withX` layer binding `build` to the authenticated page. */
export function driver<Name extends string, T>(
name: Name,
build: (ctx: DriverContext) => T,
): Layer<{ [K in Name]: T }> {
const fixture = {
// Playwright reads fixture dependencies from the source text, so
// `authedPage` must stay destructured literally.
[name]: async (
{ authedPage }: { authedPage: Page },
use: (value: T) => Promise<void>,
) => {
await use(build({ page: authedPage }));
},
};
return ((test: TestType<Fixtures, Fixtures>) =>
test.extend(fixture as never)) as Layer<{ [K in Name]: T }>;
}
/**
* A `withX` layer that seeds once per worker and tears down after. `open` and
* `close` run on a throwaway admin page: worker fixtures cannot reach
* `authedPage`.
*/
export function seeded<Name extends string, T>(
name: Name,
open: (ctx: { page: Page; workerIndex: number }) => Promise<T>,
close?: (ctx: { page: Page; value: T }) => Promise<void>,
options: { timeout?: number } = {},
): WorkerLayer<{ [K in Name]: T }> {
const fixture = {
[name]: [
async (
{ browser }: { browser: Browser },
use: (value: T) => Promise<void>,
workerInfo: { workerIndex: number },
) => {
const value = await onAdminPage(browser, (page) =>
open({ page, workerIndex: workerInfo.workerIndex }),
);
await use(value);
if (close) {
await onAdminPage(browser, (page) => close({ page, value }));
}
},
{ scope: 'worker', timeout: options.timeout },
],
};
return ((test: TestType<Fixtures, Fixtures>) =>
test.extend(fixture as never)) as WorkerLayer<{ [K in Name]: T }>;
}
/** Run `body` on a throwaway page authenticated as the admin. */
export async function onAdminPage<T>(
browser: Browser,
body: (page: Page) => Promise<T>,
): Promise<T> {
const ctx = await newAdminContext(browser);
try {
return await body(await ctx.newPage());
} finally {
await ctx.close();
}
}

392
tests/e2e/drivers/routing.ts generated Normal file
View File

@@ -0,0 +1,392 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { randomBytes } from 'crypto';
import { expect, type Locator, type Page } from '@playwright/test';
import { ADMIN } from '../helpers/auth';
import { authToken } from '../helpers/common';
import {
createDashboardViaApi,
deleteDashboardViaApi,
} from '../helpers/dashboards';
import { seedPodMetricsViaSeeder } from '../helpers/infra-monitoring';
import { gotoLogsExplorer } from '../helpers/logs-explorer';
import {
gotoTraceUntilLoaded,
type LargeTrace,
loadLargeTrace,
seedTracesViaSeeder,
} from '../helpers/trace-details';
import { driver, kit, seeded } from './kit';
// The react-router v5 -> v7 regression net. Router-sensitive mechanics (history
// depth, POP, same-url suppression, the `newTab` branch) live here so the specs
// read as the property they guard.
export const PATH = {
alerts: '/alerts',
dashboards: '/dashboard',
home: '/home',
k8s: '/infrastructure-monitoring/kubernetes',
login: '/login',
metricsSummary: '/metrics-explorer/summary',
metricsViews: '/metrics-explorer/views',
roles: '/settings/roles',
roleCreate: '/settings/roles/new',
services: '/services',
tracesExplorer: '/traces-explorer',
/** Matches no entry of `AppRoutes/routes.ts`. */
unknown: '/definitely-not-a-route',
} as const;
/** A resource attribute every seeded pod carries. */
const NAMESPACE_ATTR = 'k8s.namespace.name';
const RELATIVE_TIME_LABELS: Record<string, string> = {
'30m': 'Last 30 minutes',
'1h': 'Last 1 hour',
'6h': 'Last 6 hours',
};
/** Three browser projects share one backend, so names need more than a timestamp. */
function uniqueSuffix(): string {
return randomBytes(4).toString('hex');
}
/** Role names allow lowercase letters and hyphens only. */
export function uniqueAlphaSuffix(): string {
return [...randomBytes(6)]
.map((byte) => String.fromCharCode(97 + (byte % 26)))
.join('');
}
export function dashboardListParams(title: string): Record<string, string> {
return { search: title, columnKey: 'updatedAt', order: 'descend', page: '1' };
}
function groupByOf(url: URL): string {
return url.searchParams.get('groupBy') ?? '';
}
export interface Routing {
goto(path: string): Promise<void>;
reload(): Promise<void>;
goBack(): Promise<void>;
goForward(): Promise<void>;
/** Go back one entry and wait for `pathname`. */
goBackTo(pathname: string): Promise<void>;
url(): URL;
params(): Record<string, string>;
waitForPath(pathname: string): Promise<void>;
waitForParam(key: string, value: string): Promise<void>;
/** `history.length`: Chromium keeps it on POP and REPLACE, grows it on PUSH. */
historyDepth(): Promise<number>;
/** Complete the login form on the page as it stands, without navigating. */
login(): Promise<void>;
/** Sidebar entries are `div.nav-item`, not links. */
sidebarItem(label: string): Locator;
/** antd puts a `RouteTab` item's route on `data-node-key`. */
routeTab(route: string): Locator;
activeRouteTab(route: string): Locator;
/** The picker `TopNav` renders. Several pages mount their own. */
globalTimePicker(): Locator;
selectRelativeTime(value: string): Promise<void>;
expectRelativeTime(value: string): Promise<void>;
/** Ctrl/Cmd-click `target`, run `body` on the tab it opens, then close it. */
inNewTab(
target: Locator,
body: (tab: Routing) => Promise<void>,
): Promise<void>;
/** Hold every script requested during `body`, so lazy route chunks suspend. */
whileScriptsHeld(body: () => Promise<void>): Promise<void>;
gotoLogsExplorer(): Promise<void>;
dashboards: {
gotoList(title?: string): Promise<void>;
/** Not `dashboard-title-0`: the search tokenises on hyphens and matches leftovers. */
row(title: string): Locator;
landmark(): Locator;
title(): Locator;
};
k8s: {
/** Open the pods list once its mount-time `compositeQuery` rewrite landed. */
goto(params: Record<string, string>): Promise<void>;
/** One nuqs push: `groupBy`, `page` and `orderBy` flush together. */
groupBy(attribute: string): Promise<void>;
waitUntilGrouped(attribute: string, grouped?: boolean): Promise<void>;
groupBySelect(): Locator;
};
trace: {
path(trace: LargeTrace): string;
goto(trace: LargeTrace): Promise<void>;
rootRow(trace: LargeTrace): Locator;
back(): Promise<void>;
};
roles: {
/** Fill the create form far enough to arm the navigation blocker. */
openCreateForm(name: string): Promise<void>;
nameInput(): Locator;
cancel(): Promise<void>;
save(): Promise<void>;
discardDialog(): Locator;
settings(): Locator;
removeByName(names: string[]): Promise<void>;
};
}
export function routingDriver(page: Page): Routing {
const timePicker = (): Locator => page.getByTestId('dropDown');
const routing: Routing = {
goto: async (path) => {
await page.goto(path);
},
reload: async () => {
await page.reload();
},
goBack: async () => {
await page.goBack();
},
goForward: async () => {
await page.goForward();
},
goBackTo: async (pathname) => {
await page.goBack();
await routing.waitForPath(pathname);
},
url: () => new URL(page.url()),
params: () => Object.fromEntries(routing.url().searchParams),
waitForPath: (pathname) =>
page.waitForURL((url) => url.pathname === pathname),
waitForParam: (key, value) =>
page.waitForURL((url) => url.searchParams.get(key) === value),
historyDepth: () =>
page.evaluate(
() =>
(globalThis as unknown as { history: { length: number } }).history.length,
),
login: async () => {
await page.getByTestId('email').fill(ADMIN.email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(ADMIN.password);
await page.getByTestId('password_authn_submit').click();
},
sidebarItem: (label) =>
page.locator('.nav-item').filter({ hasText: new RegExp(`^${label}$`) }),
routeTab: (route) => page.locator(`.ant-tabs-tab[data-node-key="${route}"]`),
activeRouteTab: (route) =>
routing.routeTab(route).getByRole('tab', { selected: true }),
globalTimePicker: () =>
page.locator('.top-nav-container').getByTestId('dropDown'),
// A click before the picker is interactive is dropped silently, so retry the
// open. The popover unmounts on close, which is what proves the selection
// committed: the url may legitimately stay the same.
selectRelativeTime: async (value) => {
const option = page.getByTestId(`time-option-${value}`);
await expect(async () => {
if (!(await option.isVisible())) {
await timePicker().click();
}
await expect(option).toBeVisible({ timeout: 3_000 });
}).toPass({ timeout: 30_000 });
await option.click();
await expect(option).toBeHidden();
},
expectRelativeTime: async (value) => {
await expect(timePicker()).toHaveAccessibleName(RELATIVE_TIME_LABELS[value]);
},
inNewTab: async (target, body) => {
const [tab] = await Promise.all([
page.context().waitForEvent('page'),
target.click({ modifiers: ['ControlOrMeta'] }),
]);
try {
await tab.waitForLoadState();
await body(routingDriver(tab));
} finally {
await tab.close();
}
},
// The handler stays installed after the release: `unroute` drops the
// requests still parked in it.
whileScriptsHeld: async (body) => {
let release = (): void => {};
const held = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/*.js', async (route) => {
await held;
await route.continue();
});
try {
await body();
} finally {
release();
}
},
gotoLogsExplorer: () => gotoLogsExplorer(page),
dashboards: {
gotoList: async (title) => {
if (!title) {
await page.goto(PATH.dashboards);
await expect(routing.dashboards.landmark()).toBeVisible();
return;
}
const search = new URLSearchParams(dashboardListParams(title));
await page.goto(`${PATH.dashboards}?${search.toString()}`);
await expect(routing.dashboards.row(title)).toBeVisible();
},
row: (title) =>
page
.locator('[data-testid^="dashboard-title-"]')
.filter({ hasText: title }),
// The views rail renders whether or not the list is empty.
landmark: () => page.getByTestId('dashboards-view-search'),
title: () => page.getByTestId('dashboard-title'),
},
k8s: {
goto: async (params) => {
const search = new URLSearchParams({ category: 'pods', ...params });
await page.goto(`${PATH.k8s}?${search.toString()}`);
await page.waitForURL((url) => url.searchParams.has('compositeQuery'));
await expect(routing.k8s.groupBySelect()).toBeVisible();
},
// The search input stays readonly until the dropdown opens, and `hasText`
// would also match longer keys, so type after opening and match the title.
groupBy: async (attribute) => {
await routing.k8s.groupBySelect().click();
await page.keyboard.type(attribute);
await page.locator(`.ant-select-item-option[title="${attribute}"]`).click();
await page.keyboard.press('Escape');
await routing.k8s.waitUntilGrouped(attribute);
},
waitUntilGrouped: (attribute, grouped = true) =>
page.waitForURL((url) => groupByOf(url).includes(attribute) === grouped),
groupBySelect: () => page.getByTestId('k8s-table-group-by-select'),
},
trace: {
path: (trace) => `/trace/${trace.traceId}`,
goto: (trace) =>
gotoTraceUntilLoaded(
page,
routing.trace.path(trace),
`cell-0-${trace.landmarks.root}`,
),
rootRow: (trace) => page.getByTestId(`cell-0-${trace.landmarks.root}`),
back: async () => {
await page.getByRole('button', { name: 'Back' }).click();
},
},
roles: {
openCreateForm: async (name) => {
await page.goto(PATH.roleCreate);
await routing.roles.nameInput().fill(name);
await expect(page.getByText('Unsaved changes')).toBeVisible();
},
nameInput: () => page.getByTestId('role-name-input'),
cancel: async () => {
await page.getByTestId('cancel-button').click();
},
save: async () => {
await page.getByTestId('save-button').click();
},
discardDialog: () => page.getByTestId('discard-changes-dialog'),
settings: () => page.getByTestId('roles-settings'),
removeByName: async (names) => {
const headers = { Authorization: `Bearer ${await authToken(page)}` };
const res = await page.request.get('/api/v1/roles', { headers });
const { data } = (await res.json()) as {
data: { id: string; name: string }[];
};
await Promise.all(
data
.filter((role) => names.includes(role.name))
.map((role) =>
page.request.delete(`/api/v1/roles/${role.id}`, { headers }),
),
);
},
},
};
return routing;
}
// The seeder serves inserts on one ClickHouse session, so parallel workers
// collide with a transient `500 concurrent queries within the same session`.
const SEED_RETRY_MS = 120_000;
const SEED_TIMEOUT_MS = 180_000;
async function seedWithRetry(seed: () => Promise<void>): Promise<void> {
await expect(seed).toPass({
timeout: SEED_RETRY_MS,
intervals: [1_000, 2_000, 5_000],
});
}
const withRouting = driver('routing', ({ page }) => routingDriver(page));
const withDashboard = seeded(
'dashboard',
async ({ page }) => {
const title = `routing-${uniqueSuffix()}`;
const id = await createDashboardViaApi(page, title);
return { id, title, path: `${PATH.dashboards}/${id}` };
},
async ({ page, value }) =>
deleteDashboardViaApi(page.request, value.id, await authToken(page)),
);
const withTrace = seeded(
'largeTrace',
async ({ page }) => {
const trace = loadLargeTrace();
await seedWithRetry(() => seedTracesViaSeeder(page.request, trace.spans));
return trace;
},
undefined,
{ timeout: SEED_TIMEOUT_MS },
);
// Group-by options come from `/fields/keys` over the queried window, so the
// select stays empty until pod metrics exist inside it.
const withPods = seeded(
'pods',
async ({ page }) => {
await seedWithRetry(() => seedPodMetricsViaSeeder(page));
return { groupBy: NAMESPACE_ATTR };
},
undefined,
{ timeout: SEED_TIMEOUT_MS },
);
export const test = withPods(
withTrace(withDashboard(withRouting(kit))),
).extend<{
/** The routing driver on an unauthenticated page. */
guest: Routing;
}>({
guest: async ({ browser }, use) => {
const ctx = await browser.newContext();
await use(routingDriver(await ctx.newPage()));
await ctx.close();
},
});
export { expect, onAdminPage } from './kit';

View File

@@ -1,27 +1,42 @@
import { test as base, expect, type Page } from '@playwright/test';
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
import { installBasePathMode } from '../helpers/base-path';
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
// worker-scoped fixtures and suite hooks share one login with this fixture.
export { ADMIN };
export type { User };
export const test = base.extend<{
/**
* User identity for this test. Override with `test.use({ user: ... })` at
* the describe or test level to run the suite as a different user.
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
*/
user: User;
export const test = base.extend<
{
/**
* User identity for this test. Override with `test.use({ user: ... })` at
* the describe or test level to run the suite as a different user.
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
*/
user: User;
/**
* A Page whose context is already authenticated as `user`. First request
* for a given user triggers one login per worker; the resulting
* storageState is held in memory and reused for all later requests.
*/
authedPage: Page;
},
{
/** Applies SIGNOZ_E2E_BASE_PATH before any page or login in the worker. */
basePathMode: void;
}
>({
basePathMode: [
async ({ browser }, use) => {
await installBasePathMode(browser);
await use();
},
{ scope: 'worker', auto: true },
],
/**
* A Page whose context is already authenticated as `user`. First request
* for a given user triggers one login per worker; the resulting
* storageState is held in memory and reused for all later requests.
*/
authedPage: Page;
}>({
user: [ADMIN, { option: true }],
authedPage: async ({ browser, user }, use) => {

View File

@@ -72,6 +72,7 @@ export async function gotoAlertList(
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
if (expectRows) {
await expect(alertRuleRows(page).first()).toBeVisible();
// Skeleton rows are empty and ignore clicks; wait for a loaded one.
await expect(alertRuleRows(page).first()).toHaveText(/\S/);
}
}

View File

@@ -1,5 +1,7 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import { storageKey } from './base-path';
export type User = { email: string; password: string };
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
@@ -57,7 +59,8 @@ async function login(page: Page, user: User): Promise<void> {
async function pinSidenav(page: Page): Promise<void> {
const token = await page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
(key) => (globalThis as any).localStorage.getItem(key) || '',
storageKey('AUTH_TOKEN'),
);
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: true },

View File

@@ -0,0 +1,105 @@
import type { APIRequestContext, Browser, Page } from '@playwright/test';
// Base path mode: with SIGNOZ_E2E_BASE_PATH set (`pnpm env:start:base-path`
// writes `/signoz`), specs run unchanged against SigNoz served under that URL
// prefix. `page.goto`, `page.waitForURL` string targets and `request.*` take
// app paths and get the prefix; `page.url()` and `waitForURL` predicates see
// the URL without it. `expect(page).toHaveURL` still sees the real URL.
export const BASE_PATH = (process.env.SIGNOZ_E2E_BASE_PATH ?? '').replace(
/\/$/,
'',
);
/** The app scopes its localStorage keys to the base path (`utils/storage.ts`). */
export function storageKey(key: string): string {
return BASE_PATH ? `${BASE_PATH}/${key}` : key;
}
function toServedPath(path: string): string {
return path.startsWith('/') && !path.startsWith('//')
? `${BASE_PATH}${path}`
: path;
}
function toAppUrl(href: string): string {
const url = new URL(href);
if (url.pathname === BASE_PATH || url.pathname.startsWith(`${BASE_PATH}/`)) {
url.pathname = url.pathname.slice(BASE_PATH.length) || '/';
}
return url.toString();
}
function patchMethod<T extends object, K extends keyof T>(
proto: T,
name: K,
wrap: (original: T[K]) => T[K],
): void {
proto[name] = wrap(proto[name]);
}
let installed = false;
/**
* Patches the Page and APIRequestContext prototypes once per worker. Playwright
* exports neither class, so the prototypes come from a throwaway page.
*/
export async function installBasePathMode(browser: Browser): Promise<void> {
if (!BASE_PATH || installed) {
return;
}
installed = true;
const context = await browser.newContext();
const page = await context.newPage();
const pageProto: Page = Object.getPrototypeOf(page);
const requestProto: APIRequestContext = Object.getPrototypeOf(page.request);
await context.close();
patchMethod(
pageProto,
'goto',
(goto) =>
function (this: Page, target, options) {
return goto.call(this, toServedPath(target), options);
},
);
patchMethod(
pageProto,
'url',
(url) =>
function (this: Page) {
return toAppUrl(url.call(this));
},
);
patchMethod(
pageProto,
'waitForURL',
(waitForURL) =>
function (this: Page, matcher, options) {
let appMatcher = matcher;
if (typeof matcher === 'function') {
appMatcher = (current: URL): boolean =>
matcher(new URL(toAppUrl(current.href)));
} else if (typeof matcher === 'string') {
appMatcher = toServedPath(matcher);
}
return waitForURL.call(this, appMatcher, options);
},
);
patchMethod(
requestProto,
'fetch',
(fetch) =>
function (this: APIRequestContext, target, options) {
return fetch.call(
this,
typeof target === 'string' ? toServedPath(target) : target,
options,
);
},
);
}

View File

@@ -1,5 +1,7 @@
import type { Page, Request } from '@playwright/test';
import { storageKey } from './base-path';
// Shared helpers used across feature-specific helper modules (dashboards,
// trace-details, …). Keep this to genuinely cross-feature utilities.
@@ -45,6 +47,8 @@ const HARNESS_CONSOLE_NOISE = [
'Request failed with status code 404',
'client never received a response, or request never left',
'ErrorResponseHandler: unclassified error',
// Google Fonts, which the chromium project fails at DNS (playwright.config.ts).
'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
];
export interface ConsoleWatch {
@@ -130,7 +134,9 @@ export function requestUrl(request: Request): URL {
export async function authToken(page: Page): Promise<string> {
const state = await page.context().storageState();
for (const origin of state.origins) {
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
const entry = origin.localStorage.find(
(e) => e.name === storageKey('AUTH_TOKEN'),
);
if (entry) {
return entry.value;
}

View File

@@ -11,6 +11,8 @@ import apmMetricsTemplate from '../testdata/apm-metrics.json';
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
import variablesTemplate from '../testdata/variables-dashboard.json';
import { storageKey } from './base-path';
// ─── Constants ───────────────────────────────────────────────────────────
//
// UI strings and well-known values referenced both within this file and by
@@ -45,7 +47,8 @@ export async function authToken(page: Page): Promise<string> {
}
return page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
(key) => (globalThis as any).localStorage.getItem(key) || '',
storageKey('AUTH_TOKEN'),
);
}
@@ -82,12 +85,39 @@ async function postDashboard(
return json.data.id;
}
/** Seed a minimally-named dashboard via API. Returns the new ID. */
/**
* Seed a minimally-named dashboard via API. Returns the new ID.
*
* v2 (Perses spec) only: `POST /api/v1/dashboards` now answers 501
* `dashboard_deprecated`. Payload mirrors what the New-dashboard modal sends
* (`BlankDashboardPanel.tsx`), `schemaVersion` included.
*/
export async function createDashboardViaApi(
page: Page,
title: string,
): Promise<string> {
return postDashboard(page, { title, uploadedGrafana: false });
const token = await authToken(page);
const res = await page.request.post('/api/v2/dashboards', {
data: {
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: title },
layouts: [],
panels: {},
variables: [],
},
},
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
);
}
const json = (await res.json()) as { data: { id: string } };
return json.data.id;
}
/**
@@ -342,7 +372,7 @@ export async function deleteDashboardViaApi(
token: string,
): Promise<void> {
await request
.delete(`/api/v1/dashboards/${id}`, {
.delete(`/api/v2/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => undefined);

View File

@@ -0,0 +1,110 @@
import type { Page } from '@playwright/test';
import { seederUrl } from './common';
// Seeding for the Kubernetes lists under /infrastructure-monitoring.
/**
* The metric the pods list queries. Its rows are what put the pod resource
* attributes into `/fields/keys`, which is where the group-by select gets its
* options, so this is the metric to seed, not an arbitrary one.
*
* Dotted, not `k8s_pod_cpu_usage`: the list passes this name straight through as
* `metricNamespace` and the backend matches it literally, so the underscored
* spelling returns an empty key set and the group-by select renders "No data".
*/
const POD_CPU_METRIC = 'k8s.pod.cpu.usage';
const POD_MEMORY_METRIC = 'k8s.pod.memory.usage';
const NAMESPACES = ['default', 'kube-system', 'signoz'];
const PODS_PER_NAMESPACE = 2;
const POINTS_PER_POD = 4;
const POINT_INTERVAL_MS = 60_000;
type SeededMetric = {
metric_name: string;
timestamp: string;
value: number;
type_: string;
temporality: string;
is_monotonic: boolean;
unit: string;
resource_attrs: Record<string, string>;
labels: Record<string, string>;
};
function podResourceAttrs(
namespace: string,
pod: string,
): Record<string, string> {
return {
'k8s.cluster.name': 'e2e-cluster',
'k8s.namespace.name': namespace,
'k8s.node.name': `node-${namespace}`,
'k8s.pod.name': pod,
'k8s.pod.uid': `${namespace}-${pod}`,
};
}
function podPoints(now: number): SeededMetric[] {
const points: SeededMetric[] = [];
for (const namespace of NAMESPACES) {
for (let p = 0; p < PODS_PER_NAMESPACE; p += 1) {
const pod = `${namespace}-pod-${p}`;
const resourceAttrs = podResourceAttrs(namespace, pod);
for (let i = 0; i < POINTS_PER_POD; i += 1) {
const timestamp = new Date(
now - (POINTS_PER_POD - i) * POINT_INTERVAL_MS,
).toISOString();
points.push({
metric_name: POD_CPU_METRIC,
timestamp,
value: 0.1 + i * 0.05,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
unit: '1',
resource_attrs: resourceAttrs,
labels: resourceAttrs,
});
points.push({
metric_name: POD_MEMORY_METRIC,
timestamp,
value: 100_000_000 + i * 1_000_000,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
unit: 'By',
resource_attrs: resourceAttrs,
labels: resourceAttrs,
});
}
}
}
return points;
}
/**
* Seed pod metrics so the k8s list has rows and its group-by select has
* options. Both matter: with no rows inside the queried window the select
* renders empty and every `setK8sGroupBy` call times out.
*
* The pod keys go in `labels` as well as `resource_attrs`: the list's queries
* reference some of them as `tag` and others as `resource`, and the series
* fingerprint is computed off labels alone. With an empty label map all six
* pods collapse into one series and the list comes back empty.
*
* Wrap the call in `seedWithRetry`: the seeder serves inserts on one
* ClickHouse session, so parallel workers collide with a transient 500.
*/
export async function seedPodMetricsViaSeeder(page: Page): Promise<void> {
const res = await page.request.post(`${seederUrl()}/telemetry/metrics`, {
data: podPoints(Date.now()),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok()) {
throw new Error(
`seeder POST /telemetry/metrics ${res.status()}: ${await res.text()}`,
);
}
}

View File

@@ -7,6 +7,8 @@
"preinstall": "npx only-allow pnpm",
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/setup.py::test_setup",
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/setup.py::test_teardown",
"env:start:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/basepath/setup.py::test_setup",
"env:stop:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/basepath/setup.py::test_teardown",
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
"test": "playwright test",
"test:local": "pnpm env:start && pnpm test",

View File

@@ -108,7 +108,15 @@ export default defineConfig({
},
{
name: 'chromium',
use: devices['Desktop Chrome'],
use: {
...devices['Desktop Chrome'],
launchOptions: {
// Google Fonts CSS blocks the app script and `load` until it settles,
// so a stalled request blanks the page. Failing DNS settles it at once;
// a `route` would too, but routing turns off the HTTP cache.
args: ['--host-resolver-rules=MAP fonts.googleapis.com ~NOTFOUND'],
},
},
dependencies: ['setup'],
},
{

View File

@@ -26,6 +26,7 @@ import {
v2SaveTooltip,
v2TestButton,
} from '../../../helpers/alert-forms/v2';
import { BASE_PATH } from '../../../helpers/base-path';
// CV2-* — the v2 create builder.
//
@@ -446,7 +447,7 @@ test.describe('Alert create — v2 builder', () => {
await ownedRules.register(response);
expect(response.status()).toBe(201);
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
expect(new URL(response.url()).pathname).toBe(`${BASE_PATH}/api/v2/rules`);
const body = response.request().postDataJSON();
expect(body.schemaVersion).toBe('v2alpha1');

View File

@@ -15,7 +15,8 @@ test.describe('Alert details — page chrome', () => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await gotoAlertHistory(page, alertHistory.ruleId);
const expected = page.url();
// The served URL: base path mode strips the prefix from `page.url()`.
const expected = page.mainFrame().url();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Copied')).toBeVisible();

View File

@@ -27,7 +27,8 @@ test.describe('Alerts page shell', () => {
await page.goto(ALERTS_LIST_PATH);
// No `tab` param at all — `getActiveKey()` falls back to AlertRules.
await expect(page).toHaveURL(ALERTS_LIST_PATH);
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}(\\?|$)`));
await expect(page).not.toHaveURL(/[?&]tab=/);
await expect(
page.getByRole('tab', { name: TAB_NAMES.rules }),
).toHaveAttribute('aria-selected', 'true');

View File

@@ -33,6 +33,8 @@ test.describe('LLM Observability — Model Pricing', () => {
await expect(page.getByTestId('drawer-model-id-input')).toBeVisible();
await page.getByTestId('drawer-model-id-input').fill(MODEL_NAME);
await page.getByTestId('drawer-pattern-input').fill(MODEL_NAME);
await page.getByTestId('drawer-pattern-add-btn').click();
await page.getByTestId('drawer-input-cost').fill(INPUT_COST);
await page.getByTestId('drawer-output-cost').fill(OUTPUT_COST);

View File

@@ -0,0 +1,45 @@
import { expect, PATH, test } from '../../drivers/routing';
// `<Redirect>` replaces the history entry and v7's `<Navigate>` pushes unless
// told otherwise, which only shows when someone presses Back. Runs as `guest`:
// an authenticated page would skip the chain under test.
test.describe('Routing — auth guards', () => {
test('TC-14 a deep-linked private route survives the login round trip', async ({
guest,
}) => {
await guest.goto(PATH.services);
await guest.waitForPath(PATH.login);
await guest.login();
// Private.tsx stashed the requested path and redirects back to it.
await guest.waitForPath(PATH.services);
expect(guest.url().pathname).toBe(PATH.services);
});
test('TC-15 the login redirects replace, so Back does not re-run the chain', async ({
guest,
}) => {
const depthBeforeGoto = await guest.historyDepth();
await guest.goto(PATH.services);
await guest.waitForPath(PATH.login);
// A pushing redirect would read one higher.
const depthAtLogin = await guest.historyDepth();
expect(depthAtLogin).toBe(depthBeforeGoto + 1);
await guest.login();
await guest.waitForPath(PATH.services);
expect(await guest.historyDepth()).toBe(depthAtLogin + 1);
// Back reaches the replaced /login entry, where being logged in redirects
// straight out again.
await guest.goBack();
await expect
.poll(() => guest.url().pathname, { timeout: 15_000 })
.not.toBe(PATH.login);
expect(guest.url().pathname).toBe(PATH.services);
});
});

View File

@@ -0,0 +1,49 @@
import { expect, PATH, test } from '../../drivers/routing';
// The shape of the router mount, which no other spec observes.
test.describe('Routing: router mount', () => {
// Under a React transition the previous screen stays up instead of the
// Suspense fallback, so an uncached route renders no loader at all.
test('TC-18 an in-app navigation to an uncached route commits the Suspense fallback', async ({
authedPage: page,
routing,
}) => {
await routing.dashboards.gotoList();
await routing.whileScriptsHeld(async () => {
await routing.sidebarItem('Alerts').click();
await routing.waitForPath(PATH.alerts);
await expect(routing.dashboards.landmark()).toBeHidden();
await expect(
page.getByRole('img', { name: 'loading' }).first(),
).toBeVisible();
});
await expect(
page
.getByRole('heading', { name: 'Alert Rules' })
.or(page.getByTestId('list-alerts-search-input')),
).toBeVisible();
});
// nuqs' react-router adapter needs a router above it; the wrong order throws
// on the first page that reads a nuqs param.
test('TC-19 a page with nuqs params mounts with no router-context error', async ({
authedPage: page,
routing,
}) => {
const reported: string[] = [];
page.on('pageerror', (error) => reported.push(error.message));
page.on('console', (message) => reported.push(message.text()));
await routing.k8s.goto({ relativeTime: '30m' });
expect(
reported.filter((text) =>
/useNavigate|useSearchParams|context of a <Router>/i.test(text),
),
).toEqual([]);
});
});

View File

@@ -0,0 +1,62 @@
import {
expect,
onAdminPage,
PATH,
routingDriver,
test,
uniqueAlphaSuffix,
} from '../../drivers/routing';
// `/settings/roles/new` is the only `useNavigationBlocker` consumer, so this is
// the whole observable surface of the blocker protocol.
// Both tests drive the create form; keep them off each other's role names.
test.describe.configure({ mode: 'serial' });
const BLOCKED_ROLE_NAME = `routing-blocker-${uniqueAlphaSuffix()}`;
const SAVED_ROLE_NAME = `routing-bypass-${uniqueAlphaSuffix()}`;
test.describe('Routing — unsaved-changes navigation blocker', () => {
test.afterAll(async ({ browser }) => {
await onAdminPage(browser, (page) =>
routingDriver(page).roles.removeByName([BLOCKED_ROLE_NAME, SAVED_ROLE_NAME]),
);
});
test('TC-16 a blocked navigation can be cancelled and then confirmed', async ({
routing,
}) => {
const { roles } = routing;
await roles.openCreateForm(BLOCKED_ROLE_NAME);
await roles.cancel();
await expect(roles.discardDialog()).toBeVisible();
// Cancel drops the blocked transition rather than retrying it.
await roles
.discardDialog()
.getByRole('button', { name: 'Keep editing' })
.click();
await expect(roles.discardDialog()).toBeHidden();
expect(routing.url().pathname).toBe(PATH.roleCreate);
await expect(roles.nameInput()).toHaveValue(BLOCKED_ROLE_NAME);
await roles.cancel();
await roles.discardDialog().getByRole('button', { name: 'Discard' }).click();
await routing.waitForPath(PATH.roles);
});
test('TC-17 saving bypasses the blocker for the next navigation', async ({
routing,
}) => {
const { roles } = routing;
await roles.openCreateForm(SAVED_ROLE_NAME);
// `allowNextNavigation()` unarms the blocker for the push after a save.
await roles.save();
await routing.waitForPath(PATH.roles);
await expect(roles.discardDialog()).toHaveCount(0);
await expect(roles.settings()).toBeVisible();
});
});

View File

@@ -0,0 +1,87 @@
import { dashboardListParams, expect, PATH, test } from '../../drivers/routing';
// Imperative navigation: the `history.listen` counter behind `hasInAppHistory()`,
// `useSafeNavigate`'s same-url suppression, and its `newTab` branch.
test.describe('Routing — imperative navigation', () => {
test('TC-05 Back from a detail page restores the list with its params', async ({
routing,
dashboard,
}) => {
await routing.dashboards.gotoList(dashboard.title);
await routing.dashboards.row(dashboard.title).click();
await routing.waitForPath(dashboard.path);
await routing.goBackTo(PATH.dashboards);
expect(routing.params()).toMatchObject(dashboardListParams(dashboard.title));
});
// Both branches of the trace header's Back button in one document:
// `hasInAppHistory() ? goBack() : push(TRACES_EXPLORER)`.
test('TC-06 the trace-details previous button pushes when deep-linked and pops once in-app', async ({
routing,
largeTrace: trace,
}) => {
await routing.goto(PATH.services);
await routing.trace.goto(trace);
// The document load reset the counter, so Back pushes the explorer.
const depthBeforePush = await routing.historyDepth();
await routing.trace.back();
await routing.waitForPath(PATH.tracesExplorer);
expect(routing.url().searchParams.has('selectedExplorerView')).toBe(false);
expect(await routing.historyDepth()).toBe(depthBeforePush + 1);
// That push is in-app history, so after a POP back Back pops too.
await routing.goBackTo(routing.trace.path(trace));
await expect(routing.trace.rootRow(trace)).toBeVisible();
const depthBeforePop = await routing.historyDepth();
await routing.trace.back();
await routing.waitForPath(PATH.services);
expect(await routing.historyDepth()).toBe(depthBeforePop);
});
test('TC-07 re-selecting the current relative time adds no history entry', async ({
routing,
}) => {
test.slow();
await routing.k8s.goto({ relativeTime: '30m' });
await routing.expectRelativeTime('30m');
const before = routing.params();
const depthBefore = await routing.historyDepth();
await routing.selectRelativeTime('30m');
// `areUrlsEffectivelySame` ignores the regenerated compositeQuery `id`. Not
// followed by "one Back leaves the page": the k8s list pushes its own
// compositeQuery rewrite on mount.
expect(await routing.historyDepth()).toBe(depthBefore);
expect(routing.url().pathname).toBe(PATH.k8s);
expect(routing.params()).toMatchObject({
relativeTime: '30m',
category: before.category,
});
await routing.expectRelativeTime('30m');
});
test('TC-08 modifier-clicking an in-app link opens the same url in a new tab', async ({
routing,
dashboard,
}) => {
await routing.dashboards.gotoList(dashboard.title);
await routing.inNewTab(
routing.dashboards.row(dashboard.title),
async (tab) => {
expect(tab.url().pathname).toBe(dashboard.path);
await expect(tab.dashboards.title()).toHaveText(dashboard.title);
},
);
expect(routing.url().pathname).toBe(PATH.dashboards);
expect(routing.params()).toMatchObject(dashboardListParams(dashboard.title));
});
});

View File

@@ -0,0 +1,84 @@
import { expect, PATH, test } from '../../drivers/routing';
// Every place a `matchPath` / `generatePath` call decides what renders:
// `RouteTab`, `TopNav`'s per-route checks, and `SETTINGS`, a prefix route that
// needs an explicit `/*` under v7.
test.describe('Routing — route matching', () => {
test('TC-09 RouteTab switches the url and survives a reload', async ({
routing,
}) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
await routing.routeTab(PATH.metricsViews).click();
await routing.waitForPath(PATH.metricsViews);
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
await routing.reload();
expect(routing.url().pathname).toBe(PATH.metricsViews);
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
});
test('TC-10 a prefix-route deep link loads with the right tab selected', async ({
authedPage: page,
routing,
}) => {
await routing.goto(PATH.roles);
expect(routing.url().pathname).toBe(PATH.roles);
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
await expect(page.getByTestId('roles')).toHaveClass(/active/);
await expect(routing.roles.settings()).toBeVisible();
});
test('TC-11 a seeded dashboard id round-trips byte-identical', async ({
routing,
dashboard,
}) => {
await routing.dashboards.gotoList(dashboard.title);
await routing.dashboards.row(dashboard.title).click();
// Exact match: any re-encoding of the uuid fails here.
await routing.waitForPath(dashboard.path);
await expect(routing.dashboards.title()).toHaveText(dashboard.title);
await routing.reload();
expect(routing.url().pathname).toBe(dashboard.path);
await expect(routing.dashboards.title()).toHaveText(dashboard.title);
});
test('TC-12 the global time picker renders only where matchPath allows it', async ({
routing,
}) => {
await routing.goto(`${PATH.services}?relativeTime=30m`);
await expect(routing.globalTimePicker()).toBeVisible();
// LOGS_EXPLORER is in `routesToDisable`.
await routing.gotoLogsExplorer();
await expect(routing.globalTimePicker()).toHaveCount(0);
// ROLES_SETTINGS is in `routesToSkip`.
await routing.goto(PATH.roles);
await expect(routing.roles.settings()).toBeVisible();
await expect(routing.globalTimePicker()).toHaveCount(0);
});
// `PrivateRoute` redirects a logged-in user on an unknown path to HOME before
// the catch-all runs, so `NotFound` is unreachable while authenticated.
test('TC-13 an unknown path redirects home and replaces its history entry', async ({
authedPage: page,
routing,
}) => {
await routing.dashboards.gotoList();
const depthBefore = await routing.historyDepth();
await routing.goto(PATH.unknown);
await routing.waitForPath(PATH.home);
await expect(page.getByTestId('not-found')).toHaveCount(0);
// The redirect replaced the entry, so one Back reaches the list.
expect(await routing.historyDepth()).toBe(depthBefore + 1);
await routing.goBackTo(PATH.dashboards);
});
});

View File

@@ -0,0 +1,83 @@
import { expect, PATH, test } from '../../drivers/routing';
// The nuqs adapter swap. On the k8s list `groupBy` is written by nuqs straight
// to the History API with `history: 'push'`, while `relativeTime` is written by
// the router through `useSafeNavigate`.
test.describe('Routing — nuqs and router param coexistence', () => {
test('TC-01 a router write keeps the nuqs params it did not author', async ({
routing,
pods,
}) => {
await routing.k8s.goto({ relativeTime: '30m' });
await routing.k8s.groupBy(pods.groupBy);
await routing.selectRelativeTime('1h');
await routing.waitForParam('relativeTime', '1h');
expect(routing.url().pathname).toBe(PATH.k8s);
expect(routing.params()).toMatchObject({
relativeTime: '1h',
category: 'pods',
});
expect(routing.params().groupBy).toContain(pods.groupBy);
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
});
test('TC-02 a nuqs write keeps the relativeTime the router authored', async ({
routing,
pods,
}) => {
await routing.k8s.goto({ relativeTime: '30m' });
await routing.selectRelativeTime('6h');
await routing.waitForParam('relativeTime', '6h');
await routing.k8s.groupBy(pods.groupBy);
expect(routing.url().pathname).toBe(PATH.k8s);
expect(routing.params().relativeTime).toBe('6h');
expect(routing.params().groupBy).toContain(pods.groupBy);
await routing.expectRelativeTime('6h');
});
test('TC-03 POP across a nuqs push reverts both url and list', async ({
routing,
pods,
}) => {
await routing.k8s.goto({ relativeTime: '30m' });
// One flush, one entry: otherwise one Back lands on an intermediate url.
const depthBeforeGroupBy = await routing.historyDepth();
await routing.k8s.groupBy(pods.groupBy);
expect(await routing.historyDepth()).toBe(depthBeforeGroupBy + 1);
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
await routing.goBack();
await routing.k8s.waitUntilGrouped(pods.groupBy, false);
expect(routing.url().pathname).toBe(PATH.k8s);
await expect(routing.k8s.groupBySelect()).not.toContainText(pods.groupBy);
await routing.goForward();
await routing.k8s.waitUntilGrouped(pods.groupBy);
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
});
test('TC-04 a deep link carrying both families applies both on first paint', async ({
routing,
pods,
}) => {
await routing.k8s.goto({
relativeTime: '6h',
groupBy: JSON.stringify([pods.groupBy]),
});
// `k8s.goto` waited for the mount-time republish, so anything it dropped is
// missing by now.
expect(routing.url().pathname).toBe(PATH.k8s);
expect(routing.params().relativeTime).toBe('6h');
expect(routing.params().groupBy).toContain(pods.groupBy);
await routing.expectRelativeTime('6h');
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
});
});

View File

@@ -18,6 +18,6 @@
"outDir": "./dist",
"rootDir": "."
},
"include": ["tests/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
"include": ["tests/**/*.ts", "drivers/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -36,28 +36,6 @@ USERS_BASE = "/api/v2/users"
USER_ROLES_BASE = "/api/v2/user_roles"
def _login(signoz: types.SigNoz, email: str, password: str) -> str:
"""Complete GET /sessions/context + POST /sessions/email_password; return accessToken."""
ctx = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={
"email": email,
"ref": f"{signoz.self.host_configs['8080'].base()}",
},
timeout=5,
)
assert ctx.status_code == HTTPStatus.OK
org_id = ctx.json()["data"]["orgs"][0]["id"]
login = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={"email": email, "password": password, "orgId": org_id},
timeout=5,
)
assert login.status_code == HTTPStatus.OK
return login.json()["data"]["accessToken"]
def register_admin(
signoz: types.SigNoz,
request: pytest.FixtureRequest,
@@ -182,16 +160,14 @@ def get_tokens(signoz: types.SigNoz) -> Callable[[str, str], tuple[str, str]]:
return tokens_getter(signoz)
@pytest.fixture(name="apply_license", scope="package")
def apply_license(
def license_applier(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument,redefined-outer-name
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "apply_license",
base_path: str = "",
) -> types.Operation:
"""Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips
to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and
every spec inherits the licensed state."""
"""Apply the ENTERPRISE license and complete org onboarding, under base_path. Reuse-wrapped."""
def create() -> types.Operation:
Config.base_url = signoz.zeus.host_configs["8080"].get("/__admin")
@@ -224,12 +200,12 @@ def apply_license(
)
)
access_token = _login(signoz, USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
access_token = token_getter(signoz, base_path)(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# 201 = applied, 409 = already applied. Retry transient failures —
# the BE occasionally 5xxs right after startup before the license
# sync goroutine is ready.
license_url = signoz.self.host_configs["8080"].get("/api/v4/licenses")
license_url = signoz.self.host_configs["8080"].get(f"{base_path}/api/v4/licenses")
auth_header = {"Authorization": f"Bearer {access_token}"}
for attempt in range(10):
resp = requests.post(
@@ -248,7 +224,7 @@ def apply_license(
# redirects first-time admins to a questionnaire. Mark the preference
# complete so specs can navigate directly to the feature under test.
pref_resp = requests.put(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences/org_onboarding"),
signoz.self.host_configs["8080"].get(f"{base_path}/api/v1/org/preferences/org_onboarding"),
json={"value": True},
headers=auth_header,
timeout=5,
@@ -265,7 +241,7 @@ def apply_license(
return reuse.wrap(
request,
pytestconfig,
"apply_license",
cache_key,
lambda: types.Operation(name=""),
create,
delete,
@@ -273,6 +249,19 @@ def apply_license(
)
@pytest.fixture(name="apply_license", scope="package")
def apply_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument,redefined-outer-name
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
"""Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips
to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and
every spec inherits the licensed state."""
return license_applier(signoz, request, pytestconfig)
# This is not a fixture purposefully, we just want to add a license to the signoz instance.
# This is also idempotent in nature.
def add_license(