Scopes: Add RTK Query API client for caching (#115494)

* Scopes API client

* Initial RTK query commit

* Copy API client from generated enterprise folder

* Mock ScopesApiClient for integration tests

* Update e2e tests

* Handle group expansion for dashboard navigation

* Extract integration test mocks

* Move mock to only be for integration tests

* Update path for enterprise sync script

* Re-export mockData

* Disregard caching for search

* Leave name parameters empty

* Disable subscriptions for client requests

* Add functionality to reset cache between mocked requests

* Use grafana-test-utils for scopes integration tests

* Rollback mock setup

* Remove store form window object

* Remove cache helper

* Restore scopenode search functionality

* Improve request erro handling

* Clean up subscription in case subscription: false lies

* Fix logging security risk

* Rewrite tests to cover RTK query usage and improve error catching

* Update USE_LIVE_DATA to be consistent

* Remove unused timout parameter

* Fix error handling

* Make dashboard-navigation test pass
This commit is contained in:
Tobias Skarhed 2026-01-13 13:09:08 +01:00 committed by GitHub
parent b57b8d4359
commit d1064da4cd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3595 additions and 912 deletions

View file

@ -1,6 +1,7 @@
import { test, expect } from '@grafana/plugin-e2e';
import { setScopes } from '../utils/scope-helpers';
import { setScopes, setupScopeRoutes } from '../utils/scope-helpers';
import { testScopes } from '../utils/scopes';
import {
getAdHocFilterOptionValues,
@ -13,6 +14,7 @@ import {
} from './cuj-selectors';
import { prepareAPIMocks } from './utils';
const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH);
const DASHBOARD_UNDER_TEST = 'cuj-dashboard-1';
test.use({
@ -34,6 +36,11 @@ test.describe(
const adHocFilterPills = getAdHocFilterPills(page);
const scopesSelectorInput = getScopesSelectorInput(page);
// Set up routes before any navigation (only for mocked mode)
if (!USE_LIVE_DATA) {
await setupScopeRoutes(page, testScopes());
}
await test.step('1.Apply filtering to a whole dashboard', async () => {
const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST });

View file

@ -66,6 +66,17 @@ export function getScopesDashboards(page: Page) {
return page.locator('[data-testid^="scopes-dashboards-"][role="treeitem"]');
}
/**
* Clicks the first available dashboard in the scopes dashboard list.
*/
export async function clickFirstScopesDashboard(page: Page) {
const dashboards = getScopesDashboards(page);
// Wait for at least one dashboard to be visible
await expect(dashboards.first()).toBeVisible({ timeout: 10000 });
// Click - Playwright will automatically wait for the element to be actionable
await dashboards.first().click();
}
export function getScopesDashboardsSearchInput(page: Page) {
return page.getByTestId('scopes-dashboards-search');
}

View file

@ -1,8 +1,10 @@
import { test, expect } from '@grafana/plugin-e2e';
import { setScopes } from '../utils/scope-helpers';
import { setScopes, setupScopeRoutes } from '../utils/scope-helpers';
import { testScopes } from '../utils/scopes';
import {
clickFirstScopesDashboard,
getAdHocFilterPills,
getGroupByInput,
getGroupByValues,
@ -21,6 +23,7 @@ test.use({
},
});
const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH);
const DASHBOARD_UNDER_TEST = 'cuj-dashboard-1';
const DASHBOARD_UNDER_TEST_2 = 'cuj-dashboard-2';
const NAVIGATE_TO = 'cuj-dashboard-3';
@ -38,6 +41,11 @@ test.describe(
const adhocFilterPills = getAdHocFilterPills(page);
const groupByValues = getGroupByValues(page);
// Set up routes before any navigation (only for mocked mode)
if (!USE_LIVE_DATA) {
await setupScopeRoutes(page, testScopes());
}
await test.step('1.Search dashboard', async () => {
await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST });
@ -74,7 +82,7 @@ test.describe(
await expect(markdownContent).toContainText(`now-12h`);
await scopesDashboards.first().click();
await clickFirstScopesDashboard(page);
await page.waitForURL('**/d/**');
await expect(markdownContent).toBeVisible();
@ -117,10 +125,10 @@ test.describe(
await groupByVariable.press('Enter');
await groupByVariable.press('Escape');
await expect(scopesDashboards.first()).toBeVisible();
const { getRequests, waitForExpectedRequests } = await trackDashboardReloadRequests(page);
await scopesDashboards.first().click();
await clickFirstScopesDashboard(page);
await page.waitForURL('**/d/**');
await waitForExpectedRequests();
await page.waitForLoadState('networkidle');
@ -158,8 +166,7 @@ test.describe(
const oldFilters = `GroupByVar: ${selectedValues}\n\nAdHocVar: ${processedPills}`;
await expect(markdownContent).toContainText(oldFilters);
await expect(scopesDashboards.first()).toBeVisible();
await scopesDashboards.first().click();
await clickFirstScopesDashboard(page);
await page.waitForURL('**/d/**');
const newPillCount = await adhocFilterPills.count();

View file

@ -165,9 +165,8 @@ test.describe(
await refreshBtn.click();
await page.waitForLoadState('networkidle');
expect(await panelContent.textContent()).not.toBe(panelContents);
// Wait for the panel content to change (not just for network to complete)
await expect(panelContent).not.toHaveText(panelContents!, { timeout: 10000 });
});
await test.step('6.Turn off refresh', async () => {

View file

@ -9,6 +9,7 @@ import {
openScopesSelector,
searchScopes,
selectScope,
setupScopeRoutes,
} from '../utils/scope-helpers';
import { testScopes } from '../utils/scopes';
@ -36,32 +37,37 @@ test.describe(
const scopesSelector = getScopesSelectorInput(page);
const recentScopesSelector = getRecentScopesSelector(page);
const scopeTreeCheckboxes = getScopeTreeCheckboxes(page);
const scopes = testScopes();
// Set up routes once before any navigation (only for mocked mode)
if (!USE_LIVE_DATA) {
await setupScopeRoutes(page, scopes);
}
await test.step('1.View and select any scope', async () => {
await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST });
expect.soft(scopesSelector).toHaveAttribute('data-value', '');
const scopes = testScopes();
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes);
let scopeName = await getScopeTreeName(page, 0);
const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version
const firstLevelScopes = scopes[0].children!;
await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes);
scopeName = await getScopeTreeName(page, 1);
const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version
const secondLevelScopes = firstLevelScopes[0].children!;
await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes);
const selectedScopes = [secondLevelScopes[0]]; //used only in mocked scopes version
const selectedScopes = [secondLevelScopes[0]];
scopeName = await getScopeLeafName(page, 0);
let scopeTitle = await getScopeLeafTitle(page, 0);
await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[0]);
await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version
await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes);
expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitle);
});
@ -70,28 +76,27 @@ test.describe(
await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST });
expect.soft(scopesSelector).toHaveAttribute('data-value', '');
const scopes = testScopes();
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes);
let scopeName = await getScopeTreeName(page, 0);
const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version
const firstLevelScopes = scopes[0].children!;
await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes);
scopeName = await getScopeTreeName(page, 1);
const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version
const secondLevelScopes = firstLevelScopes[0].children!;
await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes);
const scopeTitles: string[] = [];
const selectedScopes = [secondLevelScopes[0], secondLevelScopes[1]]; //used only in mocked scopes version
const selectedScopes = [secondLevelScopes[0], secondLevelScopes[1]];
for (let i = 0; i < selectedScopes.length; i++) {
scopeName = await getScopeLeafName(page, i);
scopeTitles.push(await getScopeLeafTitle(page, i));
await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[i]); //used only in mocked scopes version
await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[i]);
}
await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version
await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes);
await expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitles.join(' + '));
});
@ -102,8 +107,7 @@ test.describe(
expect.soft(scopesSelector).toHaveAttribute('data-value', '');
const scopes = testScopes();
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes);
await recentScopesSelector.click();
@ -121,26 +125,25 @@ test.describe(
expect.soft(scopesSelector).toHaveAttribute('data-value', '');
const scopes = testScopes();
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes);
let scopeName = await getScopeTreeName(page, 1);
const firstLevelScopes = scopes[2].children!; //used only in mocked scopes version
const firstLevelScopes = scopes[2].children!;
await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes);
scopeName = await getScopeTreeName(page, 1);
const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version
const secondLevelScopes = firstLevelScopes[0].children!;
await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes);
const selectedScopes = [secondLevelScopes[0]]; //used only in mocked scopes version
const selectedScopes = [secondLevelScopes[0]];
scopeName = await getScopeLeafName(page, 0);
let scopeTitle = await getScopeLeafTitle(page, 0);
await selectScope(page, scopeName, USE_LIVE_DATA ? undefined : selectedScopes[0]);
await applyScopes(page, USE_LIVE_DATA ? undefined : []); //used only in mocked scopes version
await applyScopes(page, USE_LIVE_DATA ? undefined : []);
expect.soft(scopesSelector).toHaveAttribute('data-value', new RegExp(`^${scopeTitle}`));
});
@ -148,17 +151,16 @@ test.describe(
await test.step('5.View pre-completed production entity values as I type', async () => {
await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST });
const scopes = testScopes();
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version
await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes);
let scopeName = await getScopeTreeName(page, 0);
const firstLevelScopes = scopes[0].children!; //used only in mocked scopes version
const firstLevelScopes = scopes[0].children!;
await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : firstLevelScopes);
scopeName = await getScopeTreeName(page, 1);
const secondLevelScopes = firstLevelScopes[0].children!; //used only in mocked scopes version
const secondLevelScopes = firstLevelScopes[0].children!;
await expandScopesSelection(page, scopeName, USE_LIVE_DATA ? undefined : secondLevelScopes);
const scopeSearchOne = await getScopeLeafTitle(page, 0);

View file

@ -1,6 +1,6 @@
import { test, expect } from '@grafana/plugin-e2e';
import { applyScopes, openScopesSelector, selectScope } from '../utils/scope-helpers';
import { applyScopes, openScopesSelector, selectScope, setupScopeRoutes } from '../utils/scope-helpers';
import { testScopesWithRedirect } from '../utils/scopes';
test.use({
@ -16,8 +16,13 @@ test.describe('Scope Redirect Functionality', () => {
test('should redirect to custom URL when scope has redirectUrl', async ({ page, gotoDashboardPage }) => {
const scopes = testScopesWithRedirect();
await test.step('Navigate to dashboard and open scopes selector', async () => {
await test.step('Set up routes and navigate to dashboard', async () => {
// Set up routes BEFORE navigation to ensure all requests are mocked
await setupScopeRoutes(page, scopes);
await gotoDashboardPage({ uid: 'cuj-dashboard-1' });
});
await test.step('Open scopes selector', async () => {
await openScopesSelector(page, scopes);
});
@ -40,8 +45,12 @@ test.describe('Scope Redirect Functionality', () => {
test('should prioritize redirectUrl over scope navigation fallback', async ({ page, gotoDashboardPage }) => {
const scopes = testScopesWithRedirect();
await test.step('Navigate to dashboard and open scopes selector', async () => {
await test.step('Set up routes and navigate to dashboard', async () => {
await setupScopeRoutes(page, scopes);
await gotoDashboardPage({ uid: 'cuj-dashboard-1' });
});
await test.step('Open scopes selector', async () => {
await openScopesSelector(page, scopes);
});
@ -68,8 +77,12 @@ test.describe('Scope Redirect Functionality', () => {
}) => {
const scopes = testScopesWithRedirect();
await test.step('Navigate to dashboard and select scope', async () => {
await test.step('Set up routes and navigate to dashboard', async () => {
await setupScopeRoutes(page, scopes);
await gotoDashboardPage({ uid: 'cuj-dashboard-1' });
});
await test.step('Select and apply scope', async () => {
await openScopesSelector(page, scopes);
await selectScope(page, 'sn-redirect-fallback', scopes[1]);
await applyScopes(page, [scopes[1]]);
@ -112,8 +125,12 @@ test.describe('Scope Redirect Functionality', () => {
}) => {
const scopes = testScopesWithRedirect();
await test.step('Navigate to dashboard and select scope', async () => {
await test.step('Set up routes and navigate to dashboard', async () => {
await setupScopeRoutes(page, scopes);
await gotoDashboardPage({ uid: 'cuj-dashboard-1' });
});
await test.step('Select and apply scope', async () => {
await openScopesSelector(page, scopes);
await selectScope(page, 'sn-redirect-fallback', scopes[1]);
await applyScopes(page, [scopes[1]]);
@ -151,9 +168,13 @@ test.describe('Scope Redirect Functionality', () => {
test('should not redirect to redirectPath when on active scope navigation', async ({ page, gotoDashboardPage }) => {
const scopes = testScopesWithRedirect();
await test.step('Set up routes and navigate to dashboard', async () => {
await setupScopeRoutes(page, scopes);
await gotoDashboardPage({ uid: 'cuj-dashboard-1' });
});
await test.step('Set up scope navigation to dashboard-1', async () => {
// First, apply a scope that creates scope navigation to dashboard-1 (without redirectPath)
await gotoDashboardPage({ uid: 'cuj-dashboard-1' });
await openScopesSelector(page, scopes);
await selectScope(page, 'sn-redirect-setup', scopes[2]);
await applyScopes(page, [scopes[2]]);

View file

@ -6,7 +6,150 @@ import { Resource } from '../../public/app/features/apiserver/types';
import { testScopes } from './scopes';
const USE_LIVE_DATA = Boolean(process.env.API_CALLS_CONFIG_PATH);
const USE_LIVE_DATA = Boolean(process.env.API_CONFIG_PATH);
/**
* Sets up all scope-related API routes before navigation.
* This ensures that ALL scope API requests (including those made during initial page load)
* are intercepted by the mocks, preventing RTK Query from caching real API responses.
*
* Call this BEFORE navigating to a page (e.g., before gotoDashboardPage).
*/
export async function setupScopeRoutes(page: Page, scopes: TestScope[]): Promise<void> {
// Route for scope node children (tree structure)
await page.route(`**/apis/scope.grafana.app/v0alpha1/namespaces/*/find/scope_node_children*`, async (route) => {
const url = new URL(route.request().url());
const parentParam = url.searchParams.get('parent');
const queryParam = url.searchParams.get('query');
// Find the appropriate scopes based on parent
let scopesToReturn = scopes;
if (parentParam) {
// Find nested scopes based on parent name
const findChildren = (items: TestScope[]): TestScope[] => {
for (const item of items) {
if (item.name === parentParam && item.children) {
return item.children;
}
if (item.children) {
const found = findChildren(item.children);
if (found.length > 0) {
return found;
}
}
}
return [];
};
scopesToReturn = findChildren(scopes);
if (scopesToReturn.length === 0) {
scopesToReturn = scopes; // Fallback to root scopes
}
}
// Filter by search query if provided
if (queryParam) {
const query = queryParam.toLowerCase();
const filterByQuery = (items: TestScope[]): TestScope[] => {
const results: TestScope[] = [];
for (const item of items) {
// Exact match on name or title containing the query
if (item.name.toLowerCase() === query || item.title.toLowerCase() === query) {
results.push(item);
} else if (item.name.toLowerCase().includes(query) || item.title.toLowerCase().includes(query)) {
results.push(item);
}
// Also search in children
if (item.children) {
results.push(...filterByQuery(item.children));
}
}
return results;
};
scopesToReturn = filterByQuery(scopesToReturn);
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
apiVersion: 'scope.grafana.app/v0alpha1',
kind: 'FindScopeNodeChildrenResults',
metadata: {},
items: scopesToReturn.map((scope) => ({
kind: 'ScopeNode',
apiVersion: 'scope.grafana.app/v0alpha1',
metadata: {
name: scope.name,
namespace: 'default',
},
spec: {
title: scope.title,
description: scope.title,
disableMultiSelect: scope.disableMultiSelect ?? false,
nodeType: scope.children ? 'container' : 'leaf',
...(parentParam && { parentName: parentParam }),
...((scope.addLinks || scope.children) && {
linkType: 'scope',
linkId: `scope-${scope.name}`,
}),
...(scope.redirectPath && { redirectPath: scope.redirectPath }),
},
})),
}),
});
});
// Route for individual scope fetching
await page.route(`**/apis/scope.grafana.app/v0alpha1/namespaces/*/scopes/*`, async (route) => {
const url = route.request().url();
const scopeName = url.split('/scopes/')[1]?.split('?')[0];
// Find the scope in the test data
const findScope = (items: TestScope[]): TestScope | undefined => {
for (const item of items) {
if (`scope-${item.name}` === scopeName) {
return item;
}
if (item.children) {
const found = findScope(item.children);
if (found) {
return found;
}
}
}
return undefined;
};
const scope = findScope(scopes);
if (scope) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
kind: 'Scope',
apiVersion: 'scope.grafana.app/v0alpha1',
metadata: {
name: `scope-${scope.name}`,
namespace: 'default',
},
spec: {
title: scope.title,
description: '',
filters: scope.filters,
category: scope.category,
type: scope.type,
},
}),
});
} else {
await route.fulfill({ status: 404 });
}
});
// Note: Dashboard bindings and navigations routes are set up dynamically in applyScopes()
// with scope-specific URL patterns to avoid cache issues. They are not set up here.
}
export type TestScope = {
name: string;
@ -24,6 +167,9 @@ export type TestScope = {
type ScopeDashboardBinding = Resource<ScopeDashboardBindingSpec, ScopeDashboardBindingStatus, 'ScopeDashboardBinding'>;
/**
* Sets up a route for scope node children requests and waits for the response.
*/
export async function scopeNodeChildrenRequest(
page: Page,
scopes: TestScope[],
@ -68,10 +214,13 @@ export async function scopeNodeChildrenRequest(
return page.waitForResponse((response) => response.url().includes(`/find/scope_node_children`));
}
/**
* Opens the scopes selector dropdown and waits for the tree to load.
*/
export async function openScopesSelector(page: Page, scopes?: TestScope[]) {
const click = async () => await page.getByTestId('scopes-selector-input').click();
if (!scopes) {
if (!scopes || USE_LIVE_DATA) {
await click();
return;
}
@ -82,10 +231,13 @@ export async function openScopesSelector(page: Page, scopes?: TestScope[]) {
await responsePromise;
}
/**
* Expands a scope tree node and waits for children to load.
*/
export async function expandScopesSelection(page: Page, parentScope: string, scopes?: TestScope[]) {
const click = async () => await page.getByTestId(`scopes-tree-${parentScope}-expand`).click();
if (!scopes) {
if (!scopes || USE_LIVE_DATA) {
await click();
return;
}
@ -96,6 +248,9 @@ export async function expandScopesSelection(page: Page, parentScope: string, sco
await responsePromise;
}
/**
* Sets up a route for individual scope requests and waits for the response.
*/
export async function scopeSelectRequest(page: Page, selectedScope: TestScope): Promise<Response> {
await page.route(
`**/apis/scope.grafana.app/v0alpha1/namespaces/*/scopes/scope-${selectedScope.name}`,
@ -125,6 +280,9 @@ export async function scopeSelectRequest(page: Page, selectedScope: TestScope):
return page.waitForResponse((response) => response.url().includes(`/scopes/scope-${selectedScope.name}`));
}
/**
* Selects a scope in the tree.
*/
export async function selectScope(page: Page, scopeName: string, selectedScope?: TestScope) {
const click = async () => {
const element = page.locator(
@ -134,7 +292,7 @@ export async function selectScope(page: Page, scopeName: string, selectedScope?:
await element.click({ force: true });
};
if (!selectedScope) {
if (!selectedScope || USE_LIVE_DATA) {
await click();
return;
}
@ -145,14 +303,22 @@ export async function selectScope(page: Page, scopeName: string, selectedScope?:
await responsePromise;
}
/**
* Applies the selected scopes and waits for the selector to close and page to settle.
* Sets up routes dynamically with scope-specific URL patterns to avoid cache issues.
*/
export async function applyScopes(page: Page, scopes?: TestScope[]) {
const click = async () => {
await page.getByTestId('scopes-selector-apply').scrollIntoViewIfNeeded();
await page.getByTestId('scopes-selector-apply').click({ force: true });
};
if (!scopes) {
if (!scopes || USE_LIVE_DATA) {
await click();
// Wait for the apply button to disappear (selector closed)
await page.waitForSelector('[data-testid="scopes-selector-apply"]', { state: 'hidden', timeout: 5000 });
// Wait for any resulting API calls (dashboard bindings, etc.) to complete
await page.waitForLoadState('networkidle');
return;
}
@ -166,7 +332,7 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) {
const groups: string[] = ['Most relevant', 'Dashboards', 'Something else', ''];
// Mock scope_dashboard_bindings endpoint
// Mock scope_dashboard_bindings endpoint with scope-specific URL pattern
await page.route(dashboardBindingsUrl, async (route) => {
await route.fulfill({
status: 200,
@ -220,7 +386,7 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) {
});
});
// Mock scope_navigations endpoint
// Mock scope_navigations endpoint with scope-specific URL pattern
await page.route(scopeNavigationsUrl, async (route) => {
await route.fulfill({
status: 200,
@ -266,21 +432,23 @@ export async function applyScopes(page: Page, scopes?: TestScope[]) {
(response) =>
response.url().includes(`/find/scope_dashboard_bindings`) || response.url().includes(`/find/scope_navigations`)
);
const scopeRequestPromises: Array<Promise<Response>> = [];
for (const scope of scopes) {
scopeRequestPromises.push(scopeSelectRequest(page, scope));
}
await click();
await responsePromise;
await Promise.all(scopeRequestPromises);
// Wait for the apply button to disappear (selector closed)
await page.waitForSelector('[data-testid="scopes-selector-apply"]', { state: 'hidden', timeout: 5000 });
// Wait for any resulting API calls to complete
await page.waitForLoadState('networkidle');
}
export async function searchScopes(page: Page, value: string, resultScopes: TestScope[]) {
/**
* Searches for scopes in the tree and waits for results.
* Sets up a route dynamically with filtered results to return only matching scopes.
*/
export async function searchScopes(page: Page, value: string, resultScopes?: TestScope[]) {
const click = async () => await page.getByTestId('scopes-tree-search').fill(value);
if (!resultScopes) {
if (!resultScopes || USE_LIVE_DATA) {
await click();
return;
}

View file

@ -34,6 +34,8 @@ export function createBaseQuery({ baseURL }: CreateBaseQueryOptions): BaseQueryF
getBackendSrv().fetch({
...requestOptions,
url: baseURL + requestOptions.url,
// Default to GET so backend_srv correctly skips success alerts for queries
method: requestOptions.method ?? 'GET',
showErrorAlert: requestOptions.showErrorAlert ?? false,
data: requestOptions.body,
headers,

View file

@ -0,0 +1,500 @@
/**
* Types for Scopes API - matching @grafana/data types
*/
export interface ScopeFilter {
key: string;
value: string;
operator: 'equals' | 'not-equals' | 'regex-match' | 'regex-not-match';
}
export interface ScopeSpec {
title: string;
filters: ScopeFilter[];
}
export interface Scope {
metadata: {
name: string;
};
spec: ScopeSpec;
}
export interface ScopeNodeSpec {
nodeType: 'container' | 'leaf';
title: string;
description?: string;
disableMultiSelect?: boolean;
linkType?: 'scope';
linkId?: string;
parentName: string;
}
export interface ScopeNode {
metadata: {
name: string;
};
spec: ScopeNodeSpec;
}
export interface ScopeDashboardBindingSpec {
dashboard: string;
scope: string;
}
export interface ScopeDashboardBindingStatus {
dashboardTitle: string;
groups?: string[];
}
export interface ScopeDashboardBinding {
metadata: {
name: string;
};
spec: ScopeDashboardBindingSpec;
status: ScopeDashboardBindingStatus;
}
export interface ScopeNavigation {
metadata: {
name: string;
};
spec: {
url: string;
scope: string;
subScope?: string;
preLoadSubScopeChildren?: boolean;
expandOnLoad?: boolean;
disableSubScopeSelection?: boolean;
};
status: {
title: string;
groups?: string[];
};
}
export const MOCK_SCOPES: Scope[] = [
{
metadata: { name: 'cloud' },
spec: {
title: 'Cloud',
filters: [{ key: 'cloud', value: '.*', operator: 'regex-match' }],
},
},
{
metadata: { name: 'dev' },
spec: {
title: 'Dev',
filters: [{ key: 'cloud', value: 'dev', operator: 'equals' }],
},
},
{
metadata: { name: 'ops' },
spec: {
title: 'Ops',
filters: [{ key: 'cloud', value: 'ops', operator: 'equals' }],
},
},
{
metadata: { name: 'prod' },
spec: {
title: 'Prod',
filters: [{ key: 'cloud', value: 'prod', operator: 'equals' }],
},
},
{
metadata: { name: 'grafana' },
spec: {
title: 'Grafana',
filters: [{ key: 'app', value: 'grafana', operator: 'equals' }],
},
},
{
metadata: { name: 'mimir' },
spec: {
title: 'Mimir',
filters: [{ key: 'app', value: 'mimir', operator: 'equals' }],
},
},
{
metadata: { name: 'loki' },
spec: {
title: 'Loki',
filters: [{ key: 'app', value: 'loki', operator: 'equals' }],
},
},
{
metadata: { name: 'tempo' },
spec: {
title: 'Tempo',
filters: [{ key: 'app', value: 'tempo', operator: 'equals' }],
},
},
{
metadata: { name: 'dev-env' },
spec: {
title: 'Development',
filters: [{ key: 'environment', value: 'dev', operator: 'equals' }],
},
},
{
metadata: { name: 'prod-env' },
spec: {
title: 'Production',
filters: [{ key: 'environment', value: 'prod', operator: 'equals' }],
},
},
];
const dashboardBindingsGenerator = (
scopes: string[],
dashboards: Array<{ dashboardTitle: string; dashboardKey?: string; groups?: string[] }>
) =>
scopes.reduce<ScopeDashboardBinding[]>((scopeAcc, scopeTitle) => {
const scope = scopeTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-');
return [
...scopeAcc,
...dashboards.reduce<ScopeDashboardBinding[]>((acc, { dashboardTitle, groups, dashboardKey }, idx) => {
dashboardKey = dashboardKey ?? dashboardTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-');
const group = !groups
? ''
: groups.length === 1
? groups[0] === ''
? ''
: `${groups[0].toLowerCase().replaceAll(' ', '-').replaceAll('/', '-')}-`
: `multiple${idx}-`;
const dashboard = `${group}${dashboardKey}`;
return [
...acc,
{
metadata: { name: `${scope}-${dashboard}` },
spec: {
dashboard,
scope,
},
status: {
dashboardTitle,
groups,
},
},
];
}, []),
];
}, []);
export const MOCK_SCOPE_DASHBOARD_BINDINGS: ScopeDashboardBinding[] = [
...dashboardBindingsGenerator(
['Grafana'],
[
{ dashboardTitle: 'Data Sources', groups: ['General'] },
{ dashboardTitle: 'Usage', groups: ['General'] },
{ dashboardTitle: 'Frontend Errors', groups: ['Observability'] },
{ dashboardTitle: 'Frontend Logs', groups: ['Observability'] },
{ dashboardTitle: 'Backend Errors', groups: ['Observability'] },
{ dashboardTitle: 'Backend Logs', groups: ['Observability'] },
{ dashboardTitle: 'Usage Overview', groups: ['Usage'] },
{ dashboardTitle: 'Data Sources', groups: ['Usage'] },
{ dashboardTitle: 'Stats', groups: ['Usage'] },
{ dashboardTitle: 'Overview', groups: [''] },
{ dashboardTitle: 'Frontend' },
{ dashboardTitle: 'Stats' },
]
),
...dashboardBindingsGenerator(
['Loki', 'Tempo', 'Mimir'],
[
{ dashboardTitle: 'Ingester', groups: ['Components', 'Investigations'] },
{ dashboardTitle: 'Distributor', groups: ['Components', 'Investigations'] },
{ dashboardTitle: 'Compacter', groups: ['Components', 'Investigations'] },
{ dashboardTitle: 'Datasource Errors', groups: ['Observability', 'Investigations'] },
{ dashboardTitle: 'Datasource Logs', groups: ['Observability', 'Investigations'] },
{ dashboardTitle: 'Overview' },
{ dashboardTitle: 'Stats', dashboardKey: 'another-stats' },
]
),
...dashboardBindingsGenerator(
['Dev', 'Ops', 'Prod'],
[
{ dashboardTitle: 'Overview', groups: ['Cardinality Management'] },
{ dashboardTitle: 'Metrics', groups: ['Cardinality Management'] },
{ dashboardTitle: 'Labels', groups: ['Cardinality Management'] },
{ dashboardTitle: 'Overview', groups: ['Usage Insights'] },
{ dashboardTitle: 'Data Sources', groups: ['Usage Insights'] },
{ dashboardTitle: 'Query Errors', groups: ['Usage Insights'] },
{ dashboardTitle: 'Alertmanager', groups: ['Usage Insights'] },
{ dashboardTitle: 'Metrics Ingestion', groups: ['Usage Insights'] },
{ dashboardTitle: 'Billing/Usage' },
]
),
];
export const MOCK_NODES: ScopeNode[] = [
{
metadata: { name: 'applications' },
spec: {
nodeType: 'container',
title: 'Applications',
description: 'Application Scopes',
parentName: '',
},
},
{
metadata: { name: 'cloud' },
spec: {
nodeType: 'container',
title: 'Cloud',
description: 'Cloud Scopes',
disableMultiSelect: true,
linkType: 'scope',
linkId: 'cloud',
parentName: '',
},
},
{
metadata: { name: 'applications-grafana' },
spec: {
nodeType: 'leaf',
title: 'Grafana',
description: 'Grafana',
linkType: 'scope',
linkId: 'grafana',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-mimir' },
spec: {
nodeType: 'leaf',
title: 'Mimir',
description: 'Mimir',
linkType: 'scope',
linkId: 'mimir',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-loki' },
spec: {
nodeType: 'leaf',
title: 'Loki',
description: 'Loki',
linkType: 'scope',
linkId: 'loki',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-tempo' },
spec: {
nodeType: 'leaf',
title: 'Tempo',
description: 'Tempo',
linkType: 'scope',
linkId: 'tempo',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-cloud' },
spec: {
nodeType: 'container',
title: 'Cloud',
description: 'Application/Cloud Scopes',
linkType: 'scope',
linkId: 'cloud',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-cloud-dev' },
spec: {
nodeType: 'leaf',
title: 'Dev',
description: 'Dev',
linkType: 'scope',
linkId: 'dev',
parentName: 'applications-cloud',
},
},
{
metadata: { name: 'applications-cloud-ops' },
spec: {
nodeType: 'leaf',
title: 'Ops',
description: 'Ops',
linkType: 'scope',
linkId: 'ops',
parentName: 'applications-cloud',
},
},
{
metadata: { name: 'applications-cloud-prod' },
spec: {
nodeType: 'leaf',
title: 'Prod',
description: 'Prod',
linkType: 'scope',
linkId: 'prod',
parentName: 'applications-cloud',
},
},
{
metadata: { name: 'cloud-dev' },
spec: {
nodeType: 'leaf',
title: 'Dev',
description: 'Dev',
linkType: 'scope',
linkId: 'dev',
parentName: 'cloud',
},
},
{
metadata: { name: 'cloud-ops' },
spec: {
nodeType: 'leaf',
title: 'Ops',
description: 'Ops',
linkType: 'scope',
linkId: 'ops',
parentName: 'cloud',
},
},
{
metadata: { name: 'cloud-prod' },
spec: {
nodeType: 'leaf',
title: 'Prod',
description: 'Prod',
linkType: 'scope',
linkId: 'prod',
parentName: 'cloud',
},
},
{
metadata: { name: 'cloud-applications' },
spec: {
nodeType: 'container',
title: 'Applications',
description: 'Cloud/Application Scopes',
parentName: 'cloud',
},
},
{
metadata: { name: 'cloud-applications-grafana' },
spec: {
nodeType: 'leaf',
title: 'Grafana',
description: 'Grafana',
linkType: 'scope',
linkId: 'grafana',
parentName: 'cloud-applications',
},
},
{
metadata: { name: 'cloud-applications-mimir' },
spec: {
nodeType: 'leaf',
title: 'Mimir',
description: 'Mimir',
linkType: 'scope',
linkId: 'mimir',
parentName: 'cloud-applications',
},
},
{
metadata: { name: 'cloud-applications-loki' },
spec: {
nodeType: 'leaf',
title: 'Loki',
description: 'Loki',
linkType: 'scope',
linkId: 'loki',
parentName: 'cloud-applications',
},
},
{
metadata: { name: 'cloud-applications-tempo' },
spec: {
nodeType: 'leaf',
title: 'Tempo',
description: 'Tempo',
linkType: 'scope',
linkId: 'tempo',
parentName: 'cloud-applications',
},
},
{
metadata: { name: 'environments' },
spec: {
nodeType: 'container',
title: 'Environments',
description: 'Environment Scopes',
disableMultiSelect: true,
parentName: '',
},
},
{
metadata: { name: 'environments-dev' },
spec: {
nodeType: 'container',
title: 'Development',
description: 'Development Environment',
linkType: 'scope',
linkId: 'dev-env',
parentName: 'environments',
},
},
{
metadata: { name: 'environments-prod' },
spec: {
nodeType: 'container',
title: 'Production',
description: 'Production Environment',
linkType: 'scope',
linkId: 'prod-env',
parentName: 'environments',
},
},
];
export const MOCK_SUB_SCOPE_MIMIR_ITEMS: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-1',
},
status: {
title: 'Mimir Dashboard 1',
groups: ['General'],
},
},
{
metadata: { name: 'mimir-item-2' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-2',
},
status: {
title: 'Mimir Dashboard 2',
groups: ['Observability'],
},
},
];
export const MOCK_SUB_SCOPE_LOKI_ITEMS: ScopeNavigation[] = [
{
metadata: { name: 'loki-item-1' },
spec: {
scope: 'loki',
url: '/d/loki-dashboard-1',
},
status: {
title: 'Loki Dashboard 1',
groups: ['General'],
},
},
];

View file

@ -12,6 +12,7 @@ import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v
import appPlatformDashboardv1beta1Handlers from './apis/dashboard.grafana.app/v1beta1/handlers';
import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers';
import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers';
import appPlatformScopev0alpha1Handlers from './apis/scope.grafana.app/v0alpha1/handlers';
const allHandlers: HttpHandler[] = [
// Legacy handlers
@ -29,6 +30,7 @@ const allHandlers: HttpHandler[] = [
...appPlatformFolderv1beta1Handlers,
...appPlatformIamv0alpha1Handlers,
...appPlatformCollectionsv1alpha1Handlers,
...appPlatformScopev0alpha1Handlers,
];
export default allHandlers;

View file

@ -0,0 +1,131 @@
import { HttpResponse, http } from 'msw';
import {
MOCK_NODES,
MOCK_SCOPES,
MOCK_SCOPE_DASHBOARD_BINDINGS,
MOCK_SUB_SCOPE_LOKI_ITEMS,
MOCK_SUB_SCOPE_MIMIR_ITEMS,
ScopeNavigation,
} from '../../../../fixtures/scopes';
import { getErrorResponse } from '../../../helpers';
const API_BASE = '/apis/scope.grafana.app/v0alpha1/namespaces/:namespace';
/**
* GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/scopes/:name
*
* Fetches a single scope by name.
*/
const getScopeHandler = () =>
http.get<{ namespace: string; name: string }>(`${API_BASE}/scopes/:name`, ({ params }) => {
const { name } = params;
const scope = MOCK_SCOPES.find((s) => s.metadata.name === name);
if (!scope) {
return HttpResponse.json(getErrorResponse(`scopes.scope.grafana.app "${name}" not found`, 404), {
status: 404,
});
}
return HttpResponse.json(scope);
});
/**
* GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/scopenodes/:name
*
* Fetches a single scope node by name.
*/
const getScopeNodeHandler = () =>
http.get<{ namespace: string; name: string }>(`${API_BASE}/scopenodes/:name`, ({ params }) => {
const { name } = params;
const node = MOCK_NODES.find((n) => n.metadata.name === name);
if (!node) {
return HttpResponse.json(getErrorResponse(`scopenodes.scope.grafana.app "${name}" not found`, 404), {
status: 404,
});
}
return HttpResponse.json(node);
});
/**
* GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_node_children
*
* Finds scope node children based on parent and query filters.
*/
const findScopeNodeChildrenHandler = () =>
http.get(`${API_BASE}/find/scope_node_children`, ({ request }) => {
const url = new URL(request.url);
const parent = url.searchParams.get('parent') ?? '';
const query = url.searchParams.get('query') ?? '';
const limitParam = url.searchParams.get('limit');
const names = url.searchParams.getAll('names');
let filtered = MOCK_NODES.filter(
(node) => node.spec.parentName === parent && node.spec.title.toLowerCase().includes(query.toLowerCase())
);
if (names.length > 0) {
filtered = MOCK_NODES.filter((node) => names.includes(node.metadata.name));
}
if (limitParam) {
const limit = parseInt(limitParam, 10);
filtered = filtered.slice(0, limit);
}
return HttpResponse.json({
items: filtered,
});
});
/**
* GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_dashboard_bindings
*
* Finds scope dashboard bindings for the given scope names.
*/
const findScopeDashboardBindingsHandler = () =>
http.get(`${API_BASE}/find/scope_dashboard_bindings`, ({ request }) => {
const url = new URL(request.url);
const scopeNames = url.searchParams.getAll('scope');
const bindings = MOCK_SCOPE_DASHBOARD_BINDINGS.filter((b) => scopeNames.includes(b.spec.scope));
return HttpResponse.json({
items: bindings,
});
});
/**
* GET /apis/scope.grafana.app/v0alpha1/namespaces/:namespace/find/scope_navigations
*
* Finds scope navigations for the given scope names.
*/
const findScopeNavigationsHandler = () =>
http.get(`${API_BASE}/find/scope_navigations`, ({ request }) => {
const url = new URL(request.url);
const scopeNames = url.searchParams.getAll('scope');
let items: ScopeNavigation[] = [];
if (scopeNames.includes('mimir')) {
items = [...items, ...MOCK_SUB_SCOPE_MIMIR_ITEMS];
}
if (scopeNames.includes('loki')) {
items = [...items, ...MOCK_SUB_SCOPE_LOKI_ITEMS];
}
return HttpResponse.json({
items,
});
});
export default [
getScopeHandler(),
getScopeNodeHandler(),
findScopeNodeChildrenHandler(),
findScopeDashboardBindingsHandler(),
findScopeNavigationsHandler(),
];

View file

@ -2,3 +2,12 @@ import { wellFormedTree } from './fixtures/folders';
export const getFolderFixtures = wellFormedTree;
export { MOCK_TEAMS, MOCK_TEAM_GROUPS } from './fixtures/teams';
export {
MOCK_SCOPES,
MOCK_NODES,
MOCK_SCOPE_DASHBOARD_BINDINGS,
MOCK_SUB_SCOPE_MIMIR_ITEMS,
MOCK_SUB_SCOPE_LOKI_ITEMS,
} from './fixtures/scopes';
export { default as allHandlers } from './handlers/all-handlers';
export { default as scopeHandlers } from './handlers/apis/scope.grafana.app/v0alpha1/handlers';

View file

@ -0,0 +1,16 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { getAPIBaseURL } from '@grafana/api-clients';
import { createBaseQuery } from '@grafana/api-clients/rtkq';
export const API_GROUP = 'scope.grafana.app' as const;
export const API_VERSION = 'v0alpha1' as const;
export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION);
export const api = createApi({
reducerPath: 'scopeAPIv0alpha1',
baseQuery: createBaseQuery({
baseURL: BASE_URL,
}),
endpoints: () => ({}),
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,3 @@
import { generatedAPI } from './endpoints.gen';
export const scopeAPIv0alpha1 = generatedAPI;

View file

@ -0,0 +1,43 @@
#!/bin/bash
# Syncs the scope API client from Enterprise to OSS.
#
# This script:
# 1. Regenerates the Enterprise API client from the OpenAPI spec
# 2. Copies the generated endpoints.gen.ts to OSS
#
# Prerequisites:
# - The OpenAPI spec must exist at data/openapi/scope.grafana.app-v0alpha1.json
# (generated by running TestIntegrationOpenAPIs in pkg/extensions/apiserver/tests/)
#
# Usage: ./sync-from-enterprise.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GRAFANA_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)"
# Source and destination directories for the generated API client
ENTERPRISE_SCOPE_API_DIR="$GRAFANA_ROOT/public/app/extensions/api/clients/scope/v0alpha1"
OSS_SCOPE_API_DIR="$SCRIPT_DIR"
cd "$GRAFANA_ROOT"
# Check if OpenAPI spec exists
if [ ! -f "data/openapi/scope.grafana.app-v0alpha1.json" ]; then
echo "Error: OpenAPI spec not found at data/openapi/scope.grafana.app-v0alpha1.json"
echo "Run TestIntegrationOpenAPIs in pkg/extensions/apiserver/tests/ to generate it."
exit 1
fi
echo "Step 1: Generating Enterprise API client from OpenAPI spec..."
yarn workspace @grafana/api-clients process-specs && npx rtk-query-codegen-openapi ./local/generate-enterprise-apis.ts
if [ ! -f "$ENTERPRISE_SCOPE_API_DIR/endpoints.gen.ts" ]; then
echo "Error: Enterprise endpoints.gen.ts not found after generation"
exit 1
fi
echo "Step 2: Copying endpoints.gen.ts from Enterprise to OSS..."
cp "$ENTERPRISE_SCOPE_API_DIR/endpoints.gen.ts" "$OSS_SCOPE_API_DIR/endpoints.gen.ts"
echo "Done! Scope API client synced from Enterprise."

View file

@ -3,6 +3,7 @@ import { AnyAction, combineReducers } from 'redux';
import { allReducers as allApiClientReducers } from '@grafana/api-clients/rtkq';
import { generatedAPI as legacyAPI } from '@grafana/api-clients/rtkq/legacy';
import { scopeAPIv0alpha1 } from 'app/api/clients/scope/v0alpha1';
import sharedReducers from 'app/core/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
import alertingReducers from 'app/features/alerting/state/reducers';
@ -52,6 +53,7 @@ const rootReducers = {
[alertingApi.reducerPath]: alertingApi.reducer,
[publicDashboardApi.reducerPath]: publicDashboardApi.reducer,
[browseDashboardsAPI.reducerPath]: browseDashboardsAPI.reducer,
[scopeAPIv0alpha1.reducerPath]: scopeAPIv0alpha1.reducer,
...allApiClientReducers,
};

View file

@ -1,74 +1,194 @@
import { getBackendSrv, config } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { MOCK_NODES, MOCK_SCOPES } from '@grafana/test-utils/unstable';
import { scopeAPIv0alpha1 } from 'app/api/clients/scope/v0alpha1';
import { ScopesApiClient } from './ScopesApiClient';
// Mock the runtime dependencies
jest.mock('@grafana/runtime', () => ({
getBackendSrv: jest.fn(),
config: {
featureToggles: {
useMultipleScopeNodesEndpoint: true,
useScopeSingleNodeEndpoint: true,
// Helper to create a mock subscription with unsubscribe method
const createMockSubscription = <T>(data: T): Promise<T> & { unsubscribe: jest.Mock } => {
const subscription = Promise.resolve(data) as Promise<T> & { unsubscribe: jest.Mock };
subscription.unsubscribe = jest.fn();
return subscription;
};
// Mock the RTK Query API and dispatch
jest.mock('app/api/clients/scope/v0alpha1', () => ({
scopeAPIv0alpha1: {
endpoints: {
getScope: {
initiate: jest.fn(),
},
getScopeNode: {
initiate: jest.fn(),
},
getFindScopeNodeChildrenResults: {
initiate: jest.fn(),
},
getFindScopeDashboardBindingsResults: {
initiate: jest.fn(),
},
getFindScopeNavigationsResults: {
initiate: jest.fn(),
},
},
},
}));
jest.mock('@grafana/api-clients', () => ({
getAPIBaseURL: jest.fn().mockReturnValue('/apis/scope.grafana.app/v0alpha1'),
jest.mock('app/store/store', () => ({
dispatch: jest.fn((action) => action),
}));
describe('ScopesApiClient', () => {
let apiClient: ScopesApiClient;
let mockBackendSrv: jest.Mocked<{ get: jest.Mock }>;
beforeEach(() => {
mockBackendSrv = {
get: jest.fn(),
};
(getBackendSrv as jest.Mock).mockReturnValue(mockBackendSrv);
apiClient = new ScopesApiClient();
config.featureToggles.useMultipleScopeNodesEndpoint = true;
config.featureToggles.useScopeSingleNodeEndpoint = true;
jest.clearAllMocks();
});
afterEach(() => {
jest.clearAllMocks();
});
describe('fetchScope', () => {
it('should fetch a scope by name', async () => {
// Expected: MOCK_SCOPES contains a scope with name 'grafana'
const expectedScope = MOCK_SCOPES.find((s) => s.metadata.name === 'grafana');
expect(expectedScope).toBeDefined();
const mockSubscription = createMockSubscription({ data: expectedScope });
(scopeAPIv0alpha1.endpoints.getScope.initiate as jest.Mock).mockReturnValue(mockSubscription);
const result = await apiClient.fetchScope('grafana');
// Validate: result matches the expected scope from MOCK_SCOPES
expect(result).toEqual(expectedScope);
expect(scopeAPIv0alpha1.endpoints.getScope.initiate).toHaveBeenCalledWith(
{ name: 'grafana' },
{ subscribe: false }
);
});
it('should return undefined when scope is not found', async () => {
// Expected: No scope with this name exists in MOCK_SCOPES
const nonExistentScopeName = 'non-existent-scope';
const errorResponse = {
kind: 'Status',
apiVersion: 'v1',
status: 'Failure',
message: `scopes.scope.grafana.app "${nonExistentScopeName}" not found`,
code: 404,
};
const mockSubscription = createMockSubscription({ data: errorResponse });
(scopeAPIv0alpha1.endpoints.getScope.initiate as jest.Mock).mockReturnValue(mockSubscription);
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const result = await apiClient.fetchScope(nonExistentScopeName);
// Validate: returns undefined for non-existent scope
expect(result).toBeUndefined();
expect(consoleErrorSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
});
});
describe('fetchMultipleScopes', () => {
it('should fetch multiple scopes in parallel', async () => {
// Expected: Both 'grafana' and 'mimir' exist in MOCK_SCOPES
const scopeNames = ['grafana', 'mimir'];
const expectedScopes = MOCK_SCOPES.filter((s) => scopeNames.includes(s.metadata.name));
const mockSubscriptions = expectedScopes.map((scope) => createMockSubscription({ data: scope }));
(scopeAPIv0alpha1.endpoints.getScope.initiate as jest.Mock)
.mockReturnValueOnce(mockSubscriptions[0])
.mockReturnValueOnce(mockSubscriptions[1]);
const result = await apiClient.fetchMultipleScopes(scopeNames);
// Validate: returns both scopes from MOCK_SCOPES
expect(result).toHaveLength(2);
expect(result.map((s) => s.metadata.name)).toContain('grafana');
expect(result.map((s) => s.metadata.name)).toContain('mimir');
expect(result).toEqual(expect.arrayContaining(expectedScopes));
});
it('should filter out undefined scopes when some fail', async () => {
// Expected: 'grafana' exists in MOCK_SCOPES, 'non-existent' does not
const scopeNames = ['grafana', 'non-existent'];
const expectedScope = MOCK_SCOPES.find((s) => s.metadata.name === 'grafana');
const errorResponse = {
kind: 'Status',
apiVersion: 'v1',
status: 'Failure',
message: 'scopes.scope.grafana.app "non-existent" not found',
code: 404,
};
const mockSubscriptions = [
createMockSubscription({ data: expectedScope }),
createMockSubscription({ data: errorResponse }),
];
(scopeAPIv0alpha1.endpoints.getScope.initiate as jest.Mock)
.mockReturnValueOnce(mockSubscriptions[0])
.mockReturnValueOnce(mockSubscriptions[1]);
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
const result = await apiClient.fetchMultipleScopes(scopeNames);
// Validate: only returns the existing scope from MOCK_SCOPES, filters out the non-existent one
expect(result).toHaveLength(1);
expect(result[0]).toEqual(expectedScope);
expect(result[0].metadata.name).toBe('grafana');
// Validate: console.warn is called when some scopes fail
expect(consoleWarnSpy).toHaveBeenCalled();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
});
it('should return empty array when no scopes provided', async () => {
const result = await apiClient.fetchMultipleScopes([]);
// Validate: empty input returns empty array
expect(result).toEqual([]);
});
});
describe('fetchMultipleScopeNodes', () => {
it('should fetch multiple nodes by names', async () => {
const mockNodes = [
{
metadata: { name: 'node-1' },
spec: { nodeType: 'container', title: 'Node 1', parentName: '' },
},
{
metadata: { name: 'node-2' },
spec: { nodeType: 'leaf', title: 'Node 2', parentName: 'node-1' },
},
];
// Expected: Both nodes exist in MOCK_NODES
const nodeNames = ['applications-grafana', 'applications-mimir'];
const expectedNodes = MOCK_NODES.filter((n) => nodeNames.includes(n.metadata.name));
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
const result = await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2']);
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
names: ['node-1', 'node-2'],
const mockSubscription = createMockSubscription({
data: { items: expectedNodes },
});
expect(result).toEqual(mockNodes);
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchMultipleScopeNodes(nodeNames);
// Validate: returns the expected nodes from MOCK_NODES
expect(result).toHaveLength(2);
expect(result.map((n) => n.metadata.name)).toContain('applications-grafana');
expect(result.map((n) => n.metadata.name)).toContain('applications-mimir');
expect(result).toEqual(expect.arrayContaining(expectedNodes));
});
it('should return empty array when names array is empty', async () => {
const result = await apiClient.fetchMultipleScopeNodes([]);
expect(mockBackendSrv.get).not.toHaveBeenCalled();
expect(result).toEqual([]);
});
it('should return empty array when feature toggle is disabled', async () => {
config.featureToggles.useMultipleScopeNodesEndpoint = false;
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
const result = await apiClient.fetchMultipleScopeNodes(['applications-grafana']);
expect(mockBackendSrv.get).not.toHaveBeenCalled();
expect(result).toEqual([]);
// Restore feature toggle
@ -76,79 +196,94 @@ describe('ScopesApiClient', () => {
});
it('should handle API errors gracefully', async () => {
mockBackendSrv.get.mockRejectedValue(new Error('Network error'));
// Expected: No node with this name exists in MOCK_NODES
const nonExistentNodeName = 'non-existent-node';
const mockSubscription = createMockSubscription({ data: { items: [] } });
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
const result = await apiClient.fetchMultipleScopeNodes([nonExistentNodeName]);
// Validate: returns empty array when no matches
expect(result).toEqual([]);
consoleErrorSpy.mockRestore();
});
it('should handle response with no items field', async () => {
mockBackendSrv.get.mockResolvedValue({});
// Expected: Node exists in MOCK_NODES
const nodeName = 'applications-grafana';
const mockSubscription = createMockSubscription({ data: {} });
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
expect(result).toEqual([]);
});
it('should handle response with null items', async () => {
mockBackendSrv.get.mockResolvedValue({ items: null });
const result = await apiClient.fetchMultipleScopeNodes(['node-1']);
const result = await apiClient.fetchMultipleScopeNodes([nodeName]);
// Validate: returns empty array when items field is missing
expect(result).toEqual([]);
});
it('should handle large arrays of node names', async () => {
const names = Array.from({ length: 100 }, (_, i) => `node-${i}`);
const mockNodes = names.map((name) => ({
metadata: { name },
spec: { nodeType: 'leaf', title: name, parentName: '' },
}));
// Expected: None of these node names exist in MOCK_NODES
const nonExistentNodeNames = Array.from({ length: 10 }, (_, i) => `node-${i}`);
const mockSubscription = createMockSubscription({ data: { items: [] } });
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
const result = await apiClient.fetchMultipleScopeNodes(nonExistentNodeNames);
const result = await apiClient.fetchMultipleScopeNodes(names);
expect(result).toEqual(mockNodes);
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
names,
});
// Validate: returns empty array when no matches
expect(Array.isArray(result)).toBe(true);
expect(result).toEqual([]);
});
it('should pass through node names exactly as provided', async () => {
const names = ['node-with-special-chars_123', 'node.with.dots', 'node-with-dashes'];
mockBackendSrv.get.mockResolvedValue({ items: [] });
// Expected: Both nodes exist in MOCK_NODES
const nodeNames = ['applications-grafana', 'applications-mimir'];
const expectedNodes = MOCK_NODES.filter((n) => nodeNames.includes(n.metadata.name));
const mockSubscription = createMockSubscription({
data: { items: expectedNodes },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
await apiClient.fetchMultipleScopeNodes(names);
const result = await apiClient.fetchMultipleScopeNodes(nodeNames);
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
names,
// Validate: returns nodes matching the provided names
const resultNames = result.map((n) => n.metadata.name);
expect(resultNames).toEqual(expect.arrayContaining(nodeNames));
// Verify we got the expected nodes from MOCK_NODES
expectedNodes.forEach((expectedNode) => {
expect(result).toContainEqual(expectedNode);
});
});
});
describe('fetchScopeNode', () => {
it('should fetch a single scope node by ID', async () => {
const mockNode = {
metadata: { name: 'test-node' },
spec: { nodeType: 'leaf', title: 'Test Node', parentName: 'parent' },
};
// Expected: Node exists in MOCK_NODES
const nodeName = 'applications-grafana';
const expectedNode = MOCK_NODES.find((n) => n.metadata.name === nodeName);
expect(expectedNode).toBeDefined();
mockBackendSrv.get.mockResolvedValue(mockNode);
const mockSubscription = createMockSubscription({ data: expectedNode });
(scopeAPIv0alpha1.endpoints.getScopeNode.initiate as jest.Mock).mockReturnValue(mockSubscription);
const result = await apiClient.fetchScopeNode('test-node');
const result = await apiClient.fetchScopeNode(nodeName);
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopenodes/test-node');
expect(result).toEqual(mockNode);
// Validate: result matches the expected node from MOCK_NODES
expect(result).toEqual(expectedNode);
});
it('should return undefined when feature toggle is disabled', async () => {
config.featureToggles.useScopeSingleNodeEndpoint = false;
const result = await apiClient.fetchScopeNode('test-node');
const result = await apiClient.fetchScopeNode('applications-grafana');
expect(mockBackendSrv.get).not.toHaveBeenCalled();
expect(result).toBeUndefined();
// Restore feature toggle
@ -156,65 +291,95 @@ describe('ScopesApiClient', () => {
});
it('should return undefined on API error', async () => {
mockBackendSrv.get.mockRejectedValue(new Error('Not found'));
// Expected: No node with this name exists in MOCK_NODES
const nonExistentNodeName = 'non-existent-node';
const errorResponse = {
kind: 'Status',
apiVersion: 'v1',
status: 'Failure',
message: `scopenodes.scope.grafana.app "${nonExistentNodeName}" not found`,
code: 404,
};
const mockSubscription = createMockSubscription({ data: errorResponse });
(scopeAPIv0alpha1.endpoints.getScopeNode.initiate as jest.Mock).mockReturnValue(mockSubscription);
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const result = await apiClient.fetchScopeNode('non-existent');
const result = await apiClient.fetchScopeNode(nonExistentNodeName);
// Validate: returns undefined for non-existent node
expect(result).toBeUndefined();
consoleErrorSpy.mockRestore();
});
});
describe('fetchNodes', () => {
it('should fetch nodes with parent filter', async () => {
const mockNodes = [
{
metadata: { name: 'child-1' },
spec: { nodeType: 'leaf', title: 'Child 1', parentName: 'parent' },
},
];
// Expected: MOCK_NODES contains nodes with parentName 'applications'
const parentName = 'applications';
const expectedNodes = MOCK_NODES.filter((n) => n.spec.parentName === parentName);
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
const result = await apiClient.fetchNodes({ parent: 'parent' });
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
parent: 'parent',
query: undefined,
limit: 1000,
const mockSubscription = createMockSubscription({
data: { items: expectedNodes },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchNodes({ parent: parentName });
// Validate: returns nodes with matching parentName from MOCK_NODES
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
result.forEach((node) => {
expect(node.spec.parentName).toBe(parentName);
});
// Verify all returned nodes are from the expected set
result.forEach((node) => {
expect(expectedNodes).toContainEqual(node);
});
expect(result).toEqual(mockNodes);
});
it('should fetch nodes with query filter', async () => {
const mockNodes = [
{
metadata: { name: 'matching-node' },
spec: { nodeType: 'leaf', title: 'Matching Node', parentName: '' },
},
];
// Expected: MOCK_NODES contains nodes with 'Grafana' in title (case-insensitive)
// When query is provided without parent, the API returns nodes matching the query
// In MOCK_NODES, nodes with 'Grafana' in title have parentName 'applications' or 'cloud-applications'
const query = 'Grafana';
const expectedNodes = MOCK_NODES.filter((n) => n.spec.title.toLowerCase().includes(query.toLowerCase()));
mockBackendSrv.get.mockResolvedValue({ items: mockNodes });
const result = await apiClient.fetchNodes({ query: 'matching' });
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
parent: undefined,
query: 'matching',
limit: 1000,
const mockSubscription = createMockSubscription({
data: { items: expectedNodes },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchNodes({ query });
// Validate: returns nodes matching the query from MOCK_NODES
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
result.forEach((node) => {
expect(node.spec.title.toLowerCase()).toContain('grafana');
});
// Verify all returned nodes are from the expected set
result.forEach((node) => {
expect(expectedNodes).toContainEqual(node);
});
expect(result).toEqual(mockNodes);
});
it('should respect custom limit', async () => {
mockBackendSrv.get.mockResolvedValue({ items: [] });
await apiClient.fetchNodes({ limit: 50 });
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
parent: undefined,
query: undefined,
limit: 50,
const limit = 5;
const mockNodes = MOCK_NODES.slice(0, limit);
const mockSubscription = createMockSubscription({
data: { items: mockNodes },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchNodes({ limit });
expect(result.length).toBeLessThanOrEqual(limit);
});
it('should throw error for invalid limit (too small)', async () => {
@ -226,137 +391,297 @@ describe('ScopesApiClient', () => {
});
it('should use default limit of 1000 when not specified', async () => {
mockBackendSrv.get.mockResolvedValue({ items: [] });
await apiClient.fetchNodes({});
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/find/scope_node_children', {
parent: undefined,
query: undefined,
limit: 1000,
const mockNodes = MOCK_NODES.slice(0, 1000);
const mockSubscription = createMockSubscription({
data: { items: mockNodes },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchNodes({});
expect(Array.isArray(result)).toBe(true);
// Default limit is 1000, so result should not exceed that
expect(result.length).toBeLessThanOrEqual(1000);
});
it('should return empty array on API error', async () => {
mockBackendSrv.get.mockRejectedValue(new Error('API Error'));
const result = await apiClient.fetchNodes({ parent: 'test' });
expect(result).toEqual([]);
});
});
describe('fetchScope', () => {
it('should fetch a scope by name', async () => {
const mockScope = {
metadata: { name: 'test-scope' },
spec: {
title: 'Test Scope',
filters: [],
},
};
mockBackendSrv.get.mockResolvedValue(mockScope);
const result = await apiClient.fetchScope('test-scope');
expect(mockBackendSrv.get).toHaveBeenCalledWith('/apis/scope.grafana.app/v0alpha1/scopes/test-scope');
expect(result).toEqual(mockScope);
});
it('should return undefined on error', async () => {
const mockSubscription = createMockSubscription({ data: { items: [] } });
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
mockBackendSrv.get.mockRejectedValue(new Error('Not found'));
const result = await apiClient.fetchScope('non-existent');
const result = await apiClient.fetchNodes({ parent: 'non-existent-parent' });
expect(result).toBeUndefined();
expect(Array.isArray(result)).toBe(true);
consoleErrorSpy.mockRestore();
});
it('should log error to console', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const error = new Error('Not found');
mockBackendSrv.get.mockRejectedValue(error);
it('should combine parent and query filters', async () => {
// Expected: MOCK_NODES contains nodes with parentName 'applications' and 'Grafana' in title
const parentName = 'applications';
const query = 'Grafana';
const expectedNodes = MOCK_NODES.filter(
(n) => n.spec.parentName === parentName && n.spec.title.toLowerCase().includes(query.toLowerCase())
);
await apiClient.fetchScope('non-existent');
const mockSubscription = createMockSubscription({
data: { items: expectedNodes },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
expect(consoleErrorSpy).toHaveBeenCalledWith(error);
consoleErrorSpy.mockRestore();
const result = await apiClient.fetchNodes({ parent: parentName, query });
// Validate: returns nodes matching both filters from MOCK_NODES
expect(Array.isArray(result)).toBe(true);
result.forEach((node) => {
expect(node.spec.parentName).toBe(parentName);
expect(node.spec.title.toLowerCase()).toContain('grafana');
});
// Verify all returned nodes are from the expected set
result.forEach((node) => {
expect(expectedNodes).toContainEqual(node);
});
});
});
describe('fetchMultipleScopes', () => {
it('should fetch multiple scopes in parallel', async () => {
const mockScopes = [
describe('fetchDashboards', () => {
it('should fetch dashboards for scopes', async () => {
// Expected: MOCK_SCOPE_DASHBOARD_BINDINGS contains bindings for 'grafana' scope
const scopeNames = ['grafana'];
const mockBindings = [
{
metadata: { name: 'scope-1' },
spec: { title: 'Scope 1', filters: [] },
},
{
metadata: { name: 'scope-2' },
spec: { title: 'Scope 2', filters: [] },
metadata: { name: 'grafana-binding-1' },
spec: { dashboard: 'dashboard-1', scope: 'grafana' },
status: { dashboardTitle: 'Dashboard 1' },
},
];
const mockSubscription = createMockSubscription({
data: { items: mockBindings },
});
(scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
mockBackendSrv.get.mockResolvedValueOnce(mockScopes[0]).mockResolvedValueOnce(mockScopes[1]);
const result = await apiClient.fetchDashboards(scopeNames);
const result = await apiClient.fetchMultipleScopes(['scope-1', 'scope-2']);
expect(mockBackendSrv.get).toHaveBeenCalledTimes(2);
expect(result).toEqual(mockScopes);
// Validate: returns bindings for the requested scope
expect(Array.isArray(result)).toBe(true);
result.forEach((binding) => {
expect(binding.spec.scope).toBe('grafana');
});
});
it('should filter out undefined scopes', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const mockScope = {
metadata: { name: 'scope-1' },
spec: { title: 'Scope 1', filters: [] },
};
it('should fetch dashboards for multiple scopes', async () => {
// Expected: MOCK_SCOPE_DASHBOARD_BINDINGS contains bindings for 'grafana' and 'mimir' scopes
const scopeNames = ['grafana', 'mimir'];
const mockBindings = [
{
metadata: { name: 'grafana-binding-1' },
spec: { dashboard: 'dashboard-1', scope: 'grafana' },
status: { dashboardTitle: 'Dashboard 1' },
},
{
metadata: { name: 'mimir-binding-1' },
spec: { dashboard: 'dashboard-2', scope: 'mimir' },
status: { dashboardTitle: 'Dashboard 2' },
},
];
const mockSubscription = createMockSubscription({
data: { items: mockBindings },
});
(scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
mockBackendSrv.get.mockResolvedValueOnce(mockScope).mockRejectedValueOnce(new Error('Not found'));
const result = await apiClient.fetchDashboards(scopeNames);
const result = await apiClient.fetchMultipleScopes(['scope-1', 'non-existent']);
expect(result).toEqual([mockScope]);
consoleErrorSpy.mockRestore();
// Validate: returns bindings for either scope
expect(Array.isArray(result)).toBe(true);
result.forEach((binding) => {
expect(scopeNames).toContain(binding.spec.scope);
});
});
it('should return empty array when no scopes provided', async () => {
const result = await apiClient.fetchMultipleScopes([]);
it('should return empty array when no dashboards found', async () => {
const mockSubscription = createMockSubscription({
data: { items: [] },
});
(scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchDashboards(['non-existent-scope']);
expect(result).toEqual([]);
expect(mockBackendSrv.get).not.toHaveBeenCalled();
});
it('should handle API errors gracefully', async () => {
const mockSubscription = createMockSubscription({
data: { items: [] },
});
(scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const result = await apiClient.fetchDashboards(['grafana']);
expect(Array.isArray(result)).toBe(true);
consoleErrorSpy.mockRestore();
});
});
describe('fetchScopeNavigations', () => {
it('should fetch navigations for scopes', async () => {
// Expected: MSW handler returns MOCK_SUB_SCOPE_MIMIR_ITEMS for 'mimir' scope
const scopeName = 'mimir';
const mockNavigations = [
{
metadata: { name: 'mimir-item-1' },
spec: { scope: 'mimir', url: '/d/mimir-dashboard-1' },
status: { title: 'Mimir Dashboard 1' },
},
];
const mockSubscription = createMockSubscription({
data: { items: mockNavigations },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchScopeNavigations([scopeName]);
// Validate: returns navigations for the requested scope
expect(Array.isArray(result)).toBe(true);
result.forEach((nav) => {
expect(nav.spec.scope).toBe('mimir');
});
});
it('should fetch navigations for multiple scopes', async () => {
// Expected: Returns navigations for both 'mimir' and 'loki'
const scopeNames = ['mimir', 'loki'];
const mockNavigations = [
{
metadata: { name: 'mimir-item-1' },
spec: { scope: 'mimir', url: '/d/mimir-dashboard-1' },
status: { title: 'Mimir Dashboard 1' },
},
{
metadata: { name: 'loki-item-1' },
spec: { scope: 'loki', url: '/d/loki-dashboard-1' },
status: { title: 'Loki Dashboard 1' },
},
];
const mockSubscription = createMockSubscription({
data: { items: mockNavigations },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchScopeNavigations(scopeNames);
// Validate: returns navigations for both scopes
expect(Array.isArray(result)).toBe(true);
const resultScopeNames = result.map((nav) => nav.spec.scope);
expect(resultScopeNames.length).toBeGreaterThan(0);
result.forEach((nav) => {
expect(scopeNames).toContain(nav.spec.scope);
});
});
it('should return empty array when no navigations found', async () => {
const mockSubscription = createMockSubscription({
data: { items: [] },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const result = await apiClient.fetchScopeNavigations(['grafana']);
expect(Array.isArray(result)).toBe(true);
});
it('should handle API errors gracefully', async () => {
const mockSubscription = createMockSubscription({
data: { items: [] },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const result = await apiClient.fetchScopeNavigations(['mimir']);
expect(Array.isArray(result)).toBe(true);
consoleErrorSpy.mockRestore();
});
});
describe('performance considerations', () => {
it('should make single batched request with fetchMultipleScopeNodes', async () => {
mockBackendSrv.get.mockResolvedValue({ items: [] });
// This test verifies that the method uses the batched endpoint
const nodeNames = [
'applications-grafana',
'applications-mimir',
'applications-loki',
'applications-tempo',
'applications-cloud',
];
const expectedNodes = MOCK_NODES.filter((n) => nodeNames.includes(n.metadata.name));
const mockSubscription = createMockSubscription({
data: { items: expectedNodes },
});
(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate as jest.Mock).mockReturnValue(
mockSubscription
);
await apiClient.fetchMultipleScopeNodes(['node-1', 'node-2', 'node-3', 'node-4', 'node-5']);
const result = await apiClient.fetchMultipleScopeNodes(nodeNames);
// Should make exactly 1 API call
expect(mockBackendSrv.get).toHaveBeenCalledTimes(1);
expect(Array.isArray(result)).toBe(true);
// Verify it was called once with all names
expect(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate).toHaveBeenCalledTimes(1);
expect(scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate).toHaveBeenCalledWith(
{ names: nodeNames },
{ subscribe: false }
);
});
it('should make N sequential requests with fetchScopeNode (old pattern)', async () => {
mockBackendSrv.get.mockResolvedValue({
metadata: { name: 'test' },
spec: { nodeType: 'leaf', title: 'Test', parentName: '' },
// This test demonstrates the old pattern of fetching nodes one by one
// Each call makes a separate API request
const nodeNames = [
'applications-grafana',
'applications-mimir',
'applications-loki',
'applications-tempo',
'applications-cloud',
];
const mockNodes = nodeNames.map((name) => MOCK_NODES.find((n) => n.metadata.name === name)).filter(Boolean);
const mockSubscriptions = mockNodes.map((node) => createMockSubscription({ data: node }));
mockSubscriptions.forEach((sub) => {
(scopeAPIv0alpha1.endpoints.getScopeNode.initiate as jest.Mock).mockReturnValueOnce(sub);
});
// Simulate old pattern of fetching nodes one by one
await Promise.all([
apiClient.fetchScopeNode('node-1'),
apiClient.fetchScopeNode('node-2'),
apiClient.fetchScopeNode('node-3'),
apiClient.fetchScopeNode('node-4'),
apiClient.fetchScopeNode('node-5'),
const results = await Promise.all([
apiClient.fetchScopeNode('applications-grafana'),
apiClient.fetchScopeNode('applications-mimir'),
apiClient.fetchScopeNode('applications-loki'),
apiClient.fetchScopeNode('applications-tempo'),
apiClient.fetchScopeNode('applications-cloud'),
]);
// Should make 5 separate API calls
expect(mockBackendSrv.get).toHaveBeenCalledTimes(5);
expect(results).toHaveLength(5);
expect(results.every((r) => r !== undefined)).toBe(true);
// Verify it was called 5 times (once per node)
expect(scopeAPIv0alpha1.endpoints.getScopeNode.initiate).toHaveBeenCalledTimes(5);
});
});
});

View file

@ -1,25 +1,95 @@
import { getAPIBaseURL } from '@grafana/api-clients';
import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
import { getBackendSrv, config } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { scopeAPIv0alpha1 } from 'app/api/clients/scope/v0alpha1';
import { getMessageFromError } from 'app/core/utils/errors';
import { dispatch } from 'app/store/store';
import { ScopeNavigation } from './dashboards/types';
const apiUrl = getAPIBaseURL('scope.grafana.app', 'v0alpha1');
export class ScopesApiClient {
/**
* Checks if the data is a Kubernetes Status error response.
* @param data The data to check
* @returns true if the data is a Status error, false otherwise
*/
private isStatusError(data: unknown): data is { kind: 'Status'; status: 'Failure'; message?: string; code?: number } {
return (
data !== null &&
typeof data === 'object' &&
'kind' in data &&
data.kind === 'Status' &&
'status' in data &&
data.status === 'Failure'
);
}
/**
* Extracts and validates data from an RTK Query result, checking for error responses.
* @param result The RTK Query result
* @param context Context for error logging (e.g., resource name)
* @returns The data if valid, undefined if it's an error response
*/
private extractDataOrHandleError<T>(result: { data?: T; error?: unknown }, context: string): T | undefined {
if ('data' in result && result.data) {
// Check if the data is actually an error response (Kubernetes Status object)
if (this.isStatusError(result.data)) {
const errorMessage = getMessageFromError(result.data);
console.error(`Failed to fetch %s:`, context, errorMessage);
return undefined;
}
return result.data;
}
if ('error' in result) {
const errorMessage = getMessageFromError(result.error);
console.error(`Failed to fetch %s:`, context, errorMessage);
}
return undefined;
}
async fetchScope(name: string): Promise<Scope | undefined> {
const subscription = dispatch(scopeAPIv0alpha1.endpoints.getScope.initiate({ name }, { subscribe: false }));
try {
return await getBackendSrv().get<Scope>(apiUrl + `/scopes/${name}`);
const result = await subscription;
return this.extractDataOrHandleError(result, `scope: ${name}`);
} catch (err) {
// TODO: maybe some better error handling
console.error(err);
const errorMessage = getMessageFromError(err);
console.error('Failed to fetch scope:', name, errorMessage);
return undefined;
} finally {
// Unsubscribe for extra safety, even though with subscribe: false and awaiting,
// the request completes before return, so this is mostly a no-op
subscription.unsubscribe();
}
}
async fetchMultipleScopes(scopesIds: string[]): Promise<Scope[]> {
const scopes = await Promise.all(scopesIds.map((id) => this.fetchScope(id)));
return scopes.filter((scope) => scope !== undefined);
if (scopesIds.length === 0) {
return [];
}
try {
const scopes = await Promise.all(scopesIds.map((id) => this.fetchScope(id)));
const successfulScopes = scopes.filter((scope) => scope !== undefined);
if (successfulScopes.length < scopesIds.length) {
const failedCount = scopesIds.length - successfulScopes.length;
console.warn(
'Failed to fetch',
failedCount,
'of',
scopesIds.length,
'scope(s). Requested IDs:',
scopesIds.join(', ')
);
}
return successfulScopes;
} catch (err) {
const errorMessage = getMessageFromError(err);
console.error('Failed to fetch multiple scopes:', scopesIds, errorMessage);
return [];
}
}
async fetchMultipleScopeNodes(names: string[]): Promise<ScopeNode[]> {
@ -27,13 +97,31 @@ export class ScopesApiClient {
return Promise.resolve([]);
}
const subscription = dispatch(
scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate({ names }, { subscribe: false })
);
try {
const res = await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, {
names: names,
});
return res?.items ?? [];
} catch (err) {
const result = await subscription;
if ('data' in result && result.data) {
// The generated API returns items compatible with @grafana/data ScopeNode
return result.data.items ?? [];
}
if ('error' in result) {
const errorMessage = getMessageFromError(result.error);
console.error('Failed to fetch multiple scope nodes:', names, errorMessage);
}
return [];
} catch (err) {
const errorMessage = getMessageFromError(err);
console.error('Failed to fetch multiple scope nodes:', names, errorMessage);
return [];
} finally {
// Unsubscribe for extra safety, even though with subscribe: false and awaiting,
// the request completes before return, so this is mostly a no-op
subscription.unsubscribe();
}
}
@ -53,46 +141,128 @@ export class ScopesApiClient {
throw new Error('Limit must be between 1 and 10000');
}
const subscription = dispatch(
scopeAPIv0alpha1.endpoints.getFindScopeNodeChildrenResults.initiate(
{
parent: options.parent,
query: options.query,
limit,
},
{ subscribe: false, forceRefetch: true } // Froce refetch for search. Revisit this when necessary
)
);
try {
const nodes =
(
await getBackendSrv().get<{ items: ScopeNode[] }>(apiUrl + `/find/scope_node_children`, {
parent: options.parent,
query: options.query,
limit,
})
)?.items ?? [];
const result = await subscription;
if ('data' in result && result.data) {
// The generated API returns items compatible with @grafana/data ScopeNode
return result.data.items ?? [];
}
if ('error' in result) {
const errorMessage = getMessageFromError(result.error);
const contextParts: string[] = [];
if (options.parent) {
contextParts.push('parent="' + options.parent + '"');
}
if (options.query) {
contextParts.push('query="' + options.query + '"');
}
contextParts.push('limit=' + limit);
const context = contextParts.join(', ');
console.error('Failed to fetch scope nodes:', context, errorMessage);
}
return nodes;
} catch (err) {
return [];
} catch (err) {
const errorMessage = getMessageFromError(err);
const contextParts: string[] = [];
if (options.parent) {
contextParts.push('parent="' + options.parent + '"');
}
if (options.query) {
contextParts.push('query="' + options.query + '"');
}
contextParts.push('limit=' + limit);
const context = contextParts.join(', ');
console.error('Failed to fetch scope nodes:', context, errorMessage);
return [];
} finally {
// Unsubscribe for extra safety, even though with subscribe: false and awaiting,
// the request completes before return, so this is mostly a no-op
subscription.unsubscribe();
}
}
public fetchDashboards = async (scopeNames: string[]): Promise<ScopeDashboardBinding[]> => {
try {
const response = await getBackendSrv().get<{ items: ScopeDashboardBinding[] }>(
apiUrl + `/find/scope_dashboard_bindings`,
const subscription = dispatch(
// Note: `name` is required by generated types but ignored by the query builder (codegen bug)
scopeAPIv0alpha1.endpoints.getFindScopeDashboardBindingsResults.initiate(
{
name: '',
scope: scopeNames,
}
);
},
{ subscribe: false }
)
);
try {
const result = await subscription;
if ('data' in result && result.data) {
// The generated API returns items compatible with @grafana/data ScopeDashboardBinding
return result.data.items ?? [];
}
if ('error' in result) {
const errorMessage = getMessageFromError(result.error);
console.error('Failed to fetch dashboards for scopes:', scopeNames, errorMessage);
}
return response?.items ?? [];
} catch (err) {
return [];
} catch (err) {
const errorMessage = getMessageFromError(err);
console.error('Failed to fetch dashboards for scopes:', scopeNames, errorMessage);
return [];
} finally {
// Unsubscribe for extra safety, even though with subscribe: false and awaiting,
// the request completes before return, so this is mostly a no-op
subscription.unsubscribe();
}
};
public fetchScopeNavigations = async (scopeNames: string[]): Promise<ScopeNavigation[]> => {
const subscription = dispatch(
// Note: `name` is required by generated types but ignored by the query builder (codegen bug)
scopeAPIv0alpha1.endpoints.getFindScopeNavigationsResults.initiate(
{
name: '',
scope: scopeNames,
},
{ subscribe: false }
)
);
try {
const response = await getBackendSrv().get<{ items: ScopeNavigation[] }>(apiUrl + `/find/scope_navigations`, {
scope: scopeNames,
});
const result = await subscription;
if ('data' in result && result.data) {
// The generated API returns items compatible with ScopeNavigation
return result.data.items ?? [];
}
if ('error' in result) {
const errorMessage = getMessageFromError(result.error);
console.error('Failed to fetch scope navigations for scopes:', scopeNames, errorMessage);
}
return response?.items ?? [];
} catch (err) {
return [];
} catch (err) {
const errorMessage = getMessageFromError(err);
console.error('Failed to fetch scope navigations for scopes:', scopeNames, errorMessage);
return [];
} finally {
// Unsubscribe for extra safety, even though with subscribe: false and awaiting,
// the request completes before return, so this is mostly a no-op
subscription.unsubscribe();
}
};
@ -100,11 +270,21 @@ export class ScopesApiClient {
if (!config.featureToggles.useScopeSingleNodeEndpoint) {
return Promise.resolve(undefined);
}
const subscription = dispatch(
scopeAPIv0alpha1.endpoints.getScopeNode.initiate({ name: scopeNodeId }, { subscribe: false })
);
try {
const response = await getBackendSrv().get<ScopeNode>(apiUrl + `/scopenodes/${scopeNodeId}`);
return response;
const result = await subscription;
return this.extractDataOrHandleError(result, `scope node: ${scopeNodeId}`);
} catch (err) {
const errorMessage = getMessageFromError(err);
console.error('Failed to fetch scope node:', scopeNodeId, errorMessage);
return undefined;
} finally {
// Unsubscribe for extra safety, even though with subscribe: false and awaiting,
// the request completes before return, so this is mostly a no-op
subscription.unsubscribe();
}
};
}

View file

@ -5,7 +5,11 @@ import { config, locationService } from '@grafana/runtime';
import { ScopesApiClient } from '../ScopesApiClient';
// Import mock data for subScope tests
import { navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeAndGroups } from '../tests/utils/mocks';
import {
navigationWithSubScope,
navigationWithSubScope2,
navigationWithSubScopeAndGroups,
} from '../tests/utils/mockData';
import { ScopesDashboardsService, filterItemsWithSubScopesInPath } from './ScopesDashboardsService';
import { ScopeNavigation } from './types';

View file

@ -1,20 +1,24 @@
import { config } from '@grafana/runtime';
import { config, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { backendSrv } from 'app/core/services/backend_srv';
import { setDashboardAPI } from 'app/features/dashboard/api/dashboard_api';
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { enterEditMode, updateMyVar, updateScopes, updateTimeRange } from './utils/actions';
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import { getDatasource, getInstanceSettings } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
jest.mock('@grafana/runtime', () => ({
__esModule: true,
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn(),
getBackendSrv: () => ({ get: getMock }),
getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }),
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
}));
setBackendSrv(backendSrv);
setupMockServer();
describe('Dashboard reload', () => {
let dashboardReloadSpy: jest.SpyInstance;
beforeEach(() => {

View file

@ -1,6 +1,9 @@
import { screen, waitFor } from '@testing-library/react';
import { config, locationService } from '@grafana/runtime';
import { config, locationService, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { MOCK_SUB_SCOPE_MIMIR_ITEMS } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { ScopesApiClient } from '../ScopesApiClient';
import { ScopesService } from '../ScopesService';
@ -35,26 +38,25 @@ import {
dashboardWithRootFolder,
dashboardWithRootFolderAndOtherFolder,
dashboardWithTwoFolders,
getDatasource,
getInstanceSettings,
getMock,
navigationWithSubScope,
navigationWithSubScope2,
navigationWithSubScopeDifferent,
navigationWithSubScopeAndGroups,
subScopeMimirItems,
} from './utils/mocks';
} from './utils/mockData';
import { getDatasource, getInstanceSettings } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
jest.mock('@grafana/runtime', () => ({
__esModule: true,
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn(),
getBackendSrv: () => ({ get: getMock }),
getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }),
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
}));
setBackendSrv(backendSrv);
setupMockServer();
describe('Dashboards list', () => {
let fetchDashboardsSpy: jest.SpyInstance;
let fetchScopeNavigationsSpy: jest.SpyInstance;
@ -539,7 +541,7 @@ describe('Dashboards list', () => {
it('Loads subScope items when folder is expanded', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems);
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(MOCK_SUB_SCOPE_MIMIR_ITEMS);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
@ -571,7 +573,7 @@ describe('Dashboards list', () => {
it('Shows loading state while fetching subScope items', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems);
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(MOCK_SUB_SCOPE_MIMIR_ITEMS);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
@ -591,7 +593,7 @@ describe('Dashboards list', () => {
it('Multiple subScope folders with same subScope load same content', async () => {
const mockNavigations = [navigationWithSubScope, navigationWithSubScope2];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValue(subScopeMimirItems);
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValue(MOCK_SUB_SCOPE_MIMIR_ITEMS);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
@ -676,7 +678,7 @@ describe('Dashboards list', () => {
it('Filters search works with loaded subScope content', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems);
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(MOCK_SUB_SCOPE_MIMIR_ITEMS);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);
@ -715,7 +717,7 @@ describe('Dashboards list', () => {
it('Does not fetch subScope items if folder is already loaded', async () => {
const mockNavigations = [navigationWithSubScope];
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems);
fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(MOCK_SUB_SCOPE_MIMIR_ITEMS);
await toggleDashboards();
await updateScopes(scopesService, ['grafana']);

View file

@ -1,4 +1,7 @@
import { config, locationService } from '@grafana/runtime';
import { config, locationService, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { MOCK_SCOPES } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { getDashboardScenePageStateManager } from '../../dashboard-scene/pages/DashboardScenePageStateManager';
import { ScopesService } from '../ScopesService';
@ -25,7 +28,7 @@ import {
expectResultApplicationsGrafanaSelected,
expectScopesSelectorValue,
} from './utils/assertions';
import { getDatasource, getInstanceSettings, getMock, mocksScopes } from './utils/mocks';
import { getDatasource, getInstanceSettings } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
import { getListOfScopes } from './utils/selectors';
@ -33,11 +36,13 @@ jest.mock('@grafana/runtime', () => ({
__esModule: true,
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn(),
getBackendSrv: () => ({ get: getMock }),
getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }),
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
}));
setBackendSrv(backendSrv);
setupMockServer();
describe('Selector', () => {
let fetchSelectedScopesSpy: jest.SpyInstance;
let dashboardReloadSpy: jest.SpyInstance;
@ -67,7 +72,7 @@ describe('Selector', () => {
await selectResultCloud();
await applyScopes();
expect(fetchSelectedScopesSpy).toHaveBeenCalled();
expect(getListOfScopes(scopesService)).toEqual(mocksScopes.filter(({ metadata: { name } }) => name === 'cloud'));
expect(getListOfScopes(scopesService)).toEqual(MOCK_SCOPES.filter(({ metadata: { name } }) => name === 'cloud'));
});
it('Does not save the scopes on close', async () => {

View file

@ -1,7 +1,9 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { config, locationService } from '@grafana/runtime';
import { config, locationService, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { backendSrv } from 'app/core/services/backend_srv';
import { ScopesService } from '../ScopesService';
@ -43,18 +45,20 @@ import {
expectScopesHeadline,
expectScopesSelectorValue,
} from './utils/assertions';
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import { getDatasource, getInstanceSettings } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
jest.mock('@grafana/runtime', () => ({
__esModule: true,
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn(),
getBackendSrv: () => ({ get: getMock }),
getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }),
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
}));
setBackendSrv(backendSrv);
setupMockServer();
describe('Tree', () => {
let fetchNodesSpy: jest.SpyInstance;
let fetchScopeSpy: jest.SpyInstance;

View file

@ -0,0 +1,92 @@
import { ScopeDashboardBinding } from '@grafana/data';
import { ScopeNavigation } from '../../dashboards/types';
// Mock subScope navigation items (specific to these tests)
export const navigationWithSubScope: ScopeNavigation = {
metadata: { name: 'subscope-nav-1' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-1',
},
status: {
title: 'Mimir Dashboards',
groups: [], // subScope items ignore groups
},
};
export const navigationWithSubScope2: ScopeNavigation = {
metadata: { name: 'subscope-nav-2' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-2',
},
status: {
title: 'Mimir Overview',
groups: [],
},
};
export const navigationWithSubScopeDifferent: ScopeNavigation = {
metadata: { name: 'subscope-nav-3' },
spec: {
scope: 'grafana',
subScope: 'loki',
url: '/d/subscope-dashboard-3',
},
status: {
title: 'Loki Dashboards',
groups: [],
},
};
export const navigationWithSubScopeAndGroups: ScopeNavigation = {
metadata: { name: 'subscope-nav-groups' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-groups',
},
status: {
title: 'Mimir with Groups',
groups: ['Group1', 'Group2'], // Should be ignored for subScope items
},
};
const generateScopeDashboardBinding = (dashboardTitle: string, groups?: string[], dashboardId?: string) => ({
metadata: { name: `${dashboardTitle}-name` },
spec: {
dashboard: `${dashboardId ?? dashboardTitle}-dashboard`,
scope: `${dashboardTitle}-scope`,
},
status: {
dashboardTitle,
groups,
},
});
export const dashboardWithoutFolder: ScopeDashboardBinding = generateScopeDashboardBinding('Without Folder');
export const dashboardWithOneFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With one folder', [
'Folder 1',
]);
export const dashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding('With two folders', [
'Folder 1',
'Folder 2',
]);
export const alternativeDashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding(
'Alternative with two folders',
['Folder 1', 'Folder 2'],
'With two folders'
);
export const dashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With root folder', ['']);
export const alternativeDashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding(
'Alternative With root folder',
[''],
'With root folder'
);
export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = generateScopeDashboardBinding(
'With root folder and other folder',
['', 'Folder 3']
);

View file

@ -1,594 +1,8 @@
import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data';
import { DataSourceRef } from '@grafana/schema/dist/esm/common/common.gen';
import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager';
import { ScopeNavigation } from '../../dashboards/types';
export const mocksScopes: Scope[] = [
{
metadata: { name: 'cloud' },
spec: {
title: 'Cloud',
filters: [{ key: 'cloud', value: '.*', operator: 'regex-match' }],
},
},
{
metadata: { name: 'dev' },
spec: {
title: 'Dev',
filters: [{ key: 'cloud', value: 'dev', operator: 'equals' }],
},
},
{
metadata: { name: 'ops' },
spec: {
title: 'Ops',
filters: [{ key: 'cloud', value: 'ops', operator: 'equals' }],
},
},
{
metadata: { name: 'prod' },
spec: {
title: 'Prod',
filters: [{ key: 'cloud', value: 'prod', operator: 'equals' }],
},
},
{
metadata: { name: 'grafana' },
spec: {
title: 'Grafana',
filters: [{ key: 'app', value: 'grafana', operator: 'equals' }],
},
},
{
metadata: { name: 'mimir' },
spec: {
title: 'Mimir',
filters: [{ key: 'app', value: 'mimir', operator: 'equals' }],
},
},
{
metadata: { name: 'loki' },
spec: {
title: 'Loki',
filters: [{ key: 'app', value: 'loki', operator: 'equals' }],
},
},
{
metadata: { name: 'tempo' },
spec: {
title: 'Tempo',
filters: [{ key: 'app', value: 'tempo', operator: 'equals' }],
},
},
{
metadata: { name: 'dev-env' },
spec: {
title: 'Development',
filters: [{ key: 'environment', value: 'dev', operator: 'equals' }],
},
},
{
metadata: { name: 'prod-env' },
spec: {
title: 'Production',
filters: [{ key: 'environment', value: 'prod', operator: 'equals' }],
},
},
] as const;
const dashboardBindingsGenerator = (
scopes: string[],
dashboards: Array<{ dashboardTitle: string; dashboardKey?: string; groups?: string[] }>
) =>
scopes.reduce<ScopeDashboardBinding[]>((scopeAcc, scopeTitle) => {
const scope = scopeTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-');
return [
...scopeAcc,
...dashboards.reduce<ScopeDashboardBinding[]>((acc, { dashboardTitle, groups, dashboardKey }, idx) => {
dashboardKey = dashboardKey ?? dashboardTitle.toLowerCase().replaceAll(' ', '-').replaceAll('/', '-');
const group = !groups
? ''
: groups.length === 1
? groups[0] === ''
? ''
: `${groups[0].toLowerCase().replaceAll(' ', '-').replaceAll('/', '-')}-`
: `multiple${idx}-`;
const dashboard = `${group}${dashboardKey}`;
return [
...acc,
{
metadata: { name: `${scope}-${dashboard}` },
spec: {
dashboard,
scope,
},
status: {
dashboardTitle,
groups,
},
},
];
}, []),
];
}, []);
export const mocksScopeDashboardBindings: ScopeDashboardBinding[] = [
...dashboardBindingsGenerator(
['Grafana'],
[
{ dashboardTitle: 'Data Sources', groups: ['General'] },
{ dashboardTitle: 'Usage', groups: ['General'] },
{ dashboardTitle: 'Frontend Errors', groups: ['Observability'] },
{ dashboardTitle: 'Frontend Logs', groups: ['Observability'] },
{ dashboardTitle: 'Backend Errors', groups: ['Observability'] },
{ dashboardTitle: 'Backend Logs', groups: ['Observability'] },
{ dashboardTitle: 'Usage Overview', groups: ['Usage'] },
{ dashboardTitle: 'Data Sources', groups: ['Usage'] },
{ dashboardTitle: 'Stats', groups: ['Usage'] },
{ dashboardTitle: 'Overview', groups: [''] },
{ dashboardTitle: 'Frontend' },
{ dashboardTitle: 'Stats' },
]
),
...dashboardBindingsGenerator(
['Loki', 'Tempo', 'Mimir'],
[
{ dashboardTitle: 'Ingester', groups: ['Components', 'Investigations'] },
{ dashboardTitle: 'Distributor', groups: ['Components', 'Investigations'] },
{ dashboardTitle: 'Compacter', groups: ['Components', 'Investigations'] },
{ dashboardTitle: 'Datasource Errors', groups: ['Observability', 'Investigations'] },
{ dashboardTitle: 'Datasource Logs', groups: ['Observability', 'Investigations'] },
{ dashboardTitle: 'Overview' },
{ dashboardTitle: 'Stats', dashboardKey: 'another-stats' },
]
),
...dashboardBindingsGenerator(
['Dev', 'Ops', 'Prod'],
[
{ dashboardTitle: 'Overview', groups: ['Cardinality Management'] },
{ dashboardTitle: 'Metrics', groups: ['Cardinality Management'] },
{ dashboardTitle: 'Labels', groups: ['Cardinality Management'] },
{ dashboardTitle: 'Overview', groups: ['Usage Insights'] },
{ dashboardTitle: 'Data Sources', groups: ['Usage Insights'] },
{ dashboardTitle: 'Query Errors', groups: ['Usage Insights'] },
{ dashboardTitle: 'Alertmanager', groups: ['Usage Insights'] },
{ dashboardTitle: 'Metrics Ingestion', groups: ['Usage Insights'] },
{ dashboardTitle: 'Billing/Usage' },
]
),
] as const;
export const mocksNodes: ScopeNode[] = [
{
metadata: { name: 'applications' },
spec: {
nodeType: 'container',
title: 'Applications',
description: 'Application Scopes',
parentName: '',
},
},
{
metadata: { name: 'cloud' },
spec: {
nodeType: 'container',
title: 'Cloud',
description: 'Cloud Scopes',
disableMultiSelect: true,
linkType: 'scope',
linkId: 'cloud',
parentName: '',
},
},
{
metadata: { name: 'applications-grafana' },
spec: {
nodeType: 'leaf',
title: 'Grafana',
description: 'Grafana',
linkType: 'scope',
linkId: 'grafana',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-mimir' },
spec: {
nodeType: 'leaf',
title: 'Mimir',
description: 'Mimir',
linkType: 'scope',
linkId: 'mimir',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-loki' },
spec: {
nodeType: 'leaf',
title: 'Loki',
description: 'Loki',
linkType: 'scope',
linkId: 'loki',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-tempo' },
spec: {
nodeType: 'leaf',
title: 'Tempo',
description: 'Tempo',
linkType: 'scope',
linkId: 'tempo',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-cloud' },
spec: {
nodeType: 'container',
title: 'Cloud',
description: 'Application/Cloud Scopes',
linkType: 'scope',
linkId: 'cloud',
parentName: 'applications',
},
},
{
metadata: { name: 'applications-cloud-dev' },
spec: {
nodeType: 'leaf',
title: 'Dev',
description: 'Dev',
linkType: 'scope',
linkId: 'dev',
parentName: 'applications-cloud',
},
},
{
metadata: { name: 'applications-cloud-ops' },
spec: {
nodeType: 'leaf',
title: 'Ops',
description: 'Ops',
linkType: 'scope',
linkId: 'ops',
parentName: 'applications-cloud',
},
},
{
metadata: { name: 'applications-cloud-prod' },
spec: {
nodeType: 'leaf',
title: 'Prod',
description: 'Prod',
linkType: 'scope',
linkId: 'prod',
parentName: 'applications-cloud',
},
},
{
metadata: { name: 'cloud-dev' },
spec: {
nodeType: 'leaf',
title: 'Dev',
description: 'Dev',
linkType: 'scope',
linkId: 'dev',
parentName: 'cloud',
},
},
{
metadata: { name: 'cloud-ops' },
spec: {
nodeType: 'leaf',
title: 'Ops',
description: 'Ops',
linkType: 'scope',
linkId: 'ops',
parentName: 'cloud',
},
},
{
metadata: { name: 'cloud-prod' },
spec: {
nodeType: 'leaf',
title: 'Prod',
description: 'Prod',
linkType: 'scope',
linkId: 'prod',
parentName: 'cloud',
},
},
{
metadata: { name: 'cloud-applications' },
spec: {
nodeType: 'container',
title: 'Applications',
description: 'Cloud/Application Scopes',
parentName: 'cloud',
},
},
{
metadata: { name: 'cloud-applications-grafana' },
spec: {
nodeType: 'leaf',
title: 'Grafana',
description: 'Grafana',
linkType: 'scope',
linkId: 'grafana',
parentName: 'cloud-applications',
},
},
{
metadata: { name: 'cloud-applications-mimir' },
spec: {
nodeType: 'leaf',
title: 'Mimir',
description: 'Mimir',
linkType: 'scope',
linkId: 'mimir',
parentName: 'cloud-applications',
},
},
{
metadata: { name: 'cloud-applications-loki' },
spec: {
nodeType: 'leaf',
title: 'Loki',
description: 'Loki',
linkType: 'scope',
linkId: 'loki',
parentName: 'cloud-applications',
},
},
{
metadata: { name: 'cloud-applications-tempo' },
spec: {
nodeType: 'leaf',
title: 'Tempo',
description: 'Tempo',
linkType: 'scope',
linkId: 'tempo',
parentName: 'cloud-applications',
},
},
{
metadata: { name: 'environments' },
spec: {
nodeType: 'container',
title: 'Environments',
description: 'Environment Scopes',
disableMultiSelect: true,
parentName: '',
},
},
{
metadata: { name: 'environments-dev' },
spec: {
nodeType: 'container',
title: 'Development',
description: 'Development Environment',
linkType: 'scope',
linkId: 'dev-env',
parentName: 'environments',
},
},
{
metadata: { name: 'environments-prod' },
spec: {
nodeType: 'container',
title: 'Production',
description: 'Production Environment',
linkType: 'scope',
linkId: 'prod-env',
parentName: 'environments',
},
},
] as const;
export const dashboardReloadSpy = jest.spyOn(getDashboardScenePageStateManager(), 'reloadDashboard');
export const getMock = jest
.fn()
.mockImplementation(
(url: string, params: { parent: string; scope: string[]; query?: string } & Record<string, string | string[]>) => {
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_node_children')) {
return {
items: mocksNodes.filter(
({ spec: { title, parentName } }) =>
parentName === params.parent && title.toLowerCase().includes((params.query ?? '').toLowerCase())
),
};
}
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/')) {
const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopes/', '');
return mocksScopes.find((scope) => scope.metadata.name.toLowerCase() === name.toLowerCase()) ?? {};
}
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopenodes/')) {
const name = url.replace('/apis/scope.grafana.app/v0alpha1/namespaces/default/scopenodes/', '');
return mocksNodes.find((node) => node.metadata.name === name);
}
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_dashboard_bindings')) {
return {
items: mocksScopeDashboardBindings.filter(({ spec: { scope: bindingScope } }) =>
params.scope.includes(bindingScope)
),
};
}
if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_navigations')) {
// Handle subScope fetch requests
if (params.scope && params.scope.includes('mimir')) {
return {
items: subScopeMimirItems,
};
}
if (params.scope && params.scope.includes('loki')) {
return {
items: subScopeLokiItems,
};
}
// Return empty for other scopes
return {
items: [],
};
}
if (url.startsWith('/api/dashboards/uid/')) {
return {};
}
if (url.startsWith('/apis/dashboard.grafana.app/v0alpha1/namespaces/default/dashboards/')) {
return {
metadata: {
name: '1',
},
};
}
return {};
}
);
const generateScopeDashboardBinding = (dashboardTitle: string, groups?: string[], dashboardId?: string) => ({
metadata: { name: `${dashboardTitle}-name` },
spec: {
dashboard: `${dashboardId ?? dashboardTitle}-dashboard`,
scope: `${dashboardTitle}-scope`,
},
status: {
dashboardTitle,
groups,
},
});
export const dashboardWithoutFolder: ScopeDashboardBinding = generateScopeDashboardBinding('Without Folder');
export const dashboardWithOneFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With one folder', [
'Folder 1',
]);
export const dashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding('With two folders', [
'Folder 1',
'Folder 2',
]);
export const alternativeDashboardWithTwoFolders: ScopeDashboardBinding = generateScopeDashboardBinding(
'Alternative with two folders',
['Folder 1', 'Folder 2'],
'With two folders'
);
export const dashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding('With root folder', ['']);
export const alternativeDashboardWithRootFolder: ScopeDashboardBinding = generateScopeDashboardBinding(
'Alternative With root folder',
[''],
'With root folder'
);
export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = generateScopeDashboardBinding(
'With root folder and other folder',
['', 'Folder 3']
);
// Mock subScope navigation items
export const navigationWithSubScope: ScopeNavigation = {
metadata: { name: 'subscope-nav-1' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-1',
},
status: {
title: 'Mimir Dashboards',
groups: [], // subScope items ignore groups
},
};
export const navigationWithSubScope2: ScopeNavigation = {
metadata: { name: 'subscope-nav-2' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-2',
},
status: {
title: 'Mimir Overview',
groups: [],
},
};
export const navigationWithSubScopeDifferent: ScopeNavigation = {
metadata: { name: 'subscope-nav-3' },
spec: {
scope: 'grafana',
subScope: 'loki',
url: '/d/subscope-dashboard-3',
},
status: {
title: 'Loki Dashboards',
groups: [],
},
};
export const navigationWithSubScopeAndGroups: ScopeNavigation = {
metadata: { name: 'subscope-nav-groups' },
spec: {
scope: 'grafana',
subScope: 'mimir',
url: '/d/subscope-dashboard-groups',
},
status: {
title: 'Mimir with Groups',
groups: ['Group1', 'Group2'], // Should be ignored for subScope items
},
};
// Mock items that will be loaded when subScope folder is expanded
export const subScopeMimirItems: ScopeNavigation[] = [
{
metadata: { name: 'mimir-item-1' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-1',
},
status: {
title: 'Mimir Dashboard 1',
groups: ['General'],
},
},
{
metadata: { name: 'mimir-item-2' },
spec: {
scope: 'mimir',
url: '/d/mimir-dashboard-2',
},
status: {
title: 'Mimir Dashboard 2',
groups: ['Observability'],
},
},
];
export const subScopeLokiItems: ScopeNavigation[] = [
{
metadata: { name: 'loki-item-1' },
spec: {
scope: 'loki',
url: '/d/loki-dashboard-1',
},
status: {
title: 'Loki Dashboard 1',
groups: ['General'],
},
},
];
export const getDatasource = async (ref: DataSourceRef) => {
if (ref.uid === '-- Grafana --') {
return {

View file

@ -12,8 +12,6 @@ import { DashboardDataDTO, DashboardDTO, DashboardMeta } from 'app/types/dashboa
import { defaultScopesServices, ScopesContextProvider } from '../../ScopesContextProvider';
import { getMock } from './mocks';
const getDashboardDTO: (
overrideDashboard: Partial<DashboardDataDTO>,
overrideMeta: Partial<DashboardMeta>
@ -208,7 +206,6 @@ export async function renderDashboard(
export async function resetScenes(spies: jest.SpyInstance[] = []) {
await jest.runOnlyPendingTimersAsync();
jest.useRealTimers();
getMock.mockClear();
spies.forEach((spy) => spy.mockClear());
cleanup();
}

View file

@ -1,4 +1,6 @@
import { config } from '@grafana/runtime';
import { config, setBackendSrv } from '@grafana/runtime';
import { setupMockServer } from '@grafana/test-utils/server';
import { backendSrv } from 'app/core/services/backend_srv';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
import { ScopesService } from '../ScopesService';
@ -10,18 +12,20 @@ import {
expectScopesSelectorClosed,
expectScopesSelectorDisabled,
} from './utils/assertions';
import { getDatasource, getInstanceSettings, getMock } from './utils/mocks';
import { getDatasource, getInstanceSettings } from './utils/mocks';
import { renderDashboard, resetScenes } from './utils/render';
jest.mock('@grafana/runtime', () => ({
__esModule: true,
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn(),
getBackendSrv: () => ({ get: getMock }),
getDataSourceSrv: () => ({ get: getDatasource, getInstanceSettings }),
usePluginLinks: jest.fn().mockReturnValue({ links: [] }),
}));
setBackendSrv(backendSrv);
setupMockServer();
describe('View mode', () => {
let dashboardScene: DashboardScene;
let scopesService: ScopesService;

View file

@ -4,6 +4,7 @@ import { Middleware } from 'redux';
import { allMiddleware as allApiClientMiddleware } from '@grafana/api-clients/rtkq';
import { legacyAPI } from 'app/api/clients/legacy';
import { scopeAPIv0alpha1 } from 'app/api/clients/scope/v0alpha1';
import { browseDashboardsAPI } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
import { publicDashboardApi } from 'app/features/dashboard/api/publicDashboardApi';
import { StoreState } from 'app/types/store';
@ -40,6 +41,7 @@ export function configureStore(initialState?: Partial<StoreState>) {
publicDashboardApi.middleware,
browseDashboardsAPI.middleware,
legacyAPI.middleware,
scopeAPIv0alpha1.middleware,
...allApiClientMiddleware,
...extraMiddleware
),