diff --git a/src/containers/Operations/Operations.tsx b/src/containers/Operations/Operations.tsx index 8540e2c0e..3285ca681 100644 --- a/src/containers/Operations/Operations.tsx +++ b/src/containers/Operations/Operations.tsx @@ -34,10 +34,6 @@ export function Operations({database, scrollContainerRef}: OperationsProps) { scrollContainerRef, }); - if (isAccessError(error)) { - return ; - } - const settings = React.useMemo(() => { return { ...DEFAULT_TABLE_SETTINGS, @@ -45,6 +41,10 @@ export function Operations({database, scrollContainerRef}: OperationsProps) { }; }, []); + if (isAccessError(error)) { + return ; + } + return ( diff --git a/src/store/reducers/operations.ts b/src/store/reducers/operations.ts index 4d046d6e4..2b630f541 100644 --- a/src/store/reducers/operations.ts +++ b/src/store/reducers/operations.ts @@ -8,7 +8,7 @@ import type { import {api} from './api'; -const DEFAULT_PAGE_SIZE = 10; +const DEFAULT_PAGE_SIZE = 20; export const operationsApi = api.injectEndpoints({ endpoints: (build) => ({ diff --git a/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts b/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts index 9abae7905..e1908fe7d 100644 --- a/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts +++ b/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts @@ -13,6 +13,8 @@ export class OperationsTable extends BaseModel { private emptyState: Locator; private loadingMore: Locator; private scrollContainer: Locator; + private accessDeniedState: Locator; + private accessDeniedTitle: Locator; constructor(page: Page) { super(page, page.locator('.kv-tenant-diagnostics')); @@ -22,6 +24,9 @@ export class OperationsTable extends BaseModel { this.emptyState = page.locator('.operations__table:has-text("No operations data")'); this.loadingMore = page.locator('.operations__loading-more'); this.scrollContainer = page.locator('.kv-tenant-diagnostics__page-wrapper'); + // AccessDenied component is rendered at the root level of Operations component + this.accessDeniedState = page.locator('.kv-tenant-diagnostics .empty-state'); + this.accessDeniedTitle = this.accessDeniedState.locator('.empty-state__title'); } async waitForTableVisible() { @@ -124,4 +129,69 @@ export class OperationsTable extends BaseModel { return false; } + + async isAccessDeniedVisible(): Promise { + try { + await this.accessDeniedState.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT}); + return true; + } catch { + return false; + } + } + + async getAccessDeniedTitle(): Promise { + return await this.accessDeniedTitle.innerText(); + } + + async getOperationsCount(): Promise { + // The EntitiesCount component renders a Label with the count + const countLabel = await this.page + .locator('.ydb-entities-count .g-label__content') + .textContent(); + if (!countLabel) { + return 0; + } + const match = countLabel.match(/(\d+)/); + return match ? parseInt(match[1], 10) : 0; + } + + async waitForOperationsCount(expectedCount: number, timeout = 5000): Promise { + await this.page.waitForFunction( + (expected) => { + const countElement = document.querySelector( + '.ydb-entities-count .g-label__content', + ); + if (!countElement) { + return false; + } + const text = countElement.textContent || ''; + const match = text.match(/(\d+)/); + const currentCount = match ? parseInt(match[1], 10) : 0; + return currentCount === expected; + }, + expectedCount, + {timeout}, + ); + } + + async waitForOperationsCountToChange(previousCount: number, timeout = 5000): Promise { + await this.page.waitForFunction( + (prev) => { + const countElement = document.querySelector( + '.ydb-entities-count .g-label__content', + ); + if (!countElement) { + return false; + } + const text = countElement.textContent || ''; + const match = text.match(/(\d+)/); + const currentCount = match ? parseInt(match[1], 10) : 0; + return currentCount !== prev; + }, + previousCount, + {timeout}, + ); + // Now get the actual new count + return await this.getOperationsCount(); + } } diff --git a/tests/suites/tenant/diagnostics/tabs/operations.test.ts b/tests/suites/tenant/diagnostics/tabs/operations.test.ts index c77abc89d..e719454ec 100644 --- a/tests/suites/tenant/diagnostics/tabs/operations.test.ts +++ b/tests/suites/tenant/diagnostics/tabs/operations.test.ts @@ -4,12 +4,16 @@ import {tenantName} from '../../../../utils/constants'; import {TenantPage} from '../../TenantPage'; import {Diagnostics, DiagnosticsTab} from '../Diagnostics'; -import {setupEmptyOperationsMock, setupOperationsMock} from './operationsMocks'; +import { + setupEmptyOperationsMock, + setupOperation403Mock, + setupOperationsMock, +} from './operationsMocks'; test.describe('Operations Tab - Infinite Query', () => { test('loads initial page of operations on tab click', async ({page}) => { - // Setup mocks with 30 operations (3 pages of 10) - await setupOperationsMock(page, {totalOperations: 30}); + // Setup mocks with 80 operations (4 pages of 20) + await setupOperationsMock(page, {totalOperations: 80}); const pageQueryParams = { schema: tenantName, @@ -27,10 +31,13 @@ test.describe('Operations Tab - Infinite Query', () => { await diagnostics.operations.waitForTableVisible(); await diagnostics.operations.waitForDataLoad(); - // Verify initial page loaded (should have some rows) - const rowCount = await diagnostics.operations.getRowCount(); - expect(rowCount).toBeGreaterThan(0); - expect(rowCount).toBeLessThanOrEqual(20); // Reasonable page size + // Wait a bit for the counter to stabilize after initial load + await page.waitForTimeout(1000); + + // Verify initial page loaded (should show count in badge) + const operationsCount = await diagnostics.operations.getOperationsCount(); + expect(operationsCount).toBeGreaterThan(0); + expect(operationsCount).toBeLessThanOrEqual(20); // Should have up to DEFAULT_PAGE_SIZE operations loaded initially // Verify first row data structure const firstRowData = await diagnostics.operations.getRowData(0); @@ -49,8 +56,8 @@ test.describe('Operations Tab - Infinite Query', () => { }); test('loads more operations on scroll', async ({page}) => { - // Setup mocks with 30 operations (3 pages of 10) - await setupOperationsMock(page, {totalOperations: 30}); + // Setup mocks with 80 operations (4 pages of 20) + await setupOperationsMock(page, {totalOperations: 80}); const pageQueryParams = { schema: tenantName, @@ -68,26 +75,32 @@ test.describe('Operations Tab - Infinite Query', () => { await diagnostics.operations.waitForTableVisible(); await diagnostics.operations.waitForDataLoad(); - // Get initial row count - const initialRowCount = await diagnostics.operations.getRowCount(); - expect(initialRowCount).toBeGreaterThan(0); + // Get initial operations count + const initialOperationsCount = await diagnostics.operations.getOperationsCount(); + expect(initialOperationsCount).toBeGreaterThan(0); // Scroll to bottom await diagnostics.operations.scrollToBottom(); - // Wait a bit for potential loading - await page.waitForTimeout(2000); - - // Get final row count - const finalRowCount = await diagnostics.operations.getRowCount(); + // Wait for operations count to potentially change + let finalOperationsCount: number; + try { + finalOperationsCount = await diagnostics.operations.waitForOperationsCountToChange( + initialOperationsCount, + 3000, + ); + } catch (_e) { + // If timeout, the count didn't change + finalOperationsCount = await diagnostics.operations.getOperationsCount(); + } - // Check if more rows were loaded - if (finalRowCount > initialRowCount) { - // Infinite scroll worked - more rows were loaded - expect(finalRowCount).toBeGreaterThan(initialRowCount); + // Check if more operations were loaded + if (finalOperationsCount > initialOperationsCount) { + // Infinite scroll worked - more operations were loaded + expect(finalOperationsCount).toBeGreaterThan(initialOperationsCount); } else { - // No more data to load - row count should stay the same - expect(finalRowCount).toBe(initialRowCount); + // No more data to load - operations count should stay the same + expect(finalOperationsCount).toBe(initialOperationsCount); } }); @@ -119,4 +132,103 @@ test.describe('Operations Tab - Infinite Query', () => { const rowCount = await diagnostics.operations.getRowCount(); expect(rowCount).toBeLessThanOrEqual(1); }); + + test('shows access denied when operations request returns 403', async ({page}) => { + // Setup 403 error mock + await setupOperation403Mock(page); + + const pageQueryParams = { + schema: tenantName, + database: tenantName, + tenantPage: 'diagnostics', + }; + + const tenantPageInstance = new TenantPage(page); + await tenantPageInstance.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + await diagnostics.clickTab(DiagnosticsTab.Operations); + // Wait a bit for potential loading + await page.waitForTimeout(2000); + + // Wait for access denied state to be visible + const isAccessDeniedVisible = await diagnostics.operations.isAccessDeniedVisible(); + expect(isAccessDeniedVisible).toBe(true); + + // Verify the access denied message + const accessDeniedTitle = await diagnostics.operations.getAccessDeniedTitle(); + expect(accessDeniedTitle).toBe('Access denied'); + }); + + test('loads all operations when scrolling to the bottom multiple times', async ({page}) => { + // Setup mocks with 80 operations (4 pages of 20) + await setupOperationsMock(page, {totalOperations: 80}); + + const pageQueryParams = { + schema: tenantName, + database: tenantName, + tenantPage: 'diagnostics', + }; + + const tenantPageInstance = new TenantPage(page); + await tenantPageInstance.goto(pageQueryParams); + + const diagnostics = new Diagnostics(page); + await diagnostics.clickTab(DiagnosticsTab.Operations); + + // Wait for initial data + await diagnostics.operations.waitForTableVisible(); + await diagnostics.operations.waitForDataLoad(); + + // Wait a bit for the counter to stabilize after initial load + await page.waitForTimeout(2000); + + // Get initial operations count (should be around 20) + const initialOperationsCount = await diagnostics.operations.getOperationsCount(); + expect(initialOperationsCount).toBeGreaterThan(0); + expect(initialOperationsCount).toBeLessThanOrEqual(20); + + // Keep scrolling until all operations are loaded + let previousOperationsCount = initialOperationsCount; + let currentOperationsCount = initialOperationsCount; + const maxScrollAttempts = 10; // Safety limit to prevent infinite loop + let scrollAttempts = 0; + + while (currentOperationsCount < 80 && scrollAttempts < maxScrollAttempts) { + // Scroll to bottom + await diagnostics.operations.scrollToBottom(); + + // Wait for potential loading + await page.waitForTimeout(1000); + + // Check if loading more is visible and wait for it to complete + const isLoadingVisible = await diagnostics.operations.isLoadingMoreVisible(); + if (isLoadingVisible) { + await diagnostics.operations.waitForLoadingMoreToDisappear(); + } + + // Wait for operations count to change or timeout + try { + currentOperationsCount = + await diagnostics.operations.waitForOperationsCountToChange( + previousOperationsCount, + 3000, + ); + } catch (_e) { + // If timeout, the count didn't change - we might have reached the end + currentOperationsCount = await diagnostics.operations.getOperationsCount(); + } + + previousOperationsCount = currentOperationsCount; + scrollAttempts++; + } + + // Verify all 80 operations were loaded + expect(currentOperationsCount).toBe(80); + + const rowCount = await diagnostics.operations.getRowCount(); + // Verify the last operation has the expected ID pattern + const lastRowData = await diagnostics.operations.getRowData(rowCount - 1); + expect(lastRowData['Operation ID']).toContain('ydb://'); + }); }); diff --git a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts index 8e7b32d5c..ee333dc07 100644 --- a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts +++ b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts @@ -123,7 +123,7 @@ export const setupOperationsMock = async (page: Page, options?: OperationMockOpt const url = new URL(route.request().url()); const params = Object.fromEntries(url.searchParams); - const requestedPageSize = parseInt(params.page_size || '10', 10); + const requestedPageSize = parseInt(params.page_size || '20', 10); const pageToken = params.page_token; const kind = params.kind || 'buildindex'; @@ -226,6 +226,20 @@ export const setupOperationErrorMock = async (page: Page) => { }); }; +export const setupOperation403Mock = async (page: Page) => { + await page.route(`${backend}/operation/list*`, async (route) => { + await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY)); + + await route.fulfill({ + status: 403, + contentType: 'application/json', + body: JSON.stringify({ + error: 'Forbidden', + }), + }); + }); +}; + // Helper to setup all required mocks for operations export const setupAllOperationMocks = async (page: Page, options?: {totalOperations?: number}) => { await setupOperationsMock(page, options);