From f9dc72a6b905adee8faafb8cb9d50a928c3d96b9 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Fri, 15 May 2026 10:07:03 +0200 Subject: [PATCH 01/10] CONSOLE-5233: Playwright-test-migration-for-console/app --- .gitignore | 3 +- AGENTS.md | 4 + frontend/.eslintignore | 1 + frontend/e2e/.eslintrc.json | 5 + frontend/e2e/pages/details-page.ts | 92 +++--- frontend/e2e/pages/list-page.ts | 282 ++++++++---------- frontend/e2e/pages/login-page.ts | 44 +++ frontend/e2e/pages/machine-config-page.ts | 30 ++ frontend/e2e/pages/masthead-page.ts | 58 +--- frontend/e2e/pages/modal-page.ts | 31 +- frontend/e2e/pages/nav-page.ts | 78 +++++ frontend/e2e/pages/yaml-editor-page.ts | 41 +-- ...sion-webhook-warning-notifications.spec.ts | 204 +++++++++++++ .../console/app/auth-multiuser-login.spec.ts | 104 +++++++ .../e2e/tests/console/app/debug-pod.spec.ts | 190 ++++++++++++ .../e2e/tests/console/app/deployments.spec.ts | 67 +++++ .../tests/console/app/machine-config.spec.ts | 53 ++++ .../app/start-job-from-cronjob.spec.ts | 104 +++++++ 18 files changed, 1085 insertions(+), 306 deletions(-) create mode 100644 frontend/e2e/.eslintrc.json create mode 100644 frontend/e2e/pages/login-page.ts create mode 100644 frontend/e2e/pages/machine-config-page.ts create mode 100644 frontend/e2e/pages/nav-page.ts create mode 100644 frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts create mode 100644 frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts create mode 100644 frontend/e2e/tests/console/app/debug-pod.spec.ts create mode 100644 frontend/e2e/tests/console/app/deployments.spec.ts create mode 100644 frontend/e2e/tests/console/app/machine-config.spec.ts create mode 100644 frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts diff --git a/.gitignore b/.gitignore index 42b73536ed5..1ae9898cacc 100644 --- a/.gitignore +++ b/.gitignore @@ -40,5 +40,4 @@ cypress-a11y-report.json /dynamic-demo-plugin/**/dist **/.claude/settings.local.json **/chartstore-*/ -.artifacts/ -.playwright-mcp/ +.playwright-mcp/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 88422d7a3c4..34d3e1c44c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,6 +125,10 @@ These files are the single source of truth for architecture, coding standards, a - [CONTRIBUTING.md](CONTRIBUTING.md) - contribution workflow and commit message conventions. - [README.md](README.md) - project setup, build instructions, and architecture overview. +## Playwright migration + +We are migrating Cypress e2e tests to Playwright. Use `/migrate-cypress` to convert test files and `/debug-test` to fix failing tests. Shared migration context (translation tables, structural rules, checklist) is in `.claude/migration-context.md`. + ### Dynamic plugin SDK - [Dynamic Plugin SDK documentation](frontend/packages/console-dynamic-plugin-sdk/README.md) - architecture, design principles, and development guidelines. Consult before modifying SDK code. diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 035b50201ba..7c16baf3e62 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -12,5 +12,6 @@ Godeps dynamic-demo-plugin .eslintrc.js tsconfig.json +e2e/.eslintrc.json e2e/tsconfig.json e2e/package.json diff --git a/frontend/e2e/.eslintrc.json b/frontend/e2e/.eslintrc.json new file mode 100644 index 00000000000..b956cbdd843 --- /dev/null +++ b/frontend/e2e/.eslintrc.json @@ -0,0 +1,5 @@ +{ + "rules": { + "testing-library/prefer-screen-queries": "off" + } +} diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index 6274343a6c2..32935d7ee26 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -1,75 +1,67 @@ -import { type Locator, expect } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; import BasePage from './base-page'; export class DetailsPage extends BasePage { - private readonly pageHeading = this.page.getByTestId('page-heading'); - private readonly resourceTitle = this.page.getByTestId('resource-title'); - private readonly skeletonLoader = this.page.getByTestId('skeleton-detail-view'); - readonly nodeTerminalError: Locator = this.page.getByTestId('node-terminal-error'); - readonly xtermViewport: Locator = this.page.locator('.xterm-viewport'); - - get title(): Locator { - return this.resourceTitle; - } - - getPageHeading(): Locator { - return this.pageHeading; + private readonly pageHeading = this.page.locator('[data-test="page-heading"]'); + private readonly resourceTitle = this.page.locator('[data-test-id="resource-title"]'); + private readonly skeletonView = this.page.getByTestId('skeleton-detail-view'); + private readonly actionsMenuButton = this.page.locator('[data-test-id="actions-menu-button"]'); + readonly breadcrumbLink0 = this.page.locator('[data-test-id="breadcrumb-link-0"]'); + readonly statusPopoverButton = this.page.getByTestId('popover-status-button'); + readonly enableAutoscaleButton = this.page.getByTestId('enable-autoscale'); + readonly xtermViewport = this.page.locator('.xterm-viewport'); + readonly resourcesSuccessMessage = this.page.getByTestId('resources-successfully-created'); + readonly eventTotals = this.page.getByTestId('event-totals'); + admissionWarning(testId: string): Locator { + return this.page.getByTestId(testId); } - async waitForPageLoad(): Promise { - try { - // eslint-disable-next-line no-restricted-syntax - await this.skeletonLoader.waitFor({ state: 'detached', timeout: 30_000 }); - } catch { - // Skeleton may have already disappeared - } - await expect(this.resourceTitle.or(this.pageHeading).first()).toBeVisible({ - timeout: 30_000, - }); + debugContainerLink(containerName?: string): Locator { + const testId = containerName + ? `popup-debug-container-link-${containerName}` + : 'debug-container-link'; + return this.page.getByTestId(testId); } - tab(name: string): Locator { - return this.page.getByTestId(`horizontal-link-${name}`); + async titleShouldContain(title: string): Promise { + await this.pageHeading.waitFor({ state: 'visible', timeout: 30_000 }); + await expect(this.pageHeading).toContainText(title, { timeout: 30_000 }); } - async clickPageAction(actionName: string): Promise { - await this.robustClick(this.page.getByTestId('actions-menu-button')); - await this.robustClick(this.page.getByTestId(actionName)); + async sectionHeaderShouldExist(sectionHeading: string): Promise { + await expect( + this.page.locator(`[data-test-section-heading="${sectionHeading}"]`), + ).toBeVisible(); } - getBreadcrumb(index: number): Locator { - return this.page.getByTestId(`breadcrumb-link-${index}`); + async isLoaded(): Promise { + await expect(this.skeletonView).toBeHidden({ timeout: 30_000 }); + await this.resourceTitle.waitFor({ state: 'visible', timeout: 30_000 }); + await expect(this.resourceTitle).not.toBeEmpty(); } async selectTab(name: string): Promise { - await this.navigateToTab(this.tab(name)); - } - - async clickKebabAction(actionId: string): Promise { - await this.robustClick(this.page.getByTestId(actionId)); + const tab = this.page.locator(`[data-test-id="horizontal-link-${name}"]`); + await this.robustClick(tab); } - getResourceRow(resourceId: string): Locator { - const link = this.page.locator(`a[data-test="${resourceId}"]`); - const fallback = this.page.locator( - `[data-test="${resourceId}"], [data-test-action="${resourceId}"]`, - ); - return link.or(fallback).first(); + async clickPageActionFromDropdown(actionID: string): Promise { + await this.robustClick(this.actionsMenuButton); + const action = this.page.locator(`[data-test-action="${actionID}"]:not([disabled])`); + await this.robustClick(action); } - async clickResourceRow(resourceId: string): Promise { - const row = this.getResourceRow(resourceId); - await this.robustClick(row); + async clickBreadcrumb(): Promise { + await this.robustClick(this.breadcrumbLink0); } - getResourceByAction(actionName: string): Locator { - return this.page.locator(`[data-test-action="${actionName}"]`); + async clickStatusPopover(): Promise { + await this.robustClick(this.statusPopoverButton, { timeout: 60_000 }); } - async openResourceKebabMenu(actionName: string): Promise { - const resourceRow = this.getResourceByAction(actionName); - const kebabButton = resourceRow.getByTestId('kebab-button'); - await this.robustClick(kebabButton); + async clickDebugContainerLink(containerName?: string): Promise { + await this.robustClick(this.debugContainerLink(containerName)); } } diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index 4c8e5485936..5042d1c27ad 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -1,199 +1,161 @@ -import { type Locator, expect } from '@playwright/test'; +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; import BasePage from './base-page'; export class ListPage extends BasePage { - private readonly pageHeading: Locator = this.page.getByTestId('page-heading').locator('h1'); - private readonly dataViewTable: Locator = this.page.getByTestId('data-view-table'); - private readonly dataViewCells: Locator = this.page.locator('[data-test^="data-view-cell-"]'); - private readonly nameFilterInput = this.page.getByRole('textbox', { name: 'Filter by name' }); - private readonly dataViewFilters = this.page.locator( - '[data-ouia-component-id="DataViewFilters"]', - ); - private readonly singleFilterGroup: Locator = this.page.locator( - '.co-console-data-view-single-filter .pf-v6-c-toolbar__group.pf-m-filter-group', - ); - private readonly namespaceDropdown = this.page.getByTestId('namespace-bar-dropdown'); - private readonly resourceRows = this.page.getByTestId('resource-row'); - private readonly nameFilter = this.page.getByTestId('name-filter-input'); - private readonly createButton = this.page.getByTestId('item-create'); + private readonly heading = this.page.locator('[data-test="page-heading"] h1'); - get heading(): Locator { - return this.pageHeading; + async titleShouldHaveText(title: string): Promise { + await expect(this.heading).toContainText(title); } - get table(): Locator { - return this.dataViewTable; - } + // --- Resource row helpers (older VirtualizedTable) --- - get cells(): Locator { - return this.dataViewCells; + async rowsShouldExist(resourceName: string): Promise { + await expect( + this.page.locator('[data-test-rows="resource-row"]').filter({ hasText: resourceName }), + ).toBeVisible({ timeout: 60_000 }); } - get filterGroupToggles(): Locator { - return this.singleFilterGroup.locator('.pf-v6-c-menu-toggle'); + async rowsShouldNotExist(resourceName: string): Promise { + await expect(this.page.locator(`[data-test-id="${resourceName}"]`)).toBeHidden({ + timeout: 90_000, + }); } - cell(resourceName: string, cellName = 'name'): Locator { - return this.page.getByTestId(`data-view-cell-${resourceName}-${cellName}`); + async rowsClickKebabAction(resourceName: string, actionName: string): Promise { + const row = this.page + .locator('[data-test-rows="resource-row"]') + .filter({ hasText: resourceName }); + const kebab = row.locator('[data-test-id="kebab-button"]'); + await this.robustClick(kebab); + const action = this.page.locator(`[data-test-action="${actionName}"]:not([disabled])`); + await this.robustClick(action); } - resourceLink(name: string): Locator { - return this.page.getByTestId(name); + async rowsClickStatusButton(resourceName: string): Promise { + const row = this.page + .locator('[data-test-rows="resource-row"]') + .filter({ hasText: resourceName }); + const statusButton = row.getByTestId('popover-status-button'); + await this.robustClick(statusButton, { timeout: 60_000 }); } - async waitForRows(): Promise { - try { - await expect(this.dataViewTable).toBeVisible({ timeout: 15_000 }); - } catch { - await this.retryOnError(); - await expect(this.dataViewTable).toBeVisible({ timeout: 30_000 }); + async filterByStatus(status: string): Promise { + const filterToggle = this.page.locator('[data-ouia-component-id="DataViewCheckboxFilter"]'); + if (await filterToggle.isVisible().catch(() => false)) { + await this.robustClick(filterToggle); + const filterItem = this.page.locator( + `[data-ouia-component-id="DataViewCheckboxFilter-filter-item-${status}"]`, + ); + await this.robustClick(filterItem); + await this.robustClick(filterToggle); + } else { + const filterDropdownToggle = this.page.locator( + '[data-test-id="filter-dropdown-toggle"] button', + ); + if (await filterDropdownToggle.isVisible().catch(() => false)) { + await this.robustClick(filterDropdownToggle); + await this.page.locator(`#${status}`).click(); + await this.robustClick(filterDropdownToggle); + } } } - async filterByName(name: string): Promise { - const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); - await this.robustClick(filterToggle, { timeout: 60_000 }); - await this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' }).click(); - await this.nameFilterInput.fill(name); - } - - async filterByNameInput(name: string): Promise { - await this.nameFilter.fill(name); - } - - getCell(resourceName: string, cellName = 'name'): Locator { - return this.page.getByTestId(`data-view-cell-${resourceName}-${cellName}`); - } - - async clickRowByName(resourceName: string): Promise { - const dataViewLink = this.getCell(resourceName).locator('a').first(); - const standardLink = this.page.getByTestId(resourceName); - await this.robustClick(dataViewLink.or(standardLink).first()); - } - - getNamespaceDropdown(): Locator { - return this.namespaceDropdown; - } - - getDataViewTable(): Locator { - return this.dataViewTable; - } - - getResourceRows(): Locator { - return this.resourceRows; - } - - getCreateButton(): Locator { - return this.createButton; - } - - async clickCreateButton(): Promise { - await this.robustClick(this.createButton); - } + // --- DataView row helpers (ConsoleDataView) --- + // These use generic table locators that work even if data-test attributes + // are not forwarded to the DOM by PatternFly DataView components. - async clickCreateDropdownItem(itemName: string): Promise { - await this.robustClick(this.createButton); - await this.page.getByRole('menuitem', { name: itemName }).click(); - } - - async clickCreateYAMLDropdownButton(): Promise { - await this.robustClick(this.createButton); - const yamlMenuItem = this.page.getByTestId('dropdown-menu-yaml'); - if ((await yamlMenuItem.count()) > 0) { - await this.robustClick(yamlMenuItem); - } + private dvCell(resourceName: string, cellName = 'name'): Locator { + return this.page.locator(`[data-test="data-view-cell-${resourceName}-${cellName}"]`); } - async clickKebabAction(resourceName: string, actionName: string): Promise { - const dataViewCell = this.getCell(resourceName); - const standardCell = this.page.getByTestId(resourceName); - const cell = dataViewCell.or(standardCell).first(); - const row = cell.locator('xpath=ancestor::tr'); - const kebab = row.getByTestId('kebab-button'); - await this.robustClick(kebab); - await this.robustClick(this.page.getByTestId(actionName)); + private dvRow(resourceName: string): Locator { + return this.page.locator('table tbody tr').filter({ + has: this.page.getByRole('link', { name: resourceName, exact: true }), + }); } - async clickResourceRowKebabAction(resourceName: string, actionName: string): Promise { - const row = this.resourceRows - .filter({ hasText: resourceName }) - .first(); - const kebab = row.getByTestId('kebab-button'); - await this.robustClick(kebab); - await this.robustClick(this.page.getByTestId(actionName)); + async dvRowsShouldBeLoaded(): Promise { + await expect(this.page.getByTestId('data-view-table')).toBeVisible({ timeout: 60_000 }); } - async filterByCheckbox(filterName: string, checkboxLabel: string): Promise { - const dataViewToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); - const standardToggle = this.page.getByTestId('filter-dropdown-toggle').locator('button'); - const toggle = dataViewToggle.or(standardToggle).first(); - await this.robustClick(toggle); - - if (await this.dataViewFilters.isVisible()) { - await this.page.locator('.pf-v6-c-menu__list-item', { hasText: filterName }).click(); - const checkboxFilter = this.page.locator( - '[data-ouia-component-id="DataViewCheckboxFilter"]', - ); - await this.robustClick(checkboxFilter); - const filterItem = this.page.locator( - `[data-ouia-component-id="DataViewCheckboxFilter-filter-item-${checkboxLabel}"]`, - ); - await this.robustClick(filterItem); - await this.robustClick(checkboxFilter); - } else { - const filterItem = this.page.locator(`[data-test-row-filter="${checkboxLabel}"]`); - await this.robustClick(filterItem); + private async resolveRow(resourceName: string): Promise { + const cell = this.dvCell(resourceName); + if (await cell.isVisible({ timeout: 5_000 }).catch(() => false)) { + return cell.locator('xpath=ancestor::tr'); } + return this.dvRow(resourceName); } - async clickFirstRowLink(): Promise { - const firstLink = this.dataViewCells.first().locator('a').first(); - await this.robustClick(firstLink); - } - - async clickFirstRowLinkMatching(pattern: RegExp): Promise { - const safeFlags = pattern.flags.replace(/[gy]/g, ''); - const safePattern = new RegExp(pattern.source, safeFlags); - const links = this.dataViewCells.locator('a'); - const count = await links.count(); - for (let i = 0; i < count; i++) { - const text = await links.nth(i).textContent(); - if (text && safePattern.test(text)) { - await this.robustClick(links.nth(i)); - return; + async dvRowsShouldExist(resourceName: string, cellName = 'name'): Promise { + const cell = this.dvCell(resourceName, cellName); + const row = this.dvRow(resourceName); + try { + await expect(cell).toBeVisible({ timeout: 30_000 }); + } catch { + await this.page.reload({ waitUntil: 'domcontentloaded' }); + try { + await expect(cell).toBeVisible({ timeout: 30_000 }); + } catch { + await expect(row).toBeVisible({ timeout: 30_000 }); } } - throw new Error(`No row link matching ${pattern} found`); } - async getFirstCellText(): Promise { - const link = this.page.locator('[data-test^="data-view-cell-"]').first().locator('a').first(); - return (await link.textContent()) ?? ''; + async dvRowsShouldNotExist(resourceName: string): Promise { + const cell = this.dvCell(resourceName); + await expect(cell).toBeHidden({ timeout: 90_000 }); } - async selectProject(projectName: string): Promise { - const dropdownButton = this.namespaceDropdown.getByRole('button'); - await this.robustClick(dropdownButton); - - const searchInput = this.page.getByRole('searchbox', { name: 'Select project...' }); - // eslint-disable-next-line no-restricted-syntax - await searchInput.waitFor({ state: 'visible' }); - - const systemSwitch = this.page.getByTestId('showSystemSwitch'); - if ((await systemSwitch.count()) > 0 && !(await systemSwitch.isChecked())) { - await systemSwitch.check(); - } - - await searchInput.fill(projectName); - const item = this.page.getByRole('menuitem', { name: projectName, exact: true }); - await this.robustClick(item); + async dvRowsCountShouldBe(count: number): Promise { + await expect(this.page.locator('table tbody tr')).toHaveCount(count, { timeout: 60_000 }); } - async selectAllProjects(): Promise { - const dropdownButton = this.namespaceDropdown.getByRole('button'); - await this.robustClick(dropdownButton); - const item = this.page.getByRole('menuitem', { name: 'All Projects', exact: true }); - await this.robustClick(item); + async dvRowsClickKebabAction(resourceName: string, actionName: string): Promise { + const row = await this.resolveRow(resourceName); + const kebab = row.locator('[data-test-id="kebab-button"]'); + await this.robustClick(kebab); + const action = this.page.locator(`[data-test-action="${actionName}"]:not([disabled])`); + await this.robustClick(action); + } + + async dvRowsClickStatusButton(resourceName: string): Promise { + const row = await this.resolveRow(resourceName); + const statusButton = row.getByTestId('popover-status-button'); + await this.robustClick(statusButton, { timeout: 60_000 }); + } + + async dvFilterByName(name: string): Promise { + const filters = this.page.locator('[data-ouia-component-id="DataViewFilters"]'); + await this.robustClick(filters.locator('.pf-v6-c-menu-toggle').first()); + await this.robustClick( + this.page.locator('.pf-v6-c-menu__list-item').filter({ hasText: 'Name' }), + ); + const input = this.page.locator('[aria-label="Filter by name"]'); + await input.clear(); + await input.fill(name); + } + + async dvFilterBy(filterName: string, checkboxLabel: string): Promise { + await this.dvRowsShouldBeLoaded(); + const filters = this.page.locator('[data-ouia-component-id="DataViewFilters"]'); + await this.robustClick(filters.locator('.pf-v6-c-menu-toggle').first()); + await this.robustClick( + this.page.locator('.pf-v6-c-menu__list-item').filter({ hasText: filterName }), + ); + await this.robustClick(this.page.locator('[data-ouia-component-id="DataViewCheckboxFilter"]')); + const filterItem = this.page.locator( + `[data-ouia-component-id="DataViewCheckboxFilter-filter-item-${checkboxLabel}"]`, + ); + await expect(filterItem).toBeVisible(); + await this.robustClick(filterItem); + await expect(this.page).toHaveURL(new RegExp(`=${checkboxLabel}`), { timeout: 10_000 }); + await this.robustClick(this.page.locator('[data-ouia-component-id="DataViewCheckboxFilter"]')); + } + + get clickCreateYAMLButton(): Locator { + return this.page.getByTestId('item-create'); } } diff --git a/frontend/e2e/pages/login-page.ts b/frontend/e2e/pages/login-page.ts new file mode 100644 index 00000000000..620eec50f83 --- /dev/null +++ b/frontend/e2e/pages/login-page.ts @@ -0,0 +1,44 @@ +import BasePage from './base-page'; + +export class LoginPage extends BasePage { + private readonly loginButton = this.page.locator('[data-test-id="login"]'); + private readonly usernameInput = this.page.locator('#inputUsername'); + private readonly passwordInput = this.page.locator('#inputPassword'); + private readonly submitButton = this.page.locator('button[type="submit"]'); + private readonly userDropdownToggle = this.page.getByTestId('user-dropdown-toggle'); + + providerButton(provider: string) { + return this.page.getByText(provider, { exact: true }); + } + + async loginAs(provider: string, username: string, password: string): Promise { + const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; + await this.page.goto(baseURL, { timeout: 90_000, waitUntil: 'domcontentloaded' }); + + const authDisabled = await this.page + .evaluate(() => (window as any).SERVER_FLAGS?.authDisabled) + .catch(() => false); + + if (authDisabled) { + return false; + } + + const providerBtn = this.providerButton(provider); + await this.loginButton + .or(this.usernameInput) + .or(providerBtn) + .first() + .waitFor({ state: 'visible', timeout: 30_000 }); + + if ((await providerBtn.count()) > 0 && (await providerBtn.isVisible())) { + await providerBtn.click(); + await this.usernameInput.waitFor({ state: 'visible', timeout: 30_000 }); + } + + await this.usernameInput.fill(username); + await this.passwordInput.fill(password); + await this.submitButton.click(); + await this.userDropdownToggle.waitFor({ state: 'visible', timeout: 60_000 }); + return true; + } +} diff --git a/frontend/e2e/pages/machine-config-page.ts b/frontend/e2e/pages/machine-config-page.ts new file mode 100644 index 00000000000..30e7c05b1c1 --- /dev/null +++ b/frontend/e2e/pages/machine-config-page.ts @@ -0,0 +1,30 @@ +import { expect } from '@playwright/test'; +import type { Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class MachineConfigPage extends BasePage { + readonly configFilePath = this.page.getByTestId('config-file-path-0'); + readonly copyToClipboard = this.page.locator('.co-copy-to-clipboard__text'); + + sectionHeading(heading: string): Locator { + return this.page.locator(`[data-test-section-heading="${heading}"]`); + } + + errorHeading(text: string): Locator { + return this.page.getByText(text); + } + + async checkConfigFileDetails(mode: number, overwrite: boolean, content: string): Promise { + await this.configFilePath.scrollIntoViewIfNeeded(); + await this.page.locator('button[aria-label="Info"]').first().click(); + const descriptionList = this.page.locator('[class*="description-list"]'); + await expect(descriptionList.getByText(String(mode), { exact: true })).toBeVisible(); + await expect(descriptionList.getByText(String(overwrite), { exact: true })).toBeVisible(); + const decoded = decodeURIComponent(content) + .replace(/^(data:,)/, '') + .slice(0, 30); + const codeBlock = this.page.locator('code').first(); + await expect(codeBlock).toContainText(decoded); + } +} diff --git a/frontend/e2e/pages/masthead-page.ts b/frontend/e2e/pages/masthead-page.ts index 4e494b97e21..192e1fa27d0 100644 --- a/frontend/e2e/pages/masthead-page.ts +++ b/frontend/e2e/pages/masthead-page.ts @@ -1,59 +1,13 @@ -import { type Locator, expect } from '@playwright/test'; +import { expect } from '@playwright/test'; import BasePage from './base-page'; export class MastheadPage extends BasePage { - private readonly logo: Locator = this.page.getByTestId('masthead-logo'); - private readonly quickCreateToggle: Locator = this.page.getByTestId('quick-create-dropdown'); - private readonly userDropdownToggle: Locator = this.page.getByTestId('user-dropdown-toggle'); - private readonly copyLoginCommandLink: Locator = this.page - .getByTestId('copy-login-command') - .locator('a'); - private readonly logOutItem: Locator = this.page.getByTestId('log-out'); - readonly pageHeading: Locator = this.page.getByTestId('page-heading').locator('h1'); + readonly loadingIndicator = this.page.getByTestId('loading-indicator'); + readonly globalNotifications = this.page.getByTestId('global-notifications'); - get logoLocator(): Locator { - return this.logo; - } - - async openQuickCreate(): Promise { - // Move mouse away first to dismiss any tooltip that could intercept the click - await this.page.mouse.move(0, 0); - await this.quickCreateToggle.click(); - await expect(this.page.getByTestId('qc-import-yaml')).toBeVisible({ timeout: 10_000 }); - } - - async clickQuickCreateItem(testId: string): Promise { - // Navigate via href — PF6 dropdown re-renders detach the anchor mid-click - const link = this.page.getByTestId(testId).getByRole('menuitem'); - const href = await link.getAttribute('href'); - if (href) { - await this.page.goto(href); - } else { - await link.click(); - } - } - - async openUserDropdown(): Promise { - await this.userDropdownToggle.click(); - } - - async isAuthDisabled(): Promise { - return this.page.evaluate(() => { - const w = window as Window & { SERVER_FLAGS?: { authDisabled?: boolean } }; - return !!w.SERVER_FLAGS?.authDisabled; - }); - } - - async clickCopyLoginCommand(): Promise { - await expect(this.copyLoginCommandLink).toBeVisible(); - await this.copyLoginCommandLink.evaluate((el: HTMLAnchorElement) => - el.removeAttribute('target'), - ); - await this.copyLoginCommandLink.click(); - } - - async clickLogOut(): Promise { - await this.robustClick(this.logOutItem); + async usernameShouldHaveText(text: string): Promise { + const toggle = this.page.getByTestId('user-dropdown-toggle'); + await expect(toggle).toHaveText(text); } } diff --git a/frontend/e2e/pages/modal-page.ts b/frontend/e2e/pages/modal-page.ts index 1925d4d203b..f67970b4329 100644 --- a/frontend/e2e/pages/modal-page.ts +++ b/frontend/e2e/pages/modal-page.ts @@ -1,33 +1,38 @@ -import type { Locator } from '@playwright/test'; import { expect } from '@playwright/test'; import BasePage from './base-page'; export class ModalPage extends BasePage { - private readonly cancelButton = this.page.getByTestId('modal-cancel-action'); - private readonly submitButton = this.page.getByTestId('confirm-action'); - - getCancelButton(): Locator { - return this.cancelButton; + private get cancelButton() { + return this.page.locator('[data-test-id="modal-cancel-action"]'); } - getSubmitButton(): Locator { - return this.submitButton; + private get submitButton() { + return this.page.locator('button[type=submit]'); } - async waitForOpen(): Promise { + async shouldBeOpened(): Promise { + await this.cancelButton.scrollIntoViewIfNeeded(); await expect(this.cancelButton).toBeVisible({ timeout: 20_000 }); } - async waitForClosed(): Promise { - await expect(this.cancelButton).not.toBeAttached({ timeout: 30_000 }); + async shouldBeClosed(): Promise { + await expect(this.cancelButton).toBeHidden(); } async submit(): Promise { - await this.robustClick(this.submitButton); + await this.submitButton.click(); } async cancel(): Promise { - await this.robustClick(this.cancelButton); + await this.cancelButton.click(); + } + + async submitShouldBeDisabled(): Promise { + await expect(this.submitButton).toBeDisabled(); + } + + async submitShouldBeEnabled(): Promise { + await expect(this.submitButton).toBeEnabled(); } } diff --git a/frontend/e2e/pages/nav-page.ts b/frontend/e2e/pages/nav-page.ts new file mode 100644 index 00000000000..baaa97061c5 --- /dev/null +++ b/frontend/e2e/pages/nav-page.ts @@ -0,0 +1,78 @@ +import { expect } from '@playwright/test'; + +import BasePage from './base-page'; + +export class NavPage extends BasePage { + readonly clusterSettingsHeading = this.page.locator( + '[data-test-id="cluster-settings-page-heading"]', + ); + + private get sidebar() { + return this.page.locator('#page-sidebar'); + } + + private get perspectiveSwitcherToggle() { + return this.page.locator('[data-test-id="perspective-switcher-toggle"]'); + } + + async perspectiveSwitcherShouldHaveText(text: string): Promise { + const toggle = this.perspectiveSwitcherToggle; + await toggle.scrollIntoViewIfNeeded(); + + const isSinglePerspective = (await toggle.getAttribute('id')) === 'core-platform-perspective'; + if (isSinglePerspective) { + await expect(toggle).toContainText(text, { timeout: 30_000 }); + } else { + await expect(toggle.locator('.pf-v6-c-menu-toggle__text')).toContainText(text, { + timeout: 30_000, + }); + } + } + + async changePerspectiveTo(perspective: string): Promise { + await this.page.waitForLoadState('domcontentloaded'); + const toggle = this.perspectiveSwitcherToggle; + await toggle.scrollIntoViewIfNeeded(); + await toggle.waitFor({ state: 'visible' }); + + const isSinglePerspective = (await toggle.getAttribute('id')) === 'core-platform-perspective'; + if (isSinglePerspective) { + return; + } + + const currentText = await toggle.locator('.pf-v6-c-menu-toggle__text').textContent(); + + if (currentText?.trim() === perspective) { + return; + } + + await this.robustClick(toggle); + await expect(toggle).toHaveAttribute('aria-expanded', 'true', { timeout: 5_000 }); + const option = this.page + .locator('[data-test-id="perspective-switcher-menu-option"]') + .filter({ hasText: perspective }); + await this.robustClick(option); + } + + async shouldHaveNavSection(path: string[]): Promise { + for (const item of path) { + await expect(this.sidebar).toContainText(item); + } + } + + async shouldNotHaveNavSection(path: string[]): Promise { + const target = path[path.length - 1]; + await expect(this.sidebar.getByText(target, { exact: true })).toBeHidden(); + } + + async clickNavLink(path: string[]): Promise { + const navItem = this.sidebar.getByText(path[0]); + const expanded = await navItem.getAttribute('aria-expanded'); + if (expanded !== 'true') { + await this.robustClick(navItem); + } + if (path.length === 2) { + await this.robustClick(this.sidebar.getByText(path[1])); + } + } +} diff --git a/frontend/e2e/pages/yaml-editor-page.ts b/frontend/e2e/pages/yaml-editor-page.ts index 0b2adaacb18..166fd1ae68a 100644 --- a/frontend/e2e/pages/yaml-editor-page.ts +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -1,38 +1,21 @@ -import type { Locator } from '@playwright/test'; -import { expect } from '@playwright/test'; - import BasePage from './base-page'; export class YamlEditorPage extends BasePage { - private readonly codeEditor = this.page.getByTestId('code-editor'); - private readonly saveButton = this.page.getByTestId('save-changes'); - private readonly reloadButton = this.page.getByTestId('reload-object'); - private readonly yamlError = this.page.getByTestId('yaml-error'); - private readonly resourceSidebar = this.page.getByTestId('resource-sidebar'); - - async waitForEditorReady(): Promise { - await expect(this.codeEditor).toBeVisible({ timeout: 30_000 }); - } - - async waitForSidebarLoaded(): Promise { - if ((await this.resourceSidebar.count()) > 0) { - await expect(this.resourceSidebar).toBeAttached({ timeout: 30_000 }); - } - } - - getSaveButton(): Locator { - return this.saveButton; - } - - getYamlError(): Locator { - return this.yamlError; + async isImportLoaded(): Promise { + await this.page.locator('.monaco-editor textarea').first().waitFor({ + state: 'visible', + timeout: 30_000, + }); } - async clickSave(): Promise { - await this.robustClick(this.saveButton); + async setEditorContent(text: string): Promise { + await this.page.evaluate((content) => { + const models = (window as any).monaco.editor.getModels(); + models[0].setValue(content); + }, text); } - async clickReload(): Promise { - await this.robustClick(this.reloadButton); + async clickSaveCreateButton(): Promise { + await this.page.getByTestId('save-changes').click(); } } diff --git a/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts b/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts new file mode 100644 index 00000000000..188238e732b --- /dev/null +++ b/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts @@ -0,0 +1,204 @@ +import { test, expect } from '../../../fixtures'; +import { DetailsPage } from '../../../pages/details-page'; +import { YamlEditorPage } from '../../../pages/yaml-editor-page'; + +const POD_NAME = 'pod1'; +const DEPLOY_NAME = 'deploy1'; +const CONTAINER_NAME = 'container1'; +const WARNING_FOO = '299 - "[pod-must-have-label-foo] you must provide labels: {"foo"}"'; +const WARNING_BAR = '299 - "[deployment-must-have-label-bar] you must provide labels: {"bar"}"'; +const LEARN_MORE_ID = 'admission-webhook-warning-learn-more'; +const WARNING_ID = 'admission-webhook-warning'; + +test.describe('Admission Webhook warning notification', () => { + const testNs = `e2e-admission-${Date.now()}`; + + const pod1ReqObj = `apiVersion: v1 +kind: Pod +metadata: + name: ${POD_NAME}-a + labels: + app: httpd + namespace: ${testNs} +spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: ${CONTAINER_NAME} + image: 'image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest' + ports: + - containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL`; + + const bulkResourcesReqObj = `apiVersion: v1 +kind: Pod +metadata: + name: ${POD_NAME}-b + labels: + app: httpd + namespace: ${testNs} +spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: ${CONTAINER_NAME} + image: 'image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest' + ports: + - containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${DEPLOY_NAME} + annotations: {} + namespace: ${testNs} +spec: + selector: + matchLabels: + app: deploy1 + replicas: 3 + template: + metadata: + labels: + app: deploy1 + spec: + containers: + - name: ${CONTAINER_NAME} + image: >- + image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest + ports: + - containerPort: 8080 + protocol: TCP + env: + - name: app + value: frontennd + imagePullSecrets: [] + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 25% + maxUnavailable: 25% + paused: false +`; + + test.beforeAll(async ({ k8sClient }) => { + await k8sClient.createNamespace(testNs); + await k8sClient.waitForNamespaceReady(testNs); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(testNs); + }); + + test('Create a pod and display Admission Webhook warning notification', async ({ page }) => { + const yamlEditor = new YamlEditorPage(page); + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/import`); + await yamlEditor.isImportLoaded(); + await yamlEditor.setEditorContent(pod1ReqObj); + + await page.route(`**/api/kubernetes/api/v1/namespaces/${testNs}/pods`, async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { + ...response.headers(), + Warning: WARNING_FOO, + }, + }); + }); + + await yamlEditor.clickSaveCreateButton(); + await detailsPage.sectionHeaderShouldExist('Pod details'); + + const warning = detailsPage.admissionWarning(WARNING_ID); + await expect(warning).toContainText('Admission Webhook Warning'); + await expect(warning).toContainText(`Pod ${POD_NAME}-a violates policy ${WARNING_FOO}`); + + const learnMore = detailsPage.admissionWarning(LEARN_MORE_ID); + await expect(learnMore).toContainText('Learn more'); + await learnMore.click(); + }); + + test('Create bulk resources and display Admission Webhook warning notifications', async ({ + page, + }) => { + const yamlEditor = new YamlEditorPage(page); + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/import`); + await yamlEditor.isImportLoaded(); + await yamlEditor.setEditorContent(bulkResourcesReqObj); + + await page.route(`**/api/kubernetes/api/v1/namespaces/${testNs}/pods`, async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { + ...response.headers(), + Warning: WARNING_FOO, + }, + }); + }); + + await page.route( + `**/api/kubernetes/apis/apps/v1/namespaces/${testNs}/deployments`, + async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + const response = await route.fetch(); + await route.fulfill({ + response, + headers: { + ...response.headers(), + Warning: WARNING_BAR, + }, + }); + }, + ); + + await yamlEditor.clickSaveCreateButton(); + + await expect(detailsPage.resourcesSuccessMessage).toContainText( + 'Resources successfully created', + ); + + const warning = detailsPage.admissionWarning(WARNING_ID); + await expect(warning).toHaveCount(2); + await expect(warning.first()).toContainText('Admission Webhook Warning'); + await expect( + warning.filter({ hasText: `Pod ${POD_NAME}-b violates policy ${WARNING_FOO}` }), + ).toBeVisible(); + await expect( + warning.filter({ hasText: `Deployment ${DEPLOY_NAME} violates policy ${WARNING_BAR}` }), + ).toBeVisible(); + + const learnMore = detailsPage.admissionWarning(LEARN_MORE_ID); + await expect(learnMore.first()).toContainText('Learn more'); + await learnMore.first().click(); + }); +}); diff --git a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts new file mode 100644 index 00000000000..86def17f0d9 --- /dev/null +++ b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '../../../fixtures'; +import { LoginPage } from '../../../pages/login-page'; +import { MastheadPage } from '../../../pages/masthead-page'; +import { NavPage } from '../../../pages/nav-page'; + +const KUBEADMIN_IDP = 'kube:admin'; +const KUBEADMIN_USERNAME = 'kubeadmin'; + +test.describe('Auth test', () => { + test.use({ storageState: { cookies: [], origins: [] } }); + + test("logs in as 'test' user via htpasswd identity provider", async ({ page }) => { + const kubeadminPassword = process.env.BRIDGE_KUBEADMIN_PASSWORD; + const htpasswdPassword = process.env.BRIDGE_HTPASSWD_PASSWORD; + + if (!kubeadminPassword || !htpasswdPassword) { + test.skip(); + return; + } + + const idp = process.env.BRIDGE_HTPASSWD_IDP || 'test'; + const username = process.env.BRIDGE_HTPASSWD_USERNAME || 'test'; + const passwd = htpasswdPassword || 'test'; + + const loginPage = new LoginPage(page); + const masthead = new MastheadPage(page); + const nav = new NavPage(page); + + await test.step('Login as test user', async () => { + const loggedIn = await loginPage.loginAs(idp, username, passwd); + if (!loggedIn) { + test.skip(true, 'Auth is disabled - skipping auth test'); + } + }); + + await test.step('Verify user logged in', async () => { + await masthead.usernameShouldHaveText(username); + }); + + await test.step('Switch to Core platform perspective', async () => { + await nav.changePerspectiveTo('Core platform'); + await nav.perspectiveSwitcherShouldHaveText('Core platform'); + }); + + await test.step('Verify test user has restricted access', async () => { + await nav.shouldNotHaveNavSection(['Administration', 'Cluster Status']); + await nav.shouldNotHaveNavSection(['Administration', 'Cluster Settings']); + await nav.shouldNotHaveNavSection(['Administration', 'Namespaces']); + await nav.shouldNotHaveNavSection(['Administration', 'Custom Resource Definitions']); + await nav.shouldNotHaveNavSection(['Ecosystem', 'Software Catalog']); + await nav.shouldNotHaveNavSection(['Storage', 'Persistent Volumes']); + await nav.shouldNotHaveNavSection(['Compute']); + await nav.shouldNotHaveNavSection(['Monitoring']); + }); + }); + + test("log in as 'kubeadmin' user", async ({ page }) => { + const kubeadminPassword = process.env.BRIDGE_KUBEADMIN_PASSWORD; + if (!kubeadminPassword) { + test.skip(); + return; + } + + const loginPage = new LoginPage(page); + const masthead = new MastheadPage(page); + const nav = new NavPage(page); + + await test.step('Login as kubeadmin', async () => { + const loggedIn = await loginPage.loginAs( + KUBEADMIN_IDP, + KUBEADMIN_USERNAME, + kubeadminPassword, + ); + if (!loggedIn) { + test.skip(true, 'Auth is disabled - skipping auth test'); + } + }); + + await test.step('Verify kubeadmin logged in', async () => { + await expect(masthead.loadingIndicator).toBeHidden(); + await masthead.usernameShouldHaveText(KUBEADMIN_IDP); + await expect(masthead.globalNotifications).toContainText( + 'You are logged in as a temporary administrative user.', + ); + }); + + await test.step('Verify Core platform perspective', async () => { + await nav.perspectiveSwitcherShouldHaveText('Core platform'); + + const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; + if (!baseURL.includes('localhost')) { + await nav.changePerspectiveTo('Core platform'); + await nav.perspectiveSwitcherShouldHaveText('Core platform'); + } + }); + + await test.step('Verify kubeadmin has admin access', async () => { + await nav.shouldHaveNavSection(['Compute']); + await nav.shouldHaveNavSection(['Operators']); + await nav.clickNavLink(['Administration', 'Cluster Settings']); + await expect(nav.clusterSettingsHeading).toBeVisible(); + }); + }); +}); diff --git a/frontend/e2e/tests/console/app/debug-pod.spec.ts b/frontend/e2e/tests/console/app/debug-pod.spec.ts new file mode 100644 index 00000000000..e0aa57faedf --- /dev/null +++ b/frontend/e2e/tests/console/app/debug-pod.spec.ts @@ -0,0 +1,190 @@ +import { test, expect } from '../../../fixtures'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; +import { YamlEditorPage } from '../../../pages/yaml-editor-page'; + +const POD_NAME = 'pod1'; +const CONTAINER_NAME = 'container1'; +const podToDebug = `apiVersion: v1 +kind: Pod +metadata: + name: ${POD_NAME} +spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: ${CONTAINER_NAME} + image: quay.io/fedora/fedora + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + restartPolicy: Always`; + +async function pollForPodCrashState( + k8sClient: any, + namespace: string, + podName: string, + timeoutMs: number, +): Promise<{ ready: boolean; reason: string }> { + const deadline = Date.now() + timeoutMs; + let lastReason = 'pod not found'; + + while (Date.now() < deadline) { + try { + const pods = await k8sClient.getPods(namespace); + const pod = pods.find((p: any) => p.metadata?.name === podName); + if (!pod) { + lastReason = 'pod not found'; + } else if (!pod.status?.containerStatuses?.length) { + lastReason = `phase=${pod.status?.phase || 'unknown'}, no containerStatuses yet`; + } else { + const container = pod.status.containerStatuses[0]; + const waitingReason = container?.state?.waiting?.reason; + const restartCount = container?.restartCount ?? 0; + + if (waitingReason === 'CrashLoopBackOff' || restartCount >= 1) { + return { ready: true, reason: waitingReason || `restartCount=${restartCount}` }; + } + + if (waitingReason === 'ImagePullBackOff' || waitingReason === 'ErrImagePull') { + lastReason = `image pull failed: ${waitingReason}`; + } else if (waitingReason) { + lastReason = `waiting: ${waitingReason}`; + } else if (container?.state?.running) { + lastReason = 'container running (not crashing yet)'; + } else if (container?.state?.terminated) { + lastReason = `terminated: reason=${container.state.terminated.reason}, exitCode=${container.state.terminated.exitCode}`; + } else { + lastReason = `unknown state: ${JSON.stringify(container?.state)}`; + } + } + } catch (err) { + lastReason = `error: ${err instanceof Error ? err.message : String(err)}`; + } + await new Promise((r) => setTimeout(r, 3_000)); + } + return { ready: false, reason: lastReason }; +} + +test.describe.serial('Debug pod', () => { + const testNs = `e2e-debug-pod-${Date.now()}`; + + test.beforeAll(async ({ k8sClient }) => { + // Create namespace WITHOUT openshift.io/run-level label so that SCC admission + // injects the correct runAsUser for pods with runAsNonRoot: true + await k8sClient.coreV1Api.createNamespace({ + body: { metadata: { name: testNs } }, + }); + await k8sClient.waitForNamespaceReady(testNs); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(testNs); + }); + + test('Create pod that has crashbackloop error', async ({ page, k8sClient }) => { + test.setTimeout(300_000); + const yamlEditor = new YamlEditorPage(page); + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/import`); + await yamlEditor.isImportLoaded(); + await yamlEditor.setEditorContent(podToDebug); + await yamlEditor.clickSaveCreateButton(); + await expect(page.getByTestId('yaml-error')).toBeHidden(); + await detailsPage.sectionHeaderShouldExist('Pod details'); + + // Wait for pod to enter CrashLoopBackOff so debug links appear in subsequent tests + const podState = await pollForPodCrashState(k8sClient, testNs, POD_NAME, 120_000); + expect(podState.ready, `Pod never crashed. Last state: ${podState.reason}`).toBe(true); + }); + + test('Opens debug terminal page from Logs subsection', async ({ page }) => { + test.setTimeout(300_000); + const listPage = new ListPage(page); + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/pods`); + await listPage.dvRowsShouldExist(POD_NAME); + await page.goto(`/k8s/ns/${testNs}/pods/${POD_NAME}`); + await detailsPage.isLoaded(); + await detailsPage.selectTab('Logs'); + await detailsPage.isLoaded(); + await detailsPage.debugContainerLink().waitFor({ state: 'visible', timeout: 30_000 }); + await detailsPage.clickDebugContainerLink(); + await listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); + await expect(detailsPage.xtermViewport).toBeAttached({ timeout: 30_000 }); + await detailsPage.clickBreadcrumb(); + await listPage.dvRowsShouldExist(POD_NAME); + }); + + test('Opens debug terminal page from Pod Details - Status tool tip', async ({ page }) => { + test.setTimeout(300_000); + const listPage = new ListPage(page); + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/pods/${POD_NAME}`); + await detailsPage.isLoaded(); + await detailsPage.clickStatusPopover(); + // Regression test for OCPBUGS-83813: Wait for popover content to be stable before clicking + // https://issues.redhat.com/browse/OCPBUGS-83813 + const debugLink = detailsPage.debugContainerLink(CONTAINER_NAME); + await expect(debugLink).toBeVisible({ timeout: 30_000 }); + await detailsPage.clickDebugContainerLink(CONTAINER_NAME); + await listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); + await expect(detailsPage.xtermViewport).toBeAttached({ timeout: 30_000 }); + await detailsPage.clickBreadcrumb(); + await listPage.dvRowsShouldExist(POD_NAME); + }); + + test('Opens debug terminal page from Pods Page - Status tool tip', async ({ + page, + k8sClient, + }) => { + test.setTimeout(300_000); + const listPage = new ListPage(page); + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/pods`); + await listPage.dvRowsShouldExist(POD_NAME); + await listPage.dvRowsClickStatusButton(POD_NAME); + // Regression test for OCPBUGS-83813: Wait for popover content to be stable before clicking + // https://issues.redhat.com/browse/OCPBUGS-83813 + const debugLink = detailsPage.debugContainerLink(CONTAINER_NAME); + await expect(debugLink).toBeVisible({ timeout: 30_000 }); + await debugLink.click(); + await listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); + await expect(detailsPage.xtermViewport).toBeAttached({ timeout: 30_000 }); + + // Debug pod should not copy main pod network info + const pods = await k8sClient.getPods(testNs); + expect(pods.length).toBeGreaterThanOrEqual(2); + const ipAddressOne = pods[0]?.status?.podIP; + const ipAddressTwo = pods[1]?.status?.podIP; + expect(ipAddressOne).not.toEqual(ipAddressTwo); + + await detailsPage.clickBreadcrumb(); + await listPage.dvRowsShouldExist(POD_NAME); + }); + + test('Debug pod should be terminated after leaving debug container page', async ({ + page, + k8sClient, + }) => { + const listPage = new ListPage(page); + + await page.goto(`/k8s/ns/${testNs}/pods`); + await listPage.dvRowsShouldExist(POD_NAME); + await listPage.filterByStatus('Running'); + + const pods = await k8sClient.getPods(testNs); + const debugPod = pods.find((p) => p.metadata?.name !== POD_NAME); + if (debugPod?.metadata?.name) { + await listPage.dvRowsShouldNotExist(debugPod.metadata.name); + } + }); +}); diff --git a/frontend/e2e/tests/console/app/deployments.spec.ts b/frontend/e2e/tests/console/app/deployments.spec.ts new file mode 100644 index 00000000000..ca0a5719cfb --- /dev/null +++ b/frontend/e2e/tests/console/app/deployments.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from '../../../fixtures'; +import { DetailsPage } from '../../../pages/details-page'; + +test.describe.serial('Deployment resource details page', () => { + const testNs = `e2e-deployments-${Date.now()}`; + const workloadName = `deployment-e2e`; + + test.beforeAll(async ({ k8sClient }) => { + await k8sClient.createNamespace(testNs); + await k8sClient.waitForNamespaceReady(testNs); + + await k8sClient.appsV1Api.createNamespacedDeployment({ + namespace: testNs, + body: { + apiVersion: 'apps/v1', + kind: 'Deployment', + metadata: { name: workloadName, namespace: testNs }, + spec: { + replicas: 0, + selector: { matchLabels: { app: workloadName } }, + template: { + metadata: { labels: { app: workloadName } }, + spec: { + containers: [{ name: 'httpd', image: 'httpd' }], + }, + }, + }, + }, + }); + + await k8sClient.createCustomResource('autoscaling', 'v1', testNs, 'horizontalpodautoscalers', { + apiVersion: 'autoscaling/v1', + kind: 'HorizontalPodAutoscaler', + metadata: { name: workloadName, namespace: testNs }, + spec: { + scaleTargetRef: { + apiVersion: 'apps/v1', + kind: 'Deployment', + name: workloadName, + }, + minReplicas: 1, + maxReplicas: 10, + }, + }); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(testNs); + }); + + test('Enable deployment autoscale button should exist', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/deployments/${workloadName}`); + await detailsPage.isLoaded(); + await expect(detailsPage.enableAutoscaleButton).toBeVisible(); + await detailsPage.enableAutoscaleButton.click(); + }); + + test('Enable deployment autoscale button should not exist', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/deployments/${workloadName}`); + await detailsPage.isLoaded(); + await expect(detailsPage.enableAutoscaleButton).toBeHidden({ timeout: 10_000 }); + }); +}); diff --git a/frontend/e2e/tests/console/app/machine-config.spec.ts b/frontend/e2e/tests/console/app/machine-config.spec.ts new file mode 100644 index 00000000000..cc9a83b6ef0 --- /dev/null +++ b/frontend/e2e/tests/console/app/machine-config.spec.ts @@ -0,0 +1,53 @@ +import { test, expect } from '../../../fixtures'; +import { DetailsPage } from '../../../pages/details-page'; +import { MachineConfigPage } from '../../../pages/machine-config-page'; + +const MC_WITH_CONFIG_FILES = '00-master'; +const MC_WITHOUT_CONFIG_FILES = '99-master-ssh'; +const MC_DETAILS_PAGE_URL = '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfig/'; +const MC_SECTION_HEADING = 'Configuration files'; + +test.describe('MachineConfig resource details page', () => { + test(`${MC_WITH_CONFIG_FILES} displays configuration files`, async ({ page, k8sClient }) => { + const detailsPage = new DetailsPage(page); + const mcPage = new MachineConfigPage(page); + + await page.goto(`${MC_DETAILS_PAGE_URL}${MC_WITH_CONFIG_FILES}`); + await detailsPage.isLoaded(); + await detailsPage.titleShouldContain(MC_WITH_CONFIG_FILES); + + await expect(mcPage.sectionHeading(MC_SECTION_HEADING)).toBeVisible(); + await expect(mcPage.configFilePath).toBeVisible(); + await expect(mcPage.copyToClipboard.first()).toBeVisible(); + + const mcResource: any = await k8sClient.customObjectsApi.getClusterCustomObject({ + group: 'machineconfiguration.openshift.io', + version: 'v1', + plural: 'machineconfigs', + name: MC_WITH_CONFIG_FILES, + }); + const fileEntry = mcResource?.spec?.config?.storage?.files?.[0]; + expect(fileEntry).toHaveProperty('contents'); + expect(fileEntry).toHaveProperty('mode'); + expect(fileEntry).toHaveProperty('overwrite'); + const { + contents: { source }, + mode, + overwrite, + } = fileEntry; + await mcPage.checkConfigFileDetails(mode, overwrite, source); + }); + + test(`${MC_WITHOUT_CONFIG_FILES} does not display configuration files`, async ({ page }) => { + const detailsPage = new DetailsPage(page); + const mcPage = new MachineConfigPage(page); + + await page.goto(`${MC_DETAILS_PAGE_URL}${MC_WITHOUT_CONFIG_FILES}`); + await detailsPage.isLoaded(); + await detailsPage.titleShouldContain(MC_WITHOUT_CONFIG_FILES); + + await expect(mcPage.sectionHeading(MC_SECTION_HEADING)).toBeHidden(); + await expect(mcPage.configFilePath).toBeHidden(); + await expect(mcPage.copyToClipboard).toHaveCount(0); + }); +}); diff --git a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts new file mode 100644 index 00000000000..d33af6dd079 --- /dev/null +++ b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts @@ -0,0 +1,104 @@ +import { test, expect } from '../../../fixtures'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; +import { YamlEditorPage } from '../../../pages/yaml-editor-page'; + +const CRONJOB_NAME = 'cronjob1'; + +test.describe.serial('Start a Job from a CronJob', () => { + const testNs = `e2e-cronjob-${Date.now()}`; + + const cronJobPayload = `apiVersion: batch/v1 +kind: CronJob +metadata: + name: ${CRONJOB_NAME} + namespace: ${testNs} +spec: + schedule: '@daily' + jobTemplate: + spec: + template: + spec: + containers: + - name: hello + image: busybox + args: + - /bin/sh + - '-c' + - date; echo Hello from the Openshift cluster + restartPolicy: OnFailure`; + + test.beforeAll(async ({ k8sClient }) => { + await k8sClient.createNamespace(testNs); + await k8sClient.waitForNamespaceReady(testNs); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(testNs); + }); + + test('verify "Start Job" on the CronJob details page', async ({ page }) => { + const yamlEditor = new YamlEditorPage(page); + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/import`); + await yamlEditor.isImportLoaded(); + await yamlEditor.setEditorContent(cronJobPayload); + await yamlEditor.clickSaveCreateButton(); + await detailsPage.sectionHeaderShouldExist('CronJob details'); + + await detailsPage.clickPageActionFromDropdown('Start Job'); + await detailsPage.isLoaded(); + await detailsPage.sectionHeaderShouldExist('Job details'); + await detailsPage.titleShouldContain(CRONJOB_NAME); + }); + + test('verify "Start Job" on the CronJob list page', async ({ page }) => { + const listPage = new ListPage(page); + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/cronjobs`); + await listPage.dvRowsShouldExist(CRONJOB_NAME); + + // LazyActionMenu loads actions lazily and WebSocket updates can re-render the + // table (resetting menu state), so retry opening the kebab if the action disappears. + const row = page.locator('table tbody tr').filter({ + has: page.getByRole('link', { name: CRONJOB_NAME, exact: true }), + }); + const kebab = row.locator('[data-test-id="kebab-button"]'); + const action = page.locator('[data-test-action="Start Job"]:not([disabled])'); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + await kebab.hover(); + await kebab.click(); + try { + await action.waitFor({ state: 'visible', timeout: 5_000 }); + break; + } catch { + // Menu may have closed due to table re-render; retry + } + } + await action.click(); + + await detailsPage.isLoaded(); + await detailsPage.sectionHeaderShouldExist('Job details'); + await detailsPage.titleShouldContain(CRONJOB_NAME); + }); + + test('verify the number of Jobs in CronJob > Jobs tab list page', async ({ page }) => { + const listPage = new ListPage(page); + + await page.goto(`/k8s/ns/${testNs}/cronjobs`); + await listPage.dvRowsShouldExist(CRONJOB_NAME); + await page.goto(`/k8s/ns/${testNs}/cronjobs/${CRONJOB_NAME}/jobs`); + await listPage.dvRowsCountShouldBe(2); + }); + + test('verify the number of events in CronJob > Events tab list page', async ({ page }) => { + const detailsPage = new DetailsPage(page); + + await page.goto(`/k8s/ns/${testNs}/cronjobs/${CRONJOB_NAME}/events`); + await expect(detailsPage.eventTotals).toHaveText('Showing 2 events', { timeout: 10_000 }); + }); +}); From 0d06cfbca5882b7d2bc2e219a87dda83e6d09519 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Fri, 15 May 2026 10:16:47 +0200 Subject: [PATCH 02/10] CONSOLE-5233: remove migrated cypress tests --- ...ission-webhook-warning-notifications.cy.ts | 167 ------------------ .../tests/app/auth-multiuser-login.cy.ts | 89 ---------- .../tests/app/debug-pod.cy.ts | 113 ------------ .../tests/app/deployments.cy.ts | 55 ------ .../tests/app/machine-config.cy.ts | 68 ------- .../tests/app/start-job-from-cronjob.cy.ts | 76 -------- 6 files changed, 568 deletions(-) delete mode 100644 frontend/packages/integration-tests/tests/app/admission-webhook-warning-notifications.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/app/auth-multiuser-login.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/app/debug-pod.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/app/deployments.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/app/machine-config.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/app/start-job-from-cronjob.cy.ts diff --git a/frontend/packages/integration-tests/tests/app/admission-webhook-warning-notifications.cy.ts b/frontend/packages/integration-tests/tests/app/admission-webhook-warning-notifications.cy.ts deleted file mode 100644 index a418150e6dd..00000000000 --- a/frontend/packages/integration-tests/tests/app/admission-webhook-warning-notifications.cy.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { checkErrors, testName } from '../../support'; -import { detailsPage } from '../../views/details-page'; -import * as yamlEditor from '../../views/yaml-editor'; - -const POD_NAME = 'pod1'; -const DEPLOY_NAME = 'deploy1'; -const CONTAINER_NAME = 'container1'; -const WARNING_FOO = '299 - "[pod-must-have-label-foo] you must provide labels: {"foo"}"'; -const WARNING_BAR = '299 - "[deployment-must-have-label-bar] you must provide labels: {"bar"}"'; -const WAIT_OPTION = { timeout: 5000 }; -const POD_CREATED_ALIAS = 'podCreated'; -const BULK_RESOURCES_CREATED_ALIAS = 'bulkResourcesCreated'; -const LEARN_MORE_ID = 'admission-webhook-warning-learn-more'; -const WARNING_ID = 'admission-webhook-warning'; -const resources = [ - { kind: 'Pod', name: `${POD_NAME}-b`, warning: WARNING_FOO, resource: 'pods', path: 'api' }, - { - kind: 'Deployment', - name: DEPLOY_NAME, - warning: WARNING_BAR, - resource: 'deployments', - path: 'apis/apps', - }, -]; -const pod1ReqObj = `apiVersion: v1 -kind: Pod -metadata: - name: ${POD_NAME}-a - labels: - app: httpd - namespace: ${testName} -spec: - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - containers: - - name: ${CONTAINER_NAME} - image: 'image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest' - ports: - - containerPort: 8080 - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL`; - -const bulkResourcesReqObj = `apiVersion: v1 -kind: Pod -metadata: - name: ${POD_NAME}-b - labels: - app: httpd - namespace: ${testName} -spec: - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - containers: - - name: ${CONTAINER_NAME} - image: 'image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest' - ports: - - containerPort: 8080 - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ${DEPLOY_NAME} - annotations: {} - namespace: ${testName} -spec: - selector: - matchLabels: - app: deploy1 - replicas: 3 - template: - metadata: - labels: - app: deploy1 - spec: - containers: - - name: ${CONTAINER_NAME} - image: >- - image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest - ports: - - containerPort: 8080 - protocol: TCP - env: - - name: app - value: frontennd - imagePullSecrets: [] - strategy: - type: RollingUpdate - rollingUpdate: - maxSurge: 25% - maxUnavailable: 25% - paused: false -`; - -describe('Admission Webhook warning notification', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.visit('/'); - cy.deleteProjectWithCLI(testName); - }); - - it('Create a pod and display Admission Webhook warning notification', () => { - cy.visit(`/k8s/ns/${testName}/import`); - yamlEditor.isImportLoaded(); - yamlEditor.setEditorContent(pod1ReqObj).then(() => { - cy.intercept('POST', `/api/kubernetes/api/v1/namespaces/${testName}/pods`, (req) => { - req.continue((res) => { - res.headers = { - Warning: WARNING_FOO, - }; - }); - }).as(POD_CREATED_ALIAS); - yamlEditor.clickSaveCreateButton(); - cy.wait(`@${POD_CREATED_ALIAS}`, WAIT_OPTION); - detailsPage.sectionHeaderShouldExist('Pod details'); - cy.byTestID(WARNING_ID).contains('Admission Webhook Warning'); - cy.byTestID(WARNING_ID).contains(`Pod ${POD_NAME}-a violates policy ${WARNING_FOO}`); - cy.byTestID(LEARN_MORE_ID).contains('Learn more').click(); - }); - }); - - it('Create bulk resources and display Admission Webhook warning notifications', () => { - cy.visit(`/k8s/ns/${testName}/import`); - yamlEditor.isImportLoaded(); - yamlEditor.setEditorContent(bulkResourcesReqObj).then(() => { - for (const resource of resources) { - cy.intercept( - 'POST', - `/api/kubernetes/${resource.path}/v1/namespaces/${testName}/${resource.resource}`, - (req) => { - req.continue((res) => { - res.headers = { - Warning: resource.warning, - }; - }); - }, - ).as(BULK_RESOURCES_CREATED_ALIAS); - } - yamlEditor.clickSaveCreateButton(); - cy.wait(`@${BULK_RESOURCES_CREATED_ALIAS}`, WAIT_OPTION); - cy.byTestID('resources-successfully-created').contains('Resources successfully created'); - cy.byTestID(WARNING_ID).contains('Admission Webhook Warning'); - cy.byTestID(WARNING_ID).contains(`Pod ${POD_NAME}-b violates policy ${WARNING_FOO}`); - cy.byTestID(WARNING_ID).contains(`Deployment ${DEPLOY_NAME} violates policy ${WARNING_BAR}`); - cy.byTestID(LEARN_MORE_ID).contains('Learn more').click(); - }); - }); -}); diff --git a/frontend/packages/integration-tests/tests/app/auth-multiuser-login.cy.ts b/frontend/packages/integration-tests/tests/app/auth-multiuser-login.cy.ts deleted file mode 100644 index c373f4d85d1..00000000000 --- a/frontend/packages/integration-tests/tests/app/auth-multiuser-login.cy.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { checkErrors } from '../../support'; -import { masthead } from '../../views/masthead'; -import { nav } from '../../views/nav'; - -describe('Auth test', () => { - const KUBEADMIN_IDP = 'kube:admin'; - const KUBEADMIN_USERNAME = 'kubeadmin'; - - beforeEach(() => { - // clear any existing sessions - Cypress.session.clearAllSavedSessions(); - }); - - afterEach(() => { - checkErrors(); - Cypress.session.clearAllSavedSessions(); - }); - - it(`logs in as 'test' user via htpasswd identity provider`, function () { - cy.env(['BRIDGE_KUBEADMIN_PASSWORD', 'BRIDGE_HTPASSWD_PASSWORD']).then( - ({ BRIDGE_KUBEADMIN_PASSWORD, BRIDGE_HTPASSWD_PASSWORD }) => { - if (!BRIDGE_KUBEADMIN_PASSWORD) { - this.skip(); - return; - } - const idp = Cypress.expose('BRIDGE_HTPASSWD_IDP') || 'test'; - const username = Cypress.expose('BRIDGE_HTPASSWD_USERNAME') || 'test'; - const passwd = BRIDGE_HTPASSWD_PASSWORD || 'test'; - cy.login(idp, username, passwd); - cy.url().should('include', Cypress.config('baseUrl')); - - // test Developer perspective is default for test user - // Below line to be uncommented after pr https://github.com/openshift/console-operator/pull/954 is merged - masthead.username.shouldHaveText(username); - - cy.log('switches from dev to admin perspective'); - // nav.sidenav.switcher.shouldHaveText('Developer'); - nav.sidenav.switcher.changePerspectiveTo('Core platform'); - nav.sidenav.switcher.shouldHaveText('Core platform'); - - cy.log('does not show admin nav items in Administration to test user'); - // eslint-disable-next-line cypress/no-unnecessary-waiting - cy.wait(10000); // wait for feature FLAGS to load - nav.sidenav.shouldNotHaveNavSection(['Administration', 'Cluster Status']); - nav.sidenav.shouldNotHaveNavSection(['Administration', 'Cluster Settings']); - nav.sidenav.shouldNotHaveNavSection(['Administration', 'Namespaces']); - nav.sidenav.shouldNotHaveNavSection(['Administration', 'Custom Resource Definitions']); - - cy.log('does not show admin nav items in Ecosystem to test user'); - nav.sidenav.shouldNotHaveNavSection(['Ecosystem', 'Software Catalog']); - - cy.log('does not show admin nav items in Storage to test user'); - nav.sidenav.shouldNotHaveNavSection(['Storage', 'Persistent Volumes']); - - cy.log('does not show Compute or Monitoring to test user'); - nav.sidenav.shouldNotHaveNavSection(['Compute']); - nav.sidenav.shouldNotHaveNavSection(['Monitoring']); - }, - ); - }); - - it(`log in as 'kubeadmin' user`, () => { - cy.env(['BRIDGE_KUBEADMIN_PASSWORD']).then(({ BRIDGE_KUBEADMIN_PASSWORD }) => { - cy.login(KUBEADMIN_IDP, KUBEADMIN_USERNAME, BRIDGE_KUBEADMIN_PASSWORD); - cy.byTestID('loading-indicator').should('not.exist'); - cy.url().should('include', Cypress.config('baseUrl')); - masthead.username.shouldHaveText(KUBEADMIN_IDP); - cy.byTestID('global-notifications').contains( - 'You are logged in as a temporary administrative user. Update the cluster OAuth configuration to allow others to log in.', - ); - - // test Administrator perspective is default for kubeadmin - nav.sidenav.switcher.shouldHaveText('Core platform'); - // test guided tour is displayed first time switching to 'Developer' perspective - // skip if running localhost - if (!Cypress.config('baseUrl').includes('localhost')) { - // nav.sidenav.switcher.changePerspectiveTo('Developer'); - // nav.sidenav.switcher.shouldHaveText('Developer'); - nav.sidenav.switcher.changePerspectiveTo('Core platform'); - nav.sidenav.switcher.shouldHaveText('Core platform'); - } - cy.log('verify sidenav menus and Administration menu access for cluster admin user'); - nav.sidenav.shouldHaveNavSection(['Compute']); - nav.sidenav.shouldHaveNavSection(['Operators']); - nav.sidenav.clickNavLink(['Administration', 'Cluster Settings']); - cy.byLegacyTestID('cluster-settings-page-heading').should('be.visible'); - }); - }); -}); diff --git a/frontend/packages/integration-tests/tests/app/debug-pod.cy.ts b/frontend/packages/integration-tests/tests/app/debug-pod.cy.ts deleted file mode 100644 index e5714a694f3..00000000000 --- a/frontend/packages/integration-tests/tests/app/debug-pod.cy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { checkErrors, testName } from '../../support'; -import { detailsPage } from '../../views/details-page'; -import { listPage } from '../../views/list-page'; -import * as yamlEditor from '../../views/yaml-editor'; - -const POD_NAME = `pod1`; -const CONTAINER_NAME = `container1`; -const XTERM_CLASS = `[class="xterm-viewport"]`; -const podToDebug = `apiVersion: v1 -kind: Pod -metadata: - name: ${POD_NAME} -spec: - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - containers: - - name: ${CONTAINER_NAME} - image: quay.io/fedora/fedora - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL - restartPolicy: Always`; - -describe('Debug pod', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.visit('/'); - cy.deleteProjectWithCLI(testName); - }); - - it('Create pod that has crashbackloop error', () => { - cy.visit(`/k8s/ns/${testName}/import`); - yamlEditor.isImportLoaded(); - yamlEditor.setEditorContent(podToDebug).then(() => { - yamlEditor.clickSaveCreateButton(); - cy.byTestID('yaml-error').should('not.exist'); - detailsPage.sectionHeaderShouldExist('Pod details'); - }); - }); - - it('Opens debug terminal page from Logs subsection', () => { - cy.visit(`/k8s/ns/${testName}/pods`); - listPage.dvRows.shouldExist(POD_NAME); - cy.visit(`/k8s/ns/${testName}/pods/${POD_NAME}`); - detailsPage.isLoaded(); - detailsPage.selectTab('Logs'); - detailsPage.isLoaded(); - cy.byTestID('debug-container-link').click(); - listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); - cy.get(XTERM_CLASS).should('exist'); - cy.get('[data-test-id="breadcrumb-link-0"]').click(); - listPage.dvRows.shouldExist(POD_NAME); - }); - - it('Opens debug terminal page from Pod Details - Status tool tip', () => { - cy.visit(`/k8s/ns/${testName}/pods/${POD_NAME}`); - detailsPage.isLoaded(); - cy.byTestID('popover-status-button', { timeout: 60000 }).click(); - // Regression test for OCPBUGS-83813: Wait for popover content to be stable before clicking - // https://issues.redhat.com/browse/OCPBUGS-83813 - cy.byTestID(`popup-debug-container-link-${CONTAINER_NAME}`).should('be.visible'); - cy.byTestID(`popup-debug-container-link-${CONTAINER_NAME}`).click(); - listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); - cy.get(XTERM_CLASS).should('exist'); - cy.get('[data-test-id="breadcrumb-link-0"]').click(); - listPage.dvRows.shouldExist(POD_NAME); - }); - - it('Opens debug terminal page from Pods Page - Status tool tip', () => { - cy.visit(`/k8s/ns/${testName}/pods`); - listPage.dvRows.shouldExist(POD_NAME); - listPage.dvRows.clickStatusButton(POD_NAME); - // Regression test for OCPBUGS-83813: Wait for popover content to be stable before clicking - // https://issues.redhat.com/browse/OCPBUGS-83813 - cy.byTestID(`popup-debug-container-link-${CONTAINER_NAME}`).should('be.visible').click(); - listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); - cy.get(XTERM_CLASS).should('exist'); - - cy.log('debug pod should not copy main pod network info'); - cy.exec( - `oc get pods -n ${testName} -o jsonpath='{.items[0].status.podIP}{"#"}{.items[1].status.podIP}'`, - ).then((result) => { - const [ipAddressOne, ipAddressTwo] = result.stdout.split('#'); - expect(`${ipAddressOne}`).to.not.equal(`${ipAddressTwo}`); - }); - cy.get('[data-test-id="breadcrumb-link-0"]').click(); - listPage.dvRows.shouldExist(POD_NAME); - }); - - it('Debug pod should be terminated after leaving debug container page', () => { - cy.visit(`/k8s/ns/${testName}/pods`); - listPage.dvRows.shouldExist(POD_NAME); - listPage.dvFilter.by('Status', 'Running'); - cy.exec( - `oc get pods -n ${testName} -o jsonpath='{.items[0].metadata.name}{"#"}{.items[1].metadata.name}'`, - ).then((result) => { - const debugPodName = result.stdout.split('#')[1]; - listPage.dvRows.shouldNotExist(debugPodName); - }); - }); -}); diff --git a/frontend/packages/integration-tests/tests/app/deployments.cy.ts b/frontend/packages/integration-tests/tests/app/deployments.cy.ts deleted file mode 100644 index 1f5f56f00b8..00000000000 --- a/frontend/packages/integration-tests/tests/app/deployments.cy.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { checkErrors, testName } from '../../support'; -import { detailsPage } from '../../views/details-page'; -import { listPage } from '../../views/list-page'; -import { modal } from '../../views/modal'; - -describe('Deployment resource details page', () => { - let WORKLOAD_NAME; - let CREATE_DEPLOYMENT; - let CREATE_HPA; - - before(() => { - cy.login(); - cy.initAdmin(); - cy.createProjectWithCLI(testName); - - WORKLOAD_NAME = `deployment-${testName}`; - CREATE_DEPLOYMENT = `oc create deployment ${WORKLOAD_NAME} --image=httpd --replicas=0`; - CREATE_HPA = `oc autoscale deployment ${WORKLOAD_NAME} --min=1 --max=10`; - - // Create a deployment named foo with 0 replicas using the cli - cy.exec(CREATE_DEPLOYMENT, { - failOnNonZeroExit: false, - }); - // Create an HorizontalPodAutoscaler using the cli that autoscales the deployment foo - cy.exec(CREATE_HPA, { failOnNonZeroExit: false }); - cy.visit(`/k8s/ns/${testName}/deployments`); - }); - - beforeEach(() => { - cy.visitAndWait(`/k8s/ns/${testName}/deployments/${WORKLOAD_NAME}`); - detailsPage.isLoaded(); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.visit(`/k8s/ns/${testName}/deployments`); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.byName(WORKLOAD_NAME); - listPage.dvRows.clickKebabAction(WORKLOAD_NAME, 'Delete Deployment'); - modal.shouldBeOpened(); - modal.submit(); - modal.shouldBeClosed(); - cy.deleteProjectWithCLI(testName); - }); - - it('Enable deployment autoscale button should exist', () => { - cy.byTestID('enable-autoscale').should('exist').click(); - }); - it('Enable deployment autoscale button should not exist', () => { - cy.byTestID('enable-autoscale').should('not.exist'); - }); -}); diff --git a/frontend/packages/integration-tests/tests/app/machine-config.cy.ts b/frontend/packages/integration-tests/tests/app/machine-config.cy.ts deleted file mode 100644 index 6f8bd0bc50f..00000000000 --- a/frontend/packages/integration-tests/tests/app/machine-config.cy.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { checkErrors } from '../../support'; -import { detailsPage } from '../../views/details-page'; - -const MC_WITH_CONFIG_FILES = '00-master'; -const MC_WITHOUT_CONFIG_FILES = '99-master-ssh'; -const MC_DETAILS_PAGE_URL = '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfig/'; -const MC_SECTION_HEADING = 'Configuration files'; -const MC_CONFIG_FILE_PATH_ID = 'config-file-path-0'; -const MC_C2C = '.co-copy-to-clipboard__text'; -const checkMachineConfigDetails = (mode, overwrite, content) => { - cy.byTestID(MC_CONFIG_FILE_PATH_ID).scrollIntoView(); - cy.get('button[aria-label="Info"]').first().click(); - cy.contains(mode).should('exist'); - cy.contains(overwrite.toString()).should('exist'); - cy.get('code') - .first() - .should(($code) => { - const text = $code.text(); - expect(text).to.include( - decodeURIComponent(content) - .replace(/^(data:,)/, '') - .slice(0, 30), - ); - }); -}; - -describe('MachineConfig resource details page', () => { - before(() => { - cy.login(); - cy.initAdmin(); - }); - - afterEach(() => { - checkErrors(); - }); - - it(`${MC_WITH_CONFIG_FILES} displays configuration files`, () => { - cy.visit(`${MC_DETAILS_PAGE_URL}${MC_WITH_CONFIG_FILES}`); - detailsPage.titleShouldContain(`${MC_WITH_CONFIG_FILES}`); - detailsPage.isLoaded(); - cy.byTestSectionHeading(MC_SECTION_HEADING).should('exist'); - cy.byTestID(MC_CONFIG_FILE_PATH_ID).should('exist'); - cy.get(MC_C2C).should('exist'); - cy.exec(`oc get mc ${MC_WITH_CONFIG_FILES} -o jsonpath='{.spec.config.storage.files[0]}'`).then( - (result) => { - const mcContents = JSON.parse(result.stdout); - expect(mcContents).to.have.property('contents'); - expect(mcContents).to.have.property('mode'); - expect(mcContents).to.have.property('overwrite'); - const { - contents: { source }, - mode, - overwrite, - } = mcContents; - checkMachineConfigDetails(mode, overwrite, source); - }, - ); - }); - - it(`${MC_WITHOUT_CONFIG_FILES} does not display configuration files`, () => { - cy.visit(`${MC_DETAILS_PAGE_URL}${MC_WITHOUT_CONFIG_FILES}`); - detailsPage.titleShouldContain(`${MC_WITHOUT_CONFIG_FILES}`); - detailsPage.isLoaded(); - cy.byTestSectionHeading(MC_SECTION_HEADING).should('not.exist'); - cy.byTestID(MC_CONFIG_FILE_PATH_ID).should('not.exist'); - cy.get(MC_C2C).should('not.exist'); - }); -}); diff --git a/frontend/packages/integration-tests/tests/app/start-job-from-cronjob.cy.ts b/frontend/packages/integration-tests/tests/app/start-job-from-cronjob.cy.ts deleted file mode 100644 index 08efc0ae1c4..00000000000 --- a/frontend/packages/integration-tests/tests/app/start-job-from-cronjob.cy.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { checkErrors, testName } from '../../support'; -import { detailsPage } from '../../views/details-page'; -import { listPage } from '../../views/list-page'; -import * as yamlEditor from '../../views/yaml-editor'; - -const CRONJOB_NAME = 'cronjob1'; - -const cronJobReqPayload = `apiVersion: batch/v1 -kind: CronJob -metadata: - name: ${CRONJOB_NAME} - namespace: ${testName} -spec: - schedule: '@daily' - jobTemplate: - spec: - template: - spec: - containers: - - name: hello - image: busybox - args: - - /bin/sh - - '-c' - - date; echo Hello from the Openshift cluster - restartPolicy: OnFailure`; - -describe('Start a Job from a CronJob', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.visit('/'); - cy.deleteProjectWithCLI(testName); - }); - - it('verify "Start Job" on the CronJob details page', () => { - cy.visit(`/k8s/ns/${testName}/import`); - yamlEditor.isImportLoaded(); - yamlEditor.setEditorContent(cronJobReqPayload).then(() => { - yamlEditor.clickSaveCreateButton(); - detailsPage.sectionHeaderShouldExist('CronJob details'); - }); - detailsPage.clickPageActionFromDropdown('Start Job'); - detailsPage.isLoaded(); - detailsPage.sectionHeaderShouldExist('Job details'); - detailsPage.titleShouldContain(`${CRONJOB_NAME}`); - }); - - it('verify "Start Job" on the CronJob list page', () => { - cy.visit(`/k8s/ns/${testName}/cronjobs`); - listPage.dvRows.shouldBeLoaded(); - listPage.dvRows.clickKebabAction(CRONJOB_NAME, 'Start Job'); - detailsPage.isLoaded(); - detailsPage.sectionHeaderShouldExist('Job details'); - detailsPage.titleShouldContain(`${CRONJOB_NAME}`); - }); - - it('verify the number of Jobs in CronJob > Jobs tab list page', () => { - cy.visit(`/k8s/ns/${testName}/cronjobs`); - listPage.dvRows.shouldBeLoaded(); - cy.visit(`/k8s/ns/${testName}/cronjobs/${CRONJOB_NAME}/jobs`); - listPage.dvRows.countShouldBe(2); - }); - - it('verify the number of events in CronJob > Events tab list page', () => { - cy.visit(`/k8s/ns/${testName}/cronjobs/${CRONJOB_NAME}/events`); - cy.byTestID('event-totals').should('have.text', 'Showing 2 events'); - }); -}); From 76346aa46a79455b54f366823f2d7b45deb7f157 Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Mon, 1 Jun 2026 15:22:36 +0200 Subject: [PATCH 03/10] CONSOLE-5233: Address PR review comments for Playwright e2e tests - Align rowsShouldExist/rowsShouldNotExist to use consistent data-test-id selector - Use safe attribute selector [id=""] instead of CSS ID selector # - Rename misleading clickCreateYAMLButton getter to createYAMLButton - Handle single-level paths in clickNavLink to prevent no-op - Add null check for Monaco editor model in setEditorContent - Remove environment-specific conditional in perspective switching test - Use deterministic pod selection by name for IP isolation check - Use expect.poll for robust debug pod cleanup verification - Define MachineConfig interface to replace any type - Add fail-fast assertion when kebab retry loop is exhausted Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/list-page.ts | 10 ++++---- frontend/e2e/pages/nav-page.ts | 11 ++++++--- frontend/e2e/pages/yaml-editor-page.ts | 6 ++++- .../console/app/auth-multiuser-login.spec.ts | 7 +----- .../e2e/tests/console/app/debug-pod.spec.ts | 22 +++++++++++------- .../tests/console/app/machine-config.spec.ts | 23 ++++++++++++++++--- .../app/start-job-from-cronjob.spec.ts | 3 +++ 7 files changed, 56 insertions(+), 26 deletions(-) diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index 5042d1c27ad..7b6731a3f69 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -13,9 +13,9 @@ export class ListPage extends BasePage { // --- Resource row helpers (older VirtualizedTable) --- async rowsShouldExist(resourceName: string): Promise { - await expect( - this.page.locator('[data-test-rows="resource-row"]').filter({ hasText: resourceName }), - ).toBeVisible({ timeout: 60_000 }); + await expect(this.page.locator(`[data-test-id="${resourceName}"]`)).toBeVisible({ + timeout: 60_000, + }); } async rowsShouldNotExist(resourceName: string): Promise { @@ -57,7 +57,7 @@ export class ListPage extends BasePage { ); if (await filterDropdownToggle.isVisible().catch(() => false)) { await this.robustClick(filterDropdownToggle); - await this.page.locator(`#${status}`).click(); + await this.page.locator(`[id="${status}"]`).click(); await this.robustClick(filterDropdownToggle); } } @@ -155,7 +155,7 @@ export class ListPage extends BasePage { await this.robustClick(this.page.locator('[data-ouia-component-id="DataViewCheckboxFilter"]')); } - get clickCreateYAMLButton(): Locator { + get createYAMLButton(): Locator { return this.page.getByTestId('item-create'); } } diff --git a/frontend/e2e/pages/nav-page.ts b/frontend/e2e/pages/nav-page.ts index baaa97061c5..be41940ce0e 100644 --- a/frontend/e2e/pages/nav-page.ts +++ b/frontend/e2e/pages/nav-page.ts @@ -66,13 +66,18 @@ export class NavPage extends BasePage { } async clickNavLink(path: string[]): Promise { + if (!path.length) { + throw new Error('clickNavLink requires at least one path element'); + } const navItem = this.sidebar.getByText(path[0]); + if (path.length === 1) { + await this.robustClick(navItem); + return; + } const expanded = await navItem.getAttribute('aria-expanded'); if (expanded !== 'true') { await this.robustClick(navItem); } - if (path.length === 2) { - await this.robustClick(this.sidebar.getByText(path[1])); - } + await this.robustClick(this.sidebar.getByText(path[1])); } } diff --git a/frontend/e2e/pages/yaml-editor-page.ts b/frontend/e2e/pages/yaml-editor-page.ts index 166fd1ae68a..6bed9c38686 100644 --- a/frontend/e2e/pages/yaml-editor-page.ts +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -10,7 +10,11 @@ export class YamlEditorPage extends BasePage { async setEditorContent(text: string): Promise { await this.page.evaluate((content) => { - const models = (window as any).monaco.editor.getModels(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const models = (window as any).monaco?.editor?.getModels?.() ?? []; + if (!models[0]) { + throw new Error('Monaco editor model not available'); + } models[0].setValue(content); }, text); } diff --git a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts index 86def17f0d9..9f1d17e390d 100644 --- a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts +++ b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts @@ -85,13 +85,8 @@ test.describe('Auth test', () => { }); await test.step('Verify Core platform perspective', async () => { + await nav.changePerspectiveTo('Core platform'); await nav.perspectiveSwitcherShouldHaveText('Core platform'); - - const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; - if (!baseURL.includes('localhost')) { - await nav.changePerspectiveTo('Core platform'); - await nav.perspectiveSwitcherShouldHaveText('Core platform'); - } }); await test.step('Verify kubeadmin has admin access', async () => { diff --git a/frontend/e2e/tests/console/app/debug-pod.spec.ts b/frontend/e2e/tests/console/app/debug-pod.spec.ts index e0aa57faedf..93629f2f467 100644 --- a/frontend/e2e/tests/console/app/debug-pod.spec.ts +++ b/frontend/e2e/tests/console/app/debug-pod.spec.ts @@ -163,9 +163,11 @@ test.describe.serial('Debug pod', () => { // Debug pod should not copy main pod network info const pods = await k8sClient.getPods(testNs); expect(pods.length).toBeGreaterThanOrEqual(2); - const ipAddressOne = pods[0]?.status?.podIP; - const ipAddressTwo = pods[1]?.status?.podIP; - expect(ipAddressOne).not.toEqual(ipAddressTwo); + const mainPod = pods.find((p: any) => p.metadata?.name === POD_NAME); + const debugPod = pods.find((p: any) => p.metadata?.name !== POD_NAME); + expect(mainPod?.status?.podIP).toBeTruthy(); + expect(debugPod?.status?.podIP).toBeTruthy(); + expect(mainPod?.status?.podIP).not.toEqual(debugPod?.status?.podIP); await detailsPage.clickBreadcrumb(); await listPage.dvRowsShouldExist(POD_NAME); @@ -181,10 +183,14 @@ test.describe.serial('Debug pod', () => { await listPage.dvRowsShouldExist(POD_NAME); await listPage.filterByStatus('Running'); - const pods = await k8sClient.getPods(testNs); - const debugPod = pods.find((p) => p.metadata?.name !== POD_NAME); - if (debugPod?.metadata?.name) { - await listPage.dvRowsShouldNotExist(debugPod.metadata.name); - } + await expect + .poll( + async () => { + const pods = await k8sClient.getPods(testNs); + return pods.filter((p: any) => p.metadata?.name !== POD_NAME).length; + }, + { timeout: 60_000 }, + ) + .toBe(0); }); }); diff --git a/frontend/e2e/tests/console/app/machine-config.spec.ts b/frontend/e2e/tests/console/app/machine-config.spec.ts index cc9a83b6ef0..a6134301724 100644 --- a/frontend/e2e/tests/console/app/machine-config.spec.ts +++ b/frontend/e2e/tests/console/app/machine-config.spec.ts @@ -2,6 +2,23 @@ import { test, expect } from '../../../fixtures'; import { DetailsPage } from '../../../pages/details-page'; import { MachineConfigPage } from '../../../pages/machine-config-page'; +interface MachineConfigFile { + path: string; + contents: { source: string }; + mode: number; + overwrite: boolean; +} + +interface MachineConfig { + spec?: { + config?: { + storage?: { + files?: MachineConfigFile[]; + }; + }; + }; +} + const MC_WITH_CONFIG_FILES = '00-master'; const MC_WITHOUT_CONFIG_FILES = '99-master-ssh'; const MC_DETAILS_PAGE_URL = '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfig/'; @@ -20,12 +37,12 @@ test.describe('MachineConfig resource details page', () => { await expect(mcPage.configFilePath).toBeVisible(); await expect(mcPage.copyToClipboard.first()).toBeVisible(); - const mcResource: any = await k8sClient.customObjectsApi.getClusterCustomObject({ + const mcResource = (await k8sClient.customObjectsApi.getClusterCustomObject({ group: 'machineconfiguration.openshift.io', version: 'v1', plural: 'machineconfigs', name: MC_WITH_CONFIG_FILES, - }); + })) as MachineConfig; const fileEntry = mcResource?.spec?.config?.storage?.files?.[0]; expect(fileEntry).toHaveProperty('contents'); expect(fileEntry).toHaveProperty('mode'); @@ -34,7 +51,7 @@ test.describe('MachineConfig resource details page', () => { contents: { source }, mode, overwrite, - } = fileEntry; + } = fileEntry!; await mcPage.checkConfigFileDetails(mode, overwrite, source); }); diff --git a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts index d33af6dd079..24ef747c921 100644 --- a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts +++ b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts @@ -69,16 +69,19 @@ spec: const action = page.locator('[data-test-action="Start Job"]:not([disabled])'); const deadline = Date.now() + 30_000; + let found = false; while (Date.now() < deadline) { await kebab.hover(); await kebab.click(); try { await action.waitFor({ state: 'visible', timeout: 5_000 }); + found = true; break; } catch { // Menu may have closed due to table re-render; retry } } + expect(found, 'Kebab action "Start Job" was not visible after retries').toBeTruthy(); await action.click(); await detailsPage.isLoaded(); From e5b575833c6e5f8b3a3b3742c13d3c07e4e7520b Mon Sep 17 00:00:00 2001 From: Robert Luby Date: Mon, 1 Jun 2026 17:06:32 +0200 Subject: [PATCH 04/10] CONSOLE-5233: Fix "Model does not exist" test failures with auto-retry The console's API model discovery can race with page rendering in fresh browser contexts, causing "Model does not exist" errors. Add a reloadIfModelNotFound() helper to BasePage that detects the error and triggers a page reload (matching the "Try again" button behavior). Integrate the retry into DetailsPage.isLoaded() and ListPage.dvRowsShouldBeLoaded(), and add isLoaded()/dvRowsShouldBeLoaded() calls in cronjob tests that were missing page-ready checks. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/base-page.ts | 15 +++++++++++++++ frontend/e2e/pages/details-page.ts | 1 + frontend/e2e/pages/list-page.ts | 1 + .../console/app/start-job-from-cronjob.spec.ts | 2 ++ 4 files changed, 19 insertions(+) diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index 9a6f0cceeae..b5e94673a40 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -121,6 +121,21 @@ export default abstract class BasePage { throw new Error(`robustClick failed after ${retries} attempts: ${lastError?.message}`); } + protected async reloadIfModelNotFound(maxRetries = 3): Promise { + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await this.page + .getByText('Model does not exist') + .waitFor({ state: 'visible', timeout: 5_000 }); + } catch { + return; + } + await this.page.evaluate(() => window.location.reload()); + await this.page.waitForLoadState('load', { timeout: 30_000 }); + await this.waitForLoadingComplete(10_000); + } + } + async navigateToTab(locator: Locator, timeoutMs = 60_000): Promise { await this.robustClick(locator, { timeout: timeoutMs }); await this.waitForLoadingComplete(); diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index 32935d7ee26..b9997f0313f 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -38,6 +38,7 @@ export class DetailsPage extends BasePage { async isLoaded(): Promise { await expect(this.skeletonView).toBeHidden({ timeout: 30_000 }); + await this.reloadIfModelNotFound(); await this.resourceTitle.waitFor({ state: 'visible', timeout: 30_000 }); await expect(this.resourceTitle).not.toBeEmpty(); } diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index 7b6731a3f69..0c7dd9fdaa7 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -78,6 +78,7 @@ export class ListPage extends BasePage { } async dvRowsShouldBeLoaded(): Promise { + await this.reloadIfModelNotFound(); await expect(this.page.getByTestId('data-view-table')).toBeVisible({ timeout: 60_000 }); } diff --git a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts index 24ef747c921..4caddb46f8d 100644 --- a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts +++ b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts @@ -95,6 +95,7 @@ spec: await page.goto(`/k8s/ns/${testNs}/cronjobs`); await listPage.dvRowsShouldExist(CRONJOB_NAME); await page.goto(`/k8s/ns/${testNs}/cronjobs/${CRONJOB_NAME}/jobs`); + await listPage.dvRowsShouldBeLoaded(); await listPage.dvRowsCountShouldBe(2); }); @@ -102,6 +103,7 @@ spec: const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/cronjobs/${CRONJOB_NAME}/events`); + await detailsPage.isLoaded(); await expect(detailsPage.eventTotals).toHaveText('Showing 2 events', { timeout: 10_000 }); }); }); From 87b9881345622574b9b82d712fd32c0dea642f09 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 29 Jun 2026 14:02:04 -0400 Subject: [PATCH 05/10] CONSOLE-5233: Fix ESLint warnings and align with updated Playwright standards - Remove redundant .eslintrc.json (superseded by .eslintrc.cjs on main) - Replace waitFor() calls with expect assertions where applicable - Add eslint-disable comments for intentional waitFor probes (try/catch) - Add eslint-disable for expect-expect false positives (assertions in test.step blocks and page object methods) - Remove redundant waitFor before clickDebugContainerLink (robustClick already auto-waits) - Fix data-test-id selector to use getByTestId in login-page - Add { tag: ['@admin'] } to all test.describe blocks Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/.eslintrc.json | 5 ----- frontend/e2e/pages/base-page.ts | 1 + frontend/e2e/pages/login-page.ts | 16 ++++++++-------- frontend/e2e/pages/nav-page.ts | 2 +- ...mission-webhook-warning-notifications.spec.ts | 2 +- .../console/app/auth-multiuser-login.spec.ts | 3 ++- frontend/e2e/tests/console/app/debug-pod.spec.ts | 3 +-- .../e2e/tests/console/app/deployments.spec.ts | 2 +- .../e2e/tests/console/app/machine-config.spec.ts | 2 +- .../console/app/start-job-from-cronjob.spec.ts | 5 ++++- 10 files changed, 20 insertions(+), 21 deletions(-) delete mode 100644 frontend/e2e/.eslintrc.json diff --git a/frontend/e2e/.eslintrc.json b/frontend/e2e/.eslintrc.json deleted file mode 100644 index b956cbdd843..00000000000 --- a/frontend/e2e/.eslintrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "rules": { - "testing-library/prefer-screen-queries": "off" - } -} diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index b5e94673a40..b5caffcc172 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -124,6 +124,7 @@ export default abstract class BasePage { protected async reloadIfModelNotFound(maxRetries = 3): Promise { for (let attempt = 0; attempt < maxRetries; attempt++) { try { + // eslint-disable-next-line no-restricted-syntax await this.page .getByText('Model does not exist') .waitFor({ state: 'visible', timeout: 5_000 }); diff --git a/frontend/e2e/pages/login-page.ts b/frontend/e2e/pages/login-page.ts index 620eec50f83..c9a71b01421 100644 --- a/frontend/e2e/pages/login-page.ts +++ b/frontend/e2e/pages/login-page.ts @@ -1,7 +1,9 @@ +import { expect } from '@playwright/test'; + import BasePage from './base-page'; export class LoginPage extends BasePage { - private readonly loginButton = this.page.locator('[data-test-id="login"]'); + private readonly loginButton = this.page.getByTestId('login'); private readonly usernameInput = this.page.locator('#inputUsername'); private readonly passwordInput = this.page.locator('#inputPassword'); private readonly submitButton = this.page.locator('button[type="submit"]'); @@ -24,21 +26,19 @@ export class LoginPage extends BasePage { } const providerBtn = this.providerButton(provider); - await this.loginButton - .or(this.usernameInput) - .or(providerBtn) - .first() - .waitFor({ state: 'visible', timeout: 30_000 }); + await expect( + this.loginButton.or(this.usernameInput).or(providerBtn).first(), + ).toBeVisible({ timeout: 30_000 }); if ((await providerBtn.count()) > 0 && (await providerBtn.isVisible())) { await providerBtn.click(); - await this.usernameInput.waitFor({ state: 'visible', timeout: 30_000 }); + await expect(this.usernameInput).toBeVisible({ timeout: 30_000 }); } await this.usernameInput.fill(username); await this.passwordInput.fill(password); await this.submitButton.click(); - await this.userDropdownToggle.waitFor({ state: 'visible', timeout: 60_000 }); + await expect(this.userDropdownToggle).toBeVisible({ timeout: 60_000 }); return true; } } diff --git a/frontend/e2e/pages/nav-page.ts b/frontend/e2e/pages/nav-page.ts index be41940ce0e..1ccbe7e72c1 100644 --- a/frontend/e2e/pages/nav-page.ts +++ b/frontend/e2e/pages/nav-page.ts @@ -33,7 +33,7 @@ export class NavPage extends BasePage { await this.page.waitForLoadState('domcontentloaded'); const toggle = this.perspectiveSwitcherToggle; await toggle.scrollIntoViewIfNeeded(); - await toggle.waitFor({ state: 'visible' }); + await expect(toggle).toBeVisible(); const isSinglePerspective = (await toggle.getAttribute('id')) === 'core-platform-perspective'; if (isSinglePerspective) { diff --git a/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts b/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts index 188238e732b..df5c562299b 100644 --- a/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts +++ b/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts @@ -10,7 +10,7 @@ const WARNING_BAR = '299 - "[deployment-must-have-label-bar] you must provide la const LEARN_MORE_ID = 'admission-webhook-warning-learn-more'; const WARNING_ID = 'admission-webhook-warning'; -test.describe('Admission Webhook warning notification', () => { +test.describe('Admission Webhook warning notification', { tag: ['@admin'] }, () => { const testNs = `e2e-admission-${Date.now()}`; const pod1ReqObj = `apiVersion: v1 diff --git a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts index 9f1d17e390d..45bddfe40f9 100644 --- a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts +++ b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts @@ -6,9 +6,10 @@ import { NavPage } from '../../../pages/nav-page'; const KUBEADMIN_IDP = 'kube:admin'; const KUBEADMIN_USERNAME = 'kubeadmin'; -test.describe('Auth test', () => { +test.describe('Auth test', { tag: ['@admin'] }, () => { test.use({ storageState: { cookies: [], origins: [] } }); + // eslint-disable-next-line playwright/expect-expect test("logs in as 'test' user via htpasswd identity provider", async ({ page }) => { const kubeadminPassword = process.env.BRIDGE_KUBEADMIN_PASSWORD; const htpasswdPassword = process.env.BRIDGE_HTPASSWD_PASSWORD; diff --git a/frontend/e2e/tests/console/app/debug-pod.spec.ts b/frontend/e2e/tests/console/app/debug-pod.spec.ts index 93629f2f467..79d2fa97720 100644 --- a/frontend/e2e/tests/console/app/debug-pod.spec.ts +++ b/frontend/e2e/tests/console/app/debug-pod.spec.ts @@ -70,7 +70,7 @@ async function pollForPodCrashState( return { ready: false, reason: lastReason }; } -test.describe.serial('Debug pod', () => { +test.describe.serial('Debug pod', { tag: ['@admin'] }, () => { const testNs = `e2e-debug-pod-${Date.now()}`; test.beforeAll(async ({ k8sClient }) => { @@ -114,7 +114,6 @@ test.describe.serial('Debug pod', () => { await detailsPage.isLoaded(); await detailsPage.selectTab('Logs'); await detailsPage.isLoaded(); - await detailsPage.debugContainerLink().waitFor({ state: 'visible', timeout: 30_000 }); await detailsPage.clickDebugContainerLink(); await listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); await expect(detailsPage.xtermViewport).toBeAttached({ timeout: 30_000 }); diff --git a/frontend/e2e/tests/console/app/deployments.spec.ts b/frontend/e2e/tests/console/app/deployments.spec.ts index ca0a5719cfb..7f2e6387839 100644 --- a/frontend/e2e/tests/console/app/deployments.spec.ts +++ b/frontend/e2e/tests/console/app/deployments.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from '../../../fixtures'; import { DetailsPage } from '../../../pages/details-page'; -test.describe.serial('Deployment resource details page', () => { +test.describe.serial('Deployment resource details page', { tag: ['@admin'] }, () => { const testNs = `e2e-deployments-${Date.now()}`; const workloadName = `deployment-e2e`; diff --git a/frontend/e2e/tests/console/app/machine-config.spec.ts b/frontend/e2e/tests/console/app/machine-config.spec.ts index a6134301724..57bd5c37910 100644 --- a/frontend/e2e/tests/console/app/machine-config.spec.ts +++ b/frontend/e2e/tests/console/app/machine-config.spec.ts @@ -24,7 +24,7 @@ const MC_WITHOUT_CONFIG_FILES = '99-master-ssh'; const MC_DETAILS_PAGE_URL = '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfig/'; const MC_SECTION_HEADING = 'Configuration files'; -test.describe('MachineConfig resource details page', () => { +test.describe('MachineConfig resource details page', { tag: ['@admin'] }, () => { test(`${MC_WITH_CONFIG_FILES} displays configuration files`, async ({ page, k8sClient }) => { const detailsPage = new DetailsPage(page); const mcPage = new MachineConfigPage(page); diff --git a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts index 4caddb46f8d..88320a35219 100644 --- a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts +++ b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts @@ -5,7 +5,7 @@ import { YamlEditorPage } from '../../../pages/yaml-editor-page'; const CRONJOB_NAME = 'cronjob1'; -test.describe.serial('Start a Job from a CronJob', () => { +test.describe.serial('Start a Job from a CronJob', { tag: ['@admin'] }, () => { const testNs = `e2e-cronjob-${Date.now()}`; const cronJobPayload = `apiVersion: batch/v1 @@ -37,6 +37,7 @@ spec: await k8sClient.deleteNamespace(testNs); }); + // eslint-disable-next-line playwright/expect-expect test('verify "Start Job" on the CronJob details page', async ({ page }) => { const yamlEditor = new YamlEditorPage(page); const detailsPage = new DetailsPage(page); @@ -74,6 +75,7 @@ spec: await kebab.hover(); await kebab.click(); try { + // eslint-disable-next-line no-restricted-syntax await action.waitFor({ state: 'visible', timeout: 5_000 }); found = true; break; @@ -89,6 +91,7 @@ spec: await detailsPage.titleShouldContain(CRONJOB_NAME); }); + // eslint-disable-next-line playwright/expect-expect test('verify the number of Jobs in CronJob > Jobs tab list page', async ({ page }) => { const listPage = new ListPage(page); From ac6dc62f8c7fa2599afd1e4b2a7c174c7c23a628 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 29 Jun 2026 14:30:04 -0400 Subject: [PATCH 06/10] CONSOLE-5233: Harden page objects from pre-push review findings - YamlEditorPage.setEditorContent now delegates to BasePage which includes a waitForFunction readiness guard for the Monaco model - YamlEditorPage.isImportLoaded uses expect assertion instead of waitFor - filterByStatus falls through to dvFilterBy for DataView filter pages - dvRowsShouldNotExist checks both dvCell and dvRow locators Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/list-page.ts | 4 ++++ frontend/e2e/pages/yaml-editor-page.ts | 14 ++++---------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index 0c7dd9fdaa7..817994fd9fc 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -59,6 +59,8 @@ export class ListPage extends BasePage { await this.robustClick(filterDropdownToggle); await this.page.locator(`[id="${status}"]`).click(); await this.robustClick(filterDropdownToggle); + } else { + await this.dvFilterBy('Status', status); } } } @@ -107,7 +109,9 @@ export class ListPage extends BasePage { async dvRowsShouldNotExist(resourceName: string): Promise { const cell = this.dvCell(resourceName); + const row = this.dvRow(resourceName); await expect(cell).toBeHidden({ timeout: 90_000 }); + await expect(row).toBeHidden({ timeout: 10_000 }); } async dvRowsCountShouldBe(count: number): Promise { diff --git a/frontend/e2e/pages/yaml-editor-page.ts b/frontend/e2e/pages/yaml-editor-page.ts index 6bed9c38686..fbb0950d5c4 100644 --- a/frontend/e2e/pages/yaml-editor-page.ts +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -1,22 +1,16 @@ +import { expect } from '@playwright/test'; + import BasePage from './base-page'; export class YamlEditorPage extends BasePage { async isImportLoaded(): Promise { - await this.page.locator('.monaco-editor textarea').first().waitFor({ - state: 'visible', + await expect(this.page.locator('.monaco-editor textarea').first()).toBeVisible({ timeout: 30_000, }); } async setEditorContent(text: string): Promise { - await this.page.evaluate((content) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const models = (window as any).monaco?.editor?.getModels?.() ?? []; - if (!models[0]) { - throw new Error('Monaco editor model not available'); - } - models[0].setValue(content); - }, text); + await super.setEditorContent(text); } async clickSaveCreateButton(): Promise { From a33e4f1ec791a4012a0222cab6985a20c023cad4 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 29 Jun 2026 14:38:17 -0400 Subject: [PATCH 07/10] CONSOLE-5233: Remove duplicated code across page objects - Remove YamlEditorPage.setEditorContent pass-through (inherits from BasePage) - Extract sectionHeading locator in DetailsPage, reuse in sectionHeaderShouldExist - MachineConfigPage extends DetailsPage instead of BasePage, removing duplicate sectionHeading method - Simplify machine-config.spec.ts to use single MachineConfigPage instance Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/details-page.ts | 8 +++++--- frontend/e2e/pages/machine-config-page.ts | 8 ++------ frontend/e2e/pages/yaml-editor-page.ts | 4 ---- frontend/e2e/tests/console/app/machine-config.spec.ts | 11 ++++------- 4 files changed, 11 insertions(+), 20 deletions(-) diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index b9997f0313f..c1cfed9e7c7 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -30,10 +30,12 @@ export class DetailsPage extends BasePage { await expect(this.pageHeading).toContainText(title, { timeout: 30_000 }); } + sectionHeading(heading: string): Locator { + return this.page.locator(`[data-test-section-heading="${heading}"]`); + } + async sectionHeaderShouldExist(sectionHeading: string): Promise { - await expect( - this.page.locator(`[data-test-section-heading="${sectionHeading}"]`), - ).toBeVisible(); + await expect(this.sectionHeading(sectionHeading)).toBeVisible(); } async isLoaded(): Promise { diff --git a/frontend/e2e/pages/machine-config-page.ts b/frontend/e2e/pages/machine-config-page.ts index 30e7c05b1c1..fd3b47b2f60 100644 --- a/frontend/e2e/pages/machine-config-page.ts +++ b/frontend/e2e/pages/machine-config-page.ts @@ -1,16 +1,12 @@ import { expect } from '@playwright/test'; import type { Locator } from '@playwright/test'; -import BasePage from './base-page'; +import { DetailsPage } from './details-page'; -export class MachineConfigPage extends BasePage { +export class MachineConfigPage extends DetailsPage { readonly configFilePath = this.page.getByTestId('config-file-path-0'); readonly copyToClipboard = this.page.locator('.co-copy-to-clipboard__text'); - sectionHeading(heading: string): Locator { - return this.page.locator(`[data-test-section-heading="${heading}"]`); - } - errorHeading(text: string): Locator { return this.page.getByText(text); } diff --git a/frontend/e2e/pages/yaml-editor-page.ts b/frontend/e2e/pages/yaml-editor-page.ts index fbb0950d5c4..c264ed0eb4c 100644 --- a/frontend/e2e/pages/yaml-editor-page.ts +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -9,10 +9,6 @@ export class YamlEditorPage extends BasePage { }); } - async setEditorContent(text: string): Promise { - await super.setEditorContent(text); - } - async clickSaveCreateButton(): Promise { await this.page.getByTestId('save-changes').click(); } diff --git a/frontend/e2e/tests/console/app/machine-config.spec.ts b/frontend/e2e/tests/console/app/machine-config.spec.ts index 57bd5c37910..7e27f58e16f 100644 --- a/frontend/e2e/tests/console/app/machine-config.spec.ts +++ b/frontend/e2e/tests/console/app/machine-config.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '../../../fixtures'; -import { DetailsPage } from '../../../pages/details-page'; import { MachineConfigPage } from '../../../pages/machine-config-page'; interface MachineConfigFile { @@ -26,12 +25,11 @@ const MC_SECTION_HEADING = 'Configuration files'; test.describe('MachineConfig resource details page', { tag: ['@admin'] }, () => { test(`${MC_WITH_CONFIG_FILES} displays configuration files`, async ({ page, k8sClient }) => { - const detailsPage = new DetailsPage(page); const mcPage = new MachineConfigPage(page); await page.goto(`${MC_DETAILS_PAGE_URL}${MC_WITH_CONFIG_FILES}`); - await detailsPage.isLoaded(); - await detailsPage.titleShouldContain(MC_WITH_CONFIG_FILES); + await mcPage.isLoaded(); + await mcPage.titleShouldContain(MC_WITH_CONFIG_FILES); await expect(mcPage.sectionHeading(MC_SECTION_HEADING)).toBeVisible(); await expect(mcPage.configFilePath).toBeVisible(); @@ -56,12 +54,11 @@ test.describe('MachineConfig resource details page', { tag: ['@admin'] }, () => }); test(`${MC_WITHOUT_CONFIG_FILES} does not display configuration files`, async ({ page }) => { - const detailsPage = new DetailsPage(page); const mcPage = new MachineConfigPage(page); await page.goto(`${MC_DETAILS_PAGE_URL}${MC_WITHOUT_CONFIG_FILES}`); - await detailsPage.isLoaded(); - await detailsPage.titleShouldContain(MC_WITHOUT_CONFIG_FILES); + await mcPage.isLoaded(); + await mcPage.titleShouldContain(MC_WITHOUT_CONFIG_FILES); await expect(mcPage.sectionHeading(MC_SECTION_HEADING)).toBeHidden(); await expect(mcPage.configFilePath).toBeHidden(); From 965e9b930e58928a2cbdefd150710c15a62aee19 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 29 Jun 2026 15:27:53 -0400 Subject: [PATCH 08/10] CONSOLE-5233: Replace remaining waitFor calls with expect assertions in details-page Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/details-page.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index c1cfed9e7c7..b4ffa78bee0 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -26,7 +26,7 @@ export class DetailsPage extends BasePage { } async titleShouldContain(title: string): Promise { - await this.pageHeading.waitFor({ state: 'visible', timeout: 30_000 }); + await expect(this.pageHeading).toBeVisible({ timeout: 30_000 }); await expect(this.pageHeading).toContainText(title, { timeout: 30_000 }); } @@ -41,7 +41,7 @@ export class DetailsPage extends BasePage { async isLoaded(): Promise { await expect(this.skeletonView).toBeHidden({ timeout: 30_000 }); await this.reloadIfModelNotFound(); - await this.resourceTitle.waitFor({ state: 'visible', timeout: 30_000 }); + await expect(this.resourceTitle).toBeVisible({ timeout: 30_000 }); await expect(this.resourceTitle).not.toBeEmpty(); } From fa59c3908e2ed20ad6218fd945df40f33c990882 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 30 Jun 2026 11:27:55 -0400 Subject: [PATCH 09/10] CONSOLE-5233: Revert upstream page objects and adapt tests to use their APIs Restore upstream page objects (details-page, list-page, masthead-page, modal-page, yaml-editor-page, base-page) to their main branch versions to avoid breaking existing tests. Update all new test files to consume the upstream APIs directly, using inline page.locator()/page.getByTestId() for test-specific elements. Add retry-model-error utility to handle the transient "Model does not exist" error without modifying shared page objects. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/base-page.ts | 16 - frontend/e2e/pages/details-page.ts | 91 +++--- frontend/e2e/pages/list-page.ts | 287 ++++++++++-------- frontend/e2e/pages/machine-config-page.ts | 4 +- frontend/e2e/pages/masthead-page.ts | 58 +++- frontend/e2e/pages/modal-page.ts | 31 +- frontend/e2e/pages/yaml-editor-page.ts | 35 ++- ...sion-webhook-warning-notifications.spec.ts | 23 +- .../console/app/auth-multiuser-login.spec.ts | 11 +- .../e2e/tests/console/app/debug-pod.spec.ts | 63 ++-- .../e2e/tests/console/app/deployments.spec.ts | 14 +- .../tests/console/app/machine-config.spec.ts | 11 +- .../app/start-job-from-cronjob.spec.ts | 48 +-- frontend/e2e/utils/retry-model-error.ts | 22 ++ 14 files changed, 419 insertions(+), 295 deletions(-) create mode 100644 frontend/e2e/utils/retry-model-error.ts diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index b5caffcc172..9a6f0cceeae 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -121,22 +121,6 @@ export default abstract class BasePage { throw new Error(`robustClick failed after ${retries} attempts: ${lastError?.message}`); } - protected async reloadIfModelNotFound(maxRetries = 3): Promise { - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - // eslint-disable-next-line no-restricted-syntax - await this.page - .getByText('Model does not exist') - .waitFor({ state: 'visible', timeout: 5_000 }); - } catch { - return; - } - await this.page.evaluate(() => window.location.reload()); - await this.page.waitForLoadState('load', { timeout: 30_000 }); - await this.waitForLoadingComplete(10_000); - } - } - async navigateToTab(locator: Locator, timeoutMs = 60_000): Promise { await this.robustClick(locator, { timeout: timeoutMs }); await this.waitForLoadingComplete(); diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index b4ffa78bee0..6274343a6c2 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -1,70 +1,75 @@ -import { expect } from '@playwright/test'; -import type { Locator } from '@playwright/test'; +import { type Locator, expect } from '@playwright/test'; import BasePage from './base-page'; export class DetailsPage extends BasePage { - private readonly pageHeading = this.page.locator('[data-test="page-heading"]'); - private readonly resourceTitle = this.page.locator('[data-test-id="resource-title"]'); - private readonly skeletonView = this.page.getByTestId('skeleton-detail-view'); - private readonly actionsMenuButton = this.page.locator('[data-test-id="actions-menu-button"]'); - readonly breadcrumbLink0 = this.page.locator('[data-test-id="breadcrumb-link-0"]'); - readonly statusPopoverButton = this.page.getByTestId('popover-status-button'); - readonly enableAutoscaleButton = this.page.getByTestId('enable-autoscale'); - readonly xtermViewport = this.page.locator('.xterm-viewport'); - readonly resourcesSuccessMessage = this.page.getByTestId('resources-successfully-created'); - readonly eventTotals = this.page.getByTestId('event-totals'); - admissionWarning(testId: string): Locator { - return this.page.getByTestId(testId); + private readonly pageHeading = this.page.getByTestId('page-heading'); + private readonly resourceTitle = this.page.getByTestId('resource-title'); + private readonly skeletonLoader = this.page.getByTestId('skeleton-detail-view'); + readonly nodeTerminalError: Locator = this.page.getByTestId('node-terminal-error'); + readonly xtermViewport: Locator = this.page.locator('.xterm-viewport'); + + get title(): Locator { + return this.resourceTitle; } - debugContainerLink(containerName?: string): Locator { - const testId = containerName - ? `popup-debug-container-link-${containerName}` - : 'debug-container-link'; - return this.page.getByTestId(testId); + getPageHeading(): Locator { + return this.pageHeading; } - async titleShouldContain(title: string): Promise { - await expect(this.pageHeading).toBeVisible({ timeout: 30_000 }); - await expect(this.pageHeading).toContainText(title, { timeout: 30_000 }); + async waitForPageLoad(): Promise { + try { + // eslint-disable-next-line no-restricted-syntax + await this.skeletonLoader.waitFor({ state: 'detached', timeout: 30_000 }); + } catch { + // Skeleton may have already disappeared + } + await expect(this.resourceTitle.or(this.pageHeading).first()).toBeVisible({ + timeout: 30_000, + }); } - sectionHeading(heading: string): Locator { - return this.page.locator(`[data-test-section-heading="${heading}"]`); + tab(name: string): Locator { + return this.page.getByTestId(`horizontal-link-${name}`); } - async sectionHeaderShouldExist(sectionHeading: string): Promise { - await expect(this.sectionHeading(sectionHeading)).toBeVisible(); + async clickPageAction(actionName: string): Promise { + await this.robustClick(this.page.getByTestId('actions-menu-button')); + await this.robustClick(this.page.getByTestId(actionName)); } - async isLoaded(): Promise { - await expect(this.skeletonView).toBeHidden({ timeout: 30_000 }); - await this.reloadIfModelNotFound(); - await expect(this.resourceTitle).toBeVisible({ timeout: 30_000 }); - await expect(this.resourceTitle).not.toBeEmpty(); + getBreadcrumb(index: number): Locator { + return this.page.getByTestId(`breadcrumb-link-${index}`); } async selectTab(name: string): Promise { - const tab = this.page.locator(`[data-test-id="horizontal-link-${name}"]`); - await this.robustClick(tab); + await this.navigateToTab(this.tab(name)); + } + + async clickKebabAction(actionId: string): Promise { + await this.robustClick(this.page.getByTestId(actionId)); } - async clickPageActionFromDropdown(actionID: string): Promise { - await this.robustClick(this.actionsMenuButton); - const action = this.page.locator(`[data-test-action="${actionID}"]:not([disabled])`); - await this.robustClick(action); + getResourceRow(resourceId: string): Locator { + const link = this.page.locator(`a[data-test="${resourceId}"]`); + const fallback = this.page.locator( + `[data-test="${resourceId}"], [data-test-action="${resourceId}"]`, + ); + return link.or(fallback).first(); } - async clickBreadcrumb(): Promise { - await this.robustClick(this.breadcrumbLink0); + async clickResourceRow(resourceId: string): Promise { + const row = this.getResourceRow(resourceId); + await this.robustClick(row); } - async clickStatusPopover(): Promise { - await this.robustClick(this.statusPopoverButton, { timeout: 60_000 }); + getResourceByAction(actionName: string): Locator { + return this.page.locator(`[data-test-action="${actionName}"]`); } - async clickDebugContainerLink(containerName?: string): Promise { - await this.robustClick(this.debugContainerLink(containerName)); + async openResourceKebabMenu(actionName: string): Promise { + const resourceRow = this.getResourceByAction(actionName); + const kebabButton = resourceRow.getByTestId('kebab-button'); + await this.robustClick(kebabButton); } } diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index 817994fd9fc..4c8e5485936 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -1,166 +1,199 @@ -import { expect } from '@playwright/test'; -import type { Locator } from '@playwright/test'; +import { type Locator, expect } from '@playwright/test'; import BasePage from './base-page'; export class ListPage extends BasePage { - private readonly heading = this.page.locator('[data-test="page-heading"] h1'); + private readonly pageHeading: Locator = this.page.getByTestId('page-heading').locator('h1'); + private readonly dataViewTable: Locator = this.page.getByTestId('data-view-table'); + private readonly dataViewCells: Locator = this.page.locator('[data-test^="data-view-cell-"]'); + private readonly nameFilterInput = this.page.getByRole('textbox', { name: 'Filter by name' }); + private readonly dataViewFilters = this.page.locator( + '[data-ouia-component-id="DataViewFilters"]', + ); + private readonly singleFilterGroup: Locator = this.page.locator( + '.co-console-data-view-single-filter .pf-v6-c-toolbar__group.pf-m-filter-group', + ); + private readonly namespaceDropdown = this.page.getByTestId('namespace-bar-dropdown'); + private readonly resourceRows = this.page.getByTestId('resource-row'); + private readonly nameFilter = this.page.getByTestId('name-filter-input'); + private readonly createButton = this.page.getByTestId('item-create'); - async titleShouldHaveText(title: string): Promise { - await expect(this.heading).toContainText(title); + get heading(): Locator { + return this.pageHeading; } - // --- Resource row helpers (older VirtualizedTable) --- + get table(): Locator { + return this.dataViewTable; + } - async rowsShouldExist(resourceName: string): Promise { - await expect(this.page.locator(`[data-test-id="${resourceName}"]`)).toBeVisible({ - timeout: 60_000, - }); + get cells(): Locator { + return this.dataViewCells; } - async rowsShouldNotExist(resourceName: string): Promise { - await expect(this.page.locator(`[data-test-id="${resourceName}"]`)).toBeHidden({ - timeout: 90_000, - }); + get filterGroupToggles(): Locator { + return this.singleFilterGroup.locator('.pf-v6-c-menu-toggle'); } - async rowsClickKebabAction(resourceName: string, actionName: string): Promise { - const row = this.page - .locator('[data-test-rows="resource-row"]') - .filter({ hasText: resourceName }); - const kebab = row.locator('[data-test-id="kebab-button"]'); - await this.robustClick(kebab); - const action = this.page.locator(`[data-test-action="${actionName}"]:not([disabled])`); - await this.robustClick(action); + cell(resourceName: string, cellName = 'name'): Locator { + return this.page.getByTestId(`data-view-cell-${resourceName}-${cellName}`); } - async rowsClickStatusButton(resourceName: string): Promise { - const row = this.page - .locator('[data-test-rows="resource-row"]') - .filter({ hasText: resourceName }); - const statusButton = row.getByTestId('popover-status-button'); - await this.robustClick(statusButton, { timeout: 60_000 }); + resourceLink(name: string): Locator { + return this.page.getByTestId(name); } - async filterByStatus(status: string): Promise { - const filterToggle = this.page.locator('[data-ouia-component-id="DataViewCheckboxFilter"]'); - if (await filterToggle.isVisible().catch(() => false)) { - await this.robustClick(filterToggle); - const filterItem = this.page.locator( - `[data-ouia-component-id="DataViewCheckboxFilter-filter-item-${status}"]`, - ); - await this.robustClick(filterItem); - await this.robustClick(filterToggle); - } else { - const filterDropdownToggle = this.page.locator( - '[data-test-id="filter-dropdown-toggle"] button', - ); - if (await filterDropdownToggle.isVisible().catch(() => false)) { - await this.robustClick(filterDropdownToggle); - await this.page.locator(`[id="${status}"]`).click(); - await this.robustClick(filterDropdownToggle); - } else { - await this.dvFilterBy('Status', status); - } + async waitForRows(): Promise { + try { + await expect(this.dataViewTable).toBeVisible({ timeout: 15_000 }); + } catch { + await this.retryOnError(); + await expect(this.dataViewTable).toBeVisible({ timeout: 30_000 }); } } - // --- DataView row helpers (ConsoleDataView) --- - // These use generic table locators that work even if data-test attributes - // are not forwarded to the DOM by PatternFly DataView components. + async filterByName(name: string): Promise { + const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); + await this.robustClick(filterToggle, { timeout: 60_000 }); + await this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' }).click(); + await this.nameFilterInput.fill(name); + } + + async filterByNameInput(name: string): Promise { + await this.nameFilter.fill(name); + } + + getCell(resourceName: string, cellName = 'name'): Locator { + return this.page.getByTestId(`data-view-cell-${resourceName}-${cellName}`); + } + + async clickRowByName(resourceName: string): Promise { + const dataViewLink = this.getCell(resourceName).locator('a').first(); + const standardLink = this.page.getByTestId(resourceName); + await this.robustClick(dataViewLink.or(standardLink).first()); + } + + getNamespaceDropdown(): Locator { + return this.namespaceDropdown; + } + + getDataViewTable(): Locator { + return this.dataViewTable; + } - private dvCell(resourceName: string, cellName = 'name'): Locator { - return this.page.locator(`[data-test="data-view-cell-${resourceName}-${cellName}"]`); + getResourceRows(): Locator { + return this.resourceRows; } - private dvRow(resourceName: string): Locator { - return this.page.locator('table tbody tr').filter({ - has: this.page.getByRole('link', { name: resourceName, exact: true }), - }); + getCreateButton(): Locator { + return this.createButton; } - async dvRowsShouldBeLoaded(): Promise { - await this.reloadIfModelNotFound(); - await expect(this.page.getByTestId('data-view-table')).toBeVisible({ timeout: 60_000 }); + async clickCreateButton(): Promise { + await this.robustClick(this.createButton); } - private async resolveRow(resourceName: string): Promise { - const cell = this.dvCell(resourceName); - if (await cell.isVisible({ timeout: 5_000 }).catch(() => false)) { - return cell.locator('xpath=ancestor::tr'); + async clickCreateDropdownItem(itemName: string): Promise { + await this.robustClick(this.createButton); + await this.page.getByRole('menuitem', { name: itemName }).click(); + } + + async clickCreateYAMLDropdownButton(): Promise { + await this.robustClick(this.createButton); + const yamlMenuItem = this.page.getByTestId('dropdown-menu-yaml'); + if ((await yamlMenuItem.count()) > 0) { + await this.robustClick(yamlMenuItem); } - return this.dvRow(resourceName); } - async dvRowsShouldExist(resourceName: string, cellName = 'name'): Promise { - const cell = this.dvCell(resourceName, cellName); - const row = this.dvRow(resourceName); - try { - await expect(cell).toBeVisible({ timeout: 30_000 }); - } catch { - await this.page.reload({ waitUntil: 'domcontentloaded' }); - try { - await expect(cell).toBeVisible({ timeout: 30_000 }); - } catch { - await expect(row).toBeVisible({ timeout: 30_000 }); + async clickKebabAction(resourceName: string, actionName: string): Promise { + const dataViewCell = this.getCell(resourceName); + const standardCell = this.page.getByTestId(resourceName); + const cell = dataViewCell.or(standardCell).first(); + const row = cell.locator('xpath=ancestor::tr'); + const kebab = row.getByTestId('kebab-button'); + await this.robustClick(kebab); + await this.robustClick(this.page.getByTestId(actionName)); + } + + async clickResourceRowKebabAction(resourceName: string, actionName: string): Promise { + const row = this.resourceRows + .filter({ hasText: resourceName }) + .first(); + const kebab = row.getByTestId('kebab-button'); + await this.robustClick(kebab); + await this.robustClick(this.page.getByTestId(actionName)); + } + + async filterByCheckbox(filterName: string, checkboxLabel: string): Promise { + const dataViewToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); + const standardToggle = this.page.getByTestId('filter-dropdown-toggle').locator('button'); + const toggle = dataViewToggle.or(standardToggle).first(); + await this.robustClick(toggle); + + if (await this.dataViewFilters.isVisible()) { + await this.page.locator('.pf-v6-c-menu__list-item', { hasText: filterName }).click(); + const checkboxFilter = this.page.locator( + '[data-ouia-component-id="DataViewCheckboxFilter"]', + ); + await this.robustClick(checkboxFilter); + const filterItem = this.page.locator( + `[data-ouia-component-id="DataViewCheckboxFilter-filter-item-${checkboxLabel}"]`, + ); + await this.robustClick(filterItem); + await this.robustClick(checkboxFilter); + } else { + const filterItem = this.page.locator(`[data-test-row-filter="${checkboxLabel}"]`); + await this.robustClick(filterItem); + } + } + + async clickFirstRowLink(): Promise { + const firstLink = this.dataViewCells.first().locator('a').first(); + await this.robustClick(firstLink); + } + + async clickFirstRowLinkMatching(pattern: RegExp): Promise { + const safeFlags = pattern.flags.replace(/[gy]/g, ''); + const safePattern = new RegExp(pattern.source, safeFlags); + const links = this.dataViewCells.locator('a'); + const count = await links.count(); + for (let i = 0; i < count; i++) { + const text = await links.nth(i).textContent(); + if (text && safePattern.test(text)) { + await this.robustClick(links.nth(i)); + return; } } + throw new Error(`No row link matching ${pattern} found`); } - async dvRowsShouldNotExist(resourceName: string): Promise { - const cell = this.dvCell(resourceName); - const row = this.dvRow(resourceName); - await expect(cell).toBeHidden({ timeout: 90_000 }); - await expect(row).toBeHidden({ timeout: 10_000 }); + async getFirstCellText(): Promise { + const link = this.page.locator('[data-test^="data-view-cell-"]').first().locator('a').first(); + return (await link.textContent()) ?? ''; } - async dvRowsCountShouldBe(count: number): Promise { - await expect(this.page.locator('table tbody tr')).toHaveCount(count, { timeout: 60_000 }); + async selectProject(projectName: string): Promise { + const dropdownButton = this.namespaceDropdown.getByRole('button'); + await this.robustClick(dropdownButton); + + const searchInput = this.page.getByRole('searchbox', { name: 'Select project...' }); + // eslint-disable-next-line no-restricted-syntax + await searchInput.waitFor({ state: 'visible' }); + + const systemSwitch = this.page.getByTestId('showSystemSwitch'); + if ((await systemSwitch.count()) > 0 && !(await systemSwitch.isChecked())) { + await systemSwitch.check(); + } + + await searchInput.fill(projectName); + const item = this.page.getByRole('menuitem', { name: projectName, exact: true }); + await this.robustClick(item); } - async dvRowsClickKebabAction(resourceName: string, actionName: string): Promise { - const row = await this.resolveRow(resourceName); - const kebab = row.locator('[data-test-id="kebab-button"]'); - await this.robustClick(kebab); - const action = this.page.locator(`[data-test-action="${actionName}"]:not([disabled])`); - await this.robustClick(action); - } - - async dvRowsClickStatusButton(resourceName: string): Promise { - const row = await this.resolveRow(resourceName); - const statusButton = row.getByTestId('popover-status-button'); - await this.robustClick(statusButton, { timeout: 60_000 }); - } - - async dvFilterByName(name: string): Promise { - const filters = this.page.locator('[data-ouia-component-id="DataViewFilters"]'); - await this.robustClick(filters.locator('.pf-v6-c-menu-toggle').first()); - await this.robustClick( - this.page.locator('.pf-v6-c-menu__list-item').filter({ hasText: 'Name' }), - ); - const input = this.page.locator('[aria-label="Filter by name"]'); - await input.clear(); - await input.fill(name); - } - - async dvFilterBy(filterName: string, checkboxLabel: string): Promise { - await this.dvRowsShouldBeLoaded(); - const filters = this.page.locator('[data-ouia-component-id="DataViewFilters"]'); - await this.robustClick(filters.locator('.pf-v6-c-menu-toggle').first()); - await this.robustClick( - this.page.locator('.pf-v6-c-menu__list-item').filter({ hasText: filterName }), - ); - await this.robustClick(this.page.locator('[data-ouia-component-id="DataViewCheckboxFilter"]')); - const filterItem = this.page.locator( - `[data-ouia-component-id="DataViewCheckboxFilter-filter-item-${checkboxLabel}"]`, - ); - await expect(filterItem).toBeVisible(); - await this.robustClick(filterItem); - await expect(this.page).toHaveURL(new RegExp(`=${checkboxLabel}`), { timeout: 10_000 }); - await this.robustClick(this.page.locator('[data-ouia-component-id="DataViewCheckboxFilter"]')); - } - - get createYAMLButton(): Locator { - return this.page.getByTestId('item-create'); + async selectAllProjects(): Promise { + const dropdownButton = this.namespaceDropdown.getByRole('button'); + await this.robustClick(dropdownButton); + const item = this.page.getByRole('menuitem', { name: 'All Projects', exact: true }); + await this.robustClick(item); } } diff --git a/frontend/e2e/pages/machine-config-page.ts b/frontend/e2e/pages/machine-config-page.ts index fd3b47b2f60..1b9e055af51 100644 --- a/frontend/e2e/pages/machine-config-page.ts +++ b/frontend/e2e/pages/machine-config-page.ts @@ -7,8 +7,8 @@ export class MachineConfigPage extends DetailsPage { readonly configFilePath = this.page.getByTestId('config-file-path-0'); readonly copyToClipboard = this.page.locator('.co-copy-to-clipboard__text'); - errorHeading(text: string): Locator { - return this.page.getByText(text); + sectionHeading(heading: string): Locator { + return this.page.locator(`[data-test-section-heading="${heading}"]`); } async checkConfigFileDetails(mode: number, overwrite: boolean, content: string): Promise { diff --git a/frontend/e2e/pages/masthead-page.ts b/frontend/e2e/pages/masthead-page.ts index 192e1fa27d0..4e494b97e21 100644 --- a/frontend/e2e/pages/masthead-page.ts +++ b/frontend/e2e/pages/masthead-page.ts @@ -1,13 +1,59 @@ -import { expect } from '@playwright/test'; +import { type Locator, expect } from '@playwright/test'; import BasePage from './base-page'; export class MastheadPage extends BasePage { - readonly loadingIndicator = this.page.getByTestId('loading-indicator'); - readonly globalNotifications = this.page.getByTestId('global-notifications'); + private readonly logo: Locator = this.page.getByTestId('masthead-logo'); + private readonly quickCreateToggle: Locator = this.page.getByTestId('quick-create-dropdown'); + private readonly userDropdownToggle: Locator = this.page.getByTestId('user-dropdown-toggle'); + private readonly copyLoginCommandLink: Locator = this.page + .getByTestId('copy-login-command') + .locator('a'); + private readonly logOutItem: Locator = this.page.getByTestId('log-out'); + readonly pageHeading: Locator = this.page.getByTestId('page-heading').locator('h1'); - async usernameShouldHaveText(text: string): Promise { - const toggle = this.page.getByTestId('user-dropdown-toggle'); - await expect(toggle).toHaveText(text); + get logoLocator(): Locator { + return this.logo; + } + + async openQuickCreate(): Promise { + // Move mouse away first to dismiss any tooltip that could intercept the click + await this.page.mouse.move(0, 0); + await this.quickCreateToggle.click(); + await expect(this.page.getByTestId('qc-import-yaml')).toBeVisible({ timeout: 10_000 }); + } + + async clickQuickCreateItem(testId: string): Promise { + // Navigate via href — PF6 dropdown re-renders detach the anchor mid-click + const link = this.page.getByTestId(testId).getByRole('menuitem'); + const href = await link.getAttribute('href'); + if (href) { + await this.page.goto(href); + } else { + await link.click(); + } + } + + async openUserDropdown(): Promise { + await this.userDropdownToggle.click(); + } + + async isAuthDisabled(): Promise { + return this.page.evaluate(() => { + const w = window as Window & { SERVER_FLAGS?: { authDisabled?: boolean } }; + return !!w.SERVER_FLAGS?.authDisabled; + }); + } + + async clickCopyLoginCommand(): Promise { + await expect(this.copyLoginCommandLink).toBeVisible(); + await this.copyLoginCommandLink.evaluate((el: HTMLAnchorElement) => + el.removeAttribute('target'), + ); + await this.copyLoginCommandLink.click(); + } + + async clickLogOut(): Promise { + await this.robustClick(this.logOutItem); } } diff --git a/frontend/e2e/pages/modal-page.ts b/frontend/e2e/pages/modal-page.ts index f67970b4329..1925d4d203b 100644 --- a/frontend/e2e/pages/modal-page.ts +++ b/frontend/e2e/pages/modal-page.ts @@ -1,38 +1,33 @@ +import type { Locator } from '@playwright/test'; import { expect } from '@playwright/test'; import BasePage from './base-page'; export class ModalPage extends BasePage { - private get cancelButton() { - return this.page.locator('[data-test-id="modal-cancel-action"]'); + private readonly cancelButton = this.page.getByTestId('modal-cancel-action'); + private readonly submitButton = this.page.getByTestId('confirm-action'); + + getCancelButton(): Locator { + return this.cancelButton; } - private get submitButton() { - return this.page.locator('button[type=submit]'); + getSubmitButton(): Locator { + return this.submitButton; } - async shouldBeOpened(): Promise { - await this.cancelButton.scrollIntoViewIfNeeded(); + async waitForOpen(): Promise { await expect(this.cancelButton).toBeVisible({ timeout: 20_000 }); } - async shouldBeClosed(): Promise { - await expect(this.cancelButton).toBeHidden(); + async waitForClosed(): Promise { + await expect(this.cancelButton).not.toBeAttached({ timeout: 30_000 }); } async submit(): Promise { - await this.submitButton.click(); + await this.robustClick(this.submitButton); } async cancel(): Promise { - await this.cancelButton.click(); - } - - async submitShouldBeDisabled(): Promise { - await expect(this.submitButton).toBeDisabled(); - } - - async submitShouldBeEnabled(): Promise { - await expect(this.submitButton).toBeEnabled(); + await this.robustClick(this.cancelButton); } } diff --git a/frontend/e2e/pages/yaml-editor-page.ts b/frontend/e2e/pages/yaml-editor-page.ts index c264ed0eb4c..0b2adaacb18 100644 --- a/frontend/e2e/pages/yaml-editor-page.ts +++ b/frontend/e2e/pages/yaml-editor-page.ts @@ -1,15 +1,38 @@ +import type { Locator } from '@playwright/test'; import { expect } from '@playwright/test'; import BasePage from './base-page'; export class YamlEditorPage extends BasePage { - async isImportLoaded(): Promise { - await expect(this.page.locator('.monaco-editor textarea').first()).toBeVisible({ - timeout: 30_000, - }); + private readonly codeEditor = this.page.getByTestId('code-editor'); + private readonly saveButton = this.page.getByTestId('save-changes'); + private readonly reloadButton = this.page.getByTestId('reload-object'); + private readonly yamlError = this.page.getByTestId('yaml-error'); + private readonly resourceSidebar = this.page.getByTestId('resource-sidebar'); + + async waitForEditorReady(): Promise { + await expect(this.codeEditor).toBeVisible({ timeout: 30_000 }); + } + + async waitForSidebarLoaded(): Promise { + if ((await this.resourceSidebar.count()) > 0) { + await expect(this.resourceSidebar).toBeAttached({ timeout: 30_000 }); + } + } + + getSaveButton(): Locator { + return this.saveButton; + } + + getYamlError(): Locator { + return this.yamlError; + } + + async clickSave(): Promise { + await this.robustClick(this.saveButton); } - async clickSaveCreateButton(): Promise { - await this.page.getByTestId('save-changes').click(); + async clickReload(): Promise { + await this.robustClick(this.reloadButton); } } diff --git a/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts b/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts index df5c562299b..bc957192c75 100644 --- a/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts +++ b/frontend/e2e/tests/console/app/admission-webhook-warning-notifications.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '../../../fixtures'; -import { DetailsPage } from '../../../pages/details-page'; import { YamlEditorPage } from '../../../pages/yaml-editor-page'; const POD_NAME = 'pod1'; @@ -105,10 +104,9 @@ spec: test('Create a pod and display Admission Webhook warning notification', async ({ page }) => { const yamlEditor = new YamlEditorPage(page); - const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/import`); - await yamlEditor.isImportLoaded(); + await yamlEditor.waitForEditorReady(); await yamlEditor.setEditorContent(pod1ReqObj); await page.route(`**/api/kubernetes/api/v1/namespaces/${testNs}/pods`, async (route) => { @@ -126,14 +124,14 @@ spec: }); }); - await yamlEditor.clickSaveCreateButton(); - await detailsPage.sectionHeaderShouldExist('Pod details'); + await yamlEditor.clickSave(); + await expect(page.locator('[data-test-section-heading="Pod details"]')).toBeVisible(); - const warning = detailsPage.admissionWarning(WARNING_ID); + const warning = page.getByTestId(WARNING_ID); await expect(warning).toContainText('Admission Webhook Warning'); await expect(warning).toContainText(`Pod ${POD_NAME}-a violates policy ${WARNING_FOO}`); - const learnMore = detailsPage.admissionWarning(LEARN_MORE_ID); + const learnMore = page.getByTestId(LEARN_MORE_ID); await expect(learnMore).toContainText('Learn more'); await learnMore.click(); }); @@ -142,10 +140,9 @@ spec: page, }) => { const yamlEditor = new YamlEditorPage(page); - const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/import`); - await yamlEditor.isImportLoaded(); + await yamlEditor.waitForEditorReady(); await yamlEditor.setEditorContent(bulkResourcesReqObj); await page.route(`**/api/kubernetes/api/v1/namespaces/${testNs}/pods`, async (route) => { @@ -181,13 +178,13 @@ spec: }, ); - await yamlEditor.clickSaveCreateButton(); + await yamlEditor.clickSave(); - await expect(detailsPage.resourcesSuccessMessage).toContainText( + await expect(page.getByTestId('resources-successfully-created')).toContainText( 'Resources successfully created', ); - const warning = detailsPage.admissionWarning(WARNING_ID); + const warning = page.getByTestId(WARNING_ID); await expect(warning).toHaveCount(2); await expect(warning.first()).toContainText('Admission Webhook Warning'); await expect( @@ -197,7 +194,7 @@ spec: warning.filter({ hasText: `Deployment ${DEPLOY_NAME} violates policy ${WARNING_BAR}` }), ).toBeVisible(); - const learnMore = detailsPage.admissionWarning(LEARN_MORE_ID); + const learnMore = page.getByTestId(LEARN_MORE_ID); await expect(learnMore.first()).toContainText('Learn more'); await learnMore.first().click(); }); diff --git a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts index 45bddfe40f9..0a8583dc69e 100644 --- a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts +++ b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts @@ -1,6 +1,5 @@ import { test, expect } from '../../../fixtures'; import { LoginPage } from '../../../pages/login-page'; -import { MastheadPage } from '../../../pages/masthead-page'; import { NavPage } from '../../../pages/nav-page'; const KUBEADMIN_IDP = 'kube:admin'; @@ -24,7 +23,6 @@ test.describe('Auth test', { tag: ['@admin'] }, () => { const passwd = htpasswdPassword || 'test'; const loginPage = new LoginPage(page); - const masthead = new MastheadPage(page); const nav = new NavPage(page); await test.step('Login as test user', async () => { @@ -35,7 +33,7 @@ test.describe('Auth test', { tag: ['@admin'] }, () => { }); await test.step('Verify user logged in', async () => { - await masthead.usernameShouldHaveText(username); + await expect(page.getByTestId('user-dropdown-toggle')).toHaveText(username); }); await test.step('Switch to Core platform perspective', async () => { @@ -63,7 +61,6 @@ test.describe('Auth test', { tag: ['@admin'] }, () => { } const loginPage = new LoginPage(page); - const masthead = new MastheadPage(page); const nav = new NavPage(page); await test.step('Login as kubeadmin', async () => { @@ -78,9 +75,9 @@ test.describe('Auth test', { tag: ['@admin'] }, () => { }); await test.step('Verify kubeadmin logged in', async () => { - await expect(masthead.loadingIndicator).toBeHidden(); - await masthead.usernameShouldHaveText(KUBEADMIN_IDP); - await expect(masthead.globalNotifications).toContainText( + await expect(page.getByTestId('loading-indicator')).toBeHidden(); + await expect(page.getByTestId('user-dropdown-toggle')).toHaveText(KUBEADMIN_IDP); + await expect(page.getByTestId('global-notifications')).toContainText( 'You are logged in as a temporary administrative user.', ); }); diff --git a/frontend/e2e/tests/console/app/debug-pod.spec.ts b/frontend/e2e/tests/console/app/debug-pod.spec.ts index 79d2fa97720..97511ec886c 100644 --- a/frontend/e2e/tests/console/app/debug-pod.spec.ts +++ b/frontend/e2e/tests/console/app/debug-pod.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '../../../fixtures'; import { DetailsPage } from '../../../pages/details-page'; import { ListPage } from '../../../pages/list-page'; import { YamlEditorPage } from '../../../pages/yaml-editor-page'; +import { retryOnModelNotFound } from '../../../utils/retry-model-error'; const POD_NAME = 'pod1'; const CONTAINER_NAME = 'container1'; @@ -89,14 +90,13 @@ test.describe.serial('Debug pod', { tag: ['@admin'] }, () => { test('Create pod that has crashbackloop error', async ({ page, k8sClient }) => { test.setTimeout(300_000); const yamlEditor = new YamlEditorPage(page); - const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/import`); - await yamlEditor.isImportLoaded(); + await yamlEditor.waitForEditorReady(); await yamlEditor.setEditorContent(podToDebug); - await yamlEditor.clickSaveCreateButton(); - await expect(page.getByTestId('yaml-error')).toBeHidden(); - await detailsPage.sectionHeaderShouldExist('Pod details'); + await yamlEditor.clickSave(); + await expect(yamlEditor.getYamlError()).toBeHidden(); + await expect(page.locator('[data-test-section-heading="Pod details"]')).toBeVisible(); // Wait for pod to enter CrashLoopBackOff so debug links appear in subsequent tests const podState = await pollForPodCrashState(k8sClient, testNs, POD_NAME, 120_000); @@ -109,16 +109,19 @@ test.describe.serial('Debug pod', { tag: ['@admin'] }, () => { const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/pods`); - await listPage.dvRowsShouldExist(POD_NAME); + await listPage.waitForRows(); + await expect(listPage.cell(POD_NAME)).toBeVisible({ timeout: 60_000 }); await page.goto(`/k8s/ns/${testNs}/pods/${POD_NAME}`); - await detailsPage.isLoaded(); + await detailsPage.waitForPageLoad(); + await retryOnModelNotFound(page); await detailsPage.selectTab('Logs'); - await detailsPage.isLoaded(); - await detailsPage.clickDebugContainerLink(); - await listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); + await detailsPage.waitForPageLoad(); + await page.getByTestId('debug-container-link').click(); + await expect(listPage.heading).toContainText(`Debug ${CONTAINER_NAME}`); await expect(detailsPage.xtermViewport).toBeAttached({ timeout: 30_000 }); - await detailsPage.clickBreadcrumb(); - await listPage.dvRowsShouldExist(POD_NAME); + await detailsPage.getBreadcrumb(0).click(); + await listPage.waitForRows(); + await expect(listPage.cell(POD_NAME)).toBeVisible({ timeout: 60_000 }); }); test('Opens debug terminal page from Pod Details - Status tool tip', async ({ page }) => { @@ -127,17 +130,19 @@ test.describe.serial('Debug pod', { tag: ['@admin'] }, () => { const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/pods/${POD_NAME}`); - await detailsPage.isLoaded(); - await detailsPage.clickStatusPopover(); + await detailsPage.waitForPageLoad(); + await retryOnModelNotFound(page); + await page.getByTestId('popover-status-button').click({ timeout: 60_000 }); // Regression test for OCPBUGS-83813: Wait for popover content to be stable before clicking // https://issues.redhat.com/browse/OCPBUGS-83813 - const debugLink = detailsPage.debugContainerLink(CONTAINER_NAME); + const debugLink = page.getByTestId(`popup-debug-container-link-${CONTAINER_NAME}`); await expect(debugLink).toBeVisible({ timeout: 30_000 }); - await detailsPage.clickDebugContainerLink(CONTAINER_NAME); - await listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); + await debugLink.click(); + await expect(listPage.heading).toContainText(`Debug ${CONTAINER_NAME}`); await expect(detailsPage.xtermViewport).toBeAttached({ timeout: 30_000 }); - await detailsPage.clickBreadcrumb(); - await listPage.dvRowsShouldExist(POD_NAME); + await detailsPage.getBreadcrumb(0).click(); + await listPage.waitForRows(); + await expect(listPage.cell(POD_NAME)).toBeVisible({ timeout: 60_000 }); }); test('Opens debug terminal page from Pods Page - Status tool tip', async ({ @@ -149,14 +154,16 @@ test.describe.serial('Debug pod', { tag: ['@admin'] }, () => { const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/pods`); - await listPage.dvRowsShouldExist(POD_NAME); - await listPage.dvRowsClickStatusButton(POD_NAME); + await listPage.waitForRows(); + await expect(listPage.cell(POD_NAME)).toBeVisible({ timeout: 60_000 }); + const row = listPage.cell(POD_NAME).locator('xpath=ancestor::tr'); + await row.getByTestId('popover-status-button').click({ timeout: 60_000 }); // Regression test for OCPBUGS-83813: Wait for popover content to be stable before clicking // https://issues.redhat.com/browse/OCPBUGS-83813 - const debugLink = detailsPage.debugContainerLink(CONTAINER_NAME); + const debugLink = page.getByTestId(`popup-debug-container-link-${CONTAINER_NAME}`); await expect(debugLink).toBeVisible({ timeout: 30_000 }); await debugLink.click(); - await listPage.titleShouldHaveText(`Debug ${CONTAINER_NAME}`); + await expect(listPage.heading).toContainText(`Debug ${CONTAINER_NAME}`); await expect(detailsPage.xtermViewport).toBeAttached({ timeout: 30_000 }); // Debug pod should not copy main pod network info @@ -168,8 +175,9 @@ test.describe.serial('Debug pod', { tag: ['@admin'] }, () => { expect(debugPod?.status?.podIP).toBeTruthy(); expect(mainPod?.status?.podIP).not.toEqual(debugPod?.status?.podIP); - await detailsPage.clickBreadcrumb(); - await listPage.dvRowsShouldExist(POD_NAME); + await detailsPage.getBreadcrumb(0).click(); + await listPage.waitForRows(); + await expect(listPage.cell(POD_NAME)).toBeVisible({ timeout: 60_000 }); }); test('Debug pod should be terminated after leaving debug container page', async ({ @@ -179,8 +187,9 @@ test.describe.serial('Debug pod', { tag: ['@admin'] }, () => { const listPage = new ListPage(page); await page.goto(`/k8s/ns/${testNs}/pods`); - await listPage.dvRowsShouldExist(POD_NAME); - await listPage.filterByStatus('Running'); + await listPage.waitForRows(); + await expect(listPage.cell(POD_NAME)).toBeVisible({ timeout: 60_000 }); + await listPage.filterByCheckbox('Status', 'Running'); await expect .poll( diff --git a/frontend/e2e/tests/console/app/deployments.spec.ts b/frontend/e2e/tests/console/app/deployments.spec.ts index 7f2e6387839..b9037aec09a 100644 --- a/frontend/e2e/tests/console/app/deployments.spec.ts +++ b/frontend/e2e/tests/console/app/deployments.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../../fixtures'; import { DetailsPage } from '../../../pages/details-page'; +import { retryOnModelNotFound } from '../../../utils/retry-model-error'; test.describe.serial('Deployment resource details page', { tag: ['@admin'] }, () => { const testNs = `e2e-deployments-${Date.now()}`; @@ -52,16 +53,19 @@ test.describe.serial('Deployment resource details page', { tag: ['@admin'] }, () const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/deployments/${workloadName}`); - await detailsPage.isLoaded(); - await expect(detailsPage.enableAutoscaleButton).toBeVisible(); - await detailsPage.enableAutoscaleButton.click(); + await detailsPage.waitForPageLoad(); + await retryOnModelNotFound(page); + const autoscaleButton = page.getByTestId('enable-autoscale'); + await expect(autoscaleButton).toBeVisible(); + await autoscaleButton.click(); }); test('Enable deployment autoscale button should not exist', async ({ page }) => { const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/deployments/${workloadName}`); - await detailsPage.isLoaded(); - await expect(detailsPage.enableAutoscaleButton).toBeHidden({ timeout: 10_000 }); + await detailsPage.waitForPageLoad(); + await retryOnModelNotFound(page); + await expect(page.getByTestId('enable-autoscale')).toBeHidden({ timeout: 10_000 }); }); }); diff --git a/frontend/e2e/tests/console/app/machine-config.spec.ts b/frontend/e2e/tests/console/app/machine-config.spec.ts index 7e27f58e16f..79bad34a738 100644 --- a/frontend/e2e/tests/console/app/machine-config.spec.ts +++ b/frontend/e2e/tests/console/app/machine-config.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from '../../../fixtures'; import { MachineConfigPage } from '../../../pages/machine-config-page'; +import { retryOnModelNotFound } from '../../../utils/retry-model-error'; interface MachineConfigFile { path: string; @@ -28,8 +29,9 @@ test.describe('MachineConfig resource details page', { tag: ['@admin'] }, () => const mcPage = new MachineConfigPage(page); await page.goto(`${MC_DETAILS_PAGE_URL}${MC_WITH_CONFIG_FILES}`); - await mcPage.isLoaded(); - await mcPage.titleShouldContain(MC_WITH_CONFIG_FILES); + await mcPage.waitForPageLoad(); + await retryOnModelNotFound(page); + await expect(mcPage.getPageHeading()).toContainText(MC_WITH_CONFIG_FILES); await expect(mcPage.sectionHeading(MC_SECTION_HEADING)).toBeVisible(); await expect(mcPage.configFilePath).toBeVisible(); @@ -57,8 +59,9 @@ test.describe('MachineConfig resource details page', { tag: ['@admin'] }, () => const mcPage = new MachineConfigPage(page); await page.goto(`${MC_DETAILS_PAGE_URL}${MC_WITHOUT_CONFIG_FILES}`); - await mcPage.isLoaded(); - await mcPage.titleShouldContain(MC_WITHOUT_CONFIG_FILES); + await mcPage.waitForPageLoad(); + await retryOnModelNotFound(page); + await expect(mcPage.getPageHeading()).toContainText(MC_WITHOUT_CONFIG_FILES); await expect(mcPage.sectionHeading(MC_SECTION_HEADING)).toBeHidden(); await expect(mcPage.configFilePath).toBeHidden(); diff --git a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts index 88320a35219..84531032874 100644 --- a/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts +++ b/frontend/e2e/tests/console/app/start-job-from-cronjob.spec.ts @@ -2,6 +2,7 @@ import { test, expect } from '../../../fixtures'; import { DetailsPage } from '../../../pages/details-page'; import { ListPage } from '../../../pages/list-page'; import { YamlEditorPage } from '../../../pages/yaml-editor-page'; +import { retryOnModelNotFound } from '../../../utils/retry-model-error'; const CRONJOB_NAME = 'cronjob1'; @@ -43,15 +44,16 @@ spec: const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/import`); - await yamlEditor.isImportLoaded(); + await yamlEditor.waitForEditorReady(); await yamlEditor.setEditorContent(cronJobPayload); - await yamlEditor.clickSaveCreateButton(); - await detailsPage.sectionHeaderShouldExist('CronJob details'); - - await detailsPage.clickPageActionFromDropdown('Start Job'); - await detailsPage.isLoaded(); - await detailsPage.sectionHeaderShouldExist('Job details'); - await detailsPage.titleShouldContain(CRONJOB_NAME); + await yamlEditor.clickSave(); + await expect(page.locator('[data-test-section-heading="CronJob details"]')).toBeVisible(); + + await detailsPage.clickPageAction('Start Job'); + await detailsPage.waitForPageLoad(); + await retryOnModelNotFound(page); + await expect(page.locator('[data-test-section-heading="Job details"]')).toBeVisible(); + await expect(detailsPage.getPageHeading()).toContainText(CRONJOB_NAME); }); test('verify "Start Job" on the CronJob list page', async ({ page }) => { @@ -59,14 +61,13 @@ spec: const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/cronjobs`); - await listPage.dvRowsShouldExist(CRONJOB_NAME); + await listPage.waitForRows(); + await expect(listPage.cell(CRONJOB_NAME)).toBeVisible({ timeout: 60_000 }); // LazyActionMenu loads actions lazily and WebSocket updates can re-render the // table (resetting menu state), so retry opening the kebab if the action disappears. - const row = page.locator('table tbody tr').filter({ - has: page.getByRole('link', { name: CRONJOB_NAME, exact: true }), - }); - const kebab = row.locator('[data-test-id="kebab-button"]'); + const row = listPage.cell(CRONJOB_NAME).locator('xpath=ancestor::tr'); + const kebab = row.getByTestId('kebab-button'); const action = page.locator('[data-test-action="Start Job"]:not([disabled])'); const deadline = Date.now() + 30_000; @@ -86,9 +87,10 @@ spec: expect(found, 'Kebab action "Start Job" was not visible after retries').toBeTruthy(); await action.click(); - await detailsPage.isLoaded(); - await detailsPage.sectionHeaderShouldExist('Job details'); - await detailsPage.titleShouldContain(CRONJOB_NAME); + await detailsPage.waitForPageLoad(); + await retryOnModelNotFound(page); + await expect(page.locator('[data-test-section-heading="Job details"]')).toBeVisible(); + await expect(detailsPage.getPageHeading()).toContainText(CRONJOB_NAME); }); // eslint-disable-next-line playwright/expect-expect @@ -96,17 +98,21 @@ spec: const listPage = new ListPage(page); await page.goto(`/k8s/ns/${testNs}/cronjobs`); - await listPage.dvRowsShouldExist(CRONJOB_NAME); + await listPage.waitForRows(); + await expect(listPage.cell(CRONJOB_NAME)).toBeVisible({ timeout: 60_000 }); await page.goto(`/k8s/ns/${testNs}/cronjobs/${CRONJOB_NAME}/jobs`); - await listPage.dvRowsShouldBeLoaded(); - await listPage.dvRowsCountShouldBe(2); + await listPage.waitForRows(); + await expect(listPage.cells).toHaveCount(2); }); test('verify the number of events in CronJob > Events tab list page', async ({ page }) => { const detailsPage = new DetailsPage(page); await page.goto(`/k8s/ns/${testNs}/cronjobs/${CRONJOB_NAME}/events`); - await detailsPage.isLoaded(); - await expect(detailsPage.eventTotals).toHaveText('Showing 2 events', { timeout: 10_000 }); + await detailsPage.waitForPageLoad(); + await retryOnModelNotFound(page); + await expect(page.getByTestId('event-totals')).toHaveText('Showing 2 events', { + timeout: 10_000, + }); }); }); diff --git a/frontend/e2e/utils/retry-model-error.ts b/frontend/e2e/utils/retry-model-error.ts new file mode 100644 index 00000000000..87eb60e1946 --- /dev/null +++ b/frontend/e2e/utils/retry-model-error.ts @@ -0,0 +1,22 @@ +import type { Page } from '@playwright/test'; + +/** + * Retry page reload when the console shows "Model does not exist" error. + * This transient error occurs when navigating to a resource page before + * all CRD models have been registered by the backend. + */ +export async function retryOnModelNotFound(page: Page, maxRetries = 3): Promise { + const errorLocator = page.getByText('Model does not exist'); + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + // eslint-disable-next-line no-restricted-syntax + await errorLocator.waitFor({ state: 'visible', timeout: 5_000 }); + } catch { + return; + } + await page.reload({ waitUntil: 'load' }); + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(3_000); + } +} From dff9387b12067ff9381e62f0f2554fcae7c5bf51 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 30 Jun 2026 11:45:57 -0400 Subject: [PATCH 10/10] CONSOLE-5233: Fix review findings from pre-push review - retryOnModelNotFound now throws a descriptive error after exhausting retries instead of silently continuing - Restore .artifacts/ gitignore entry and trailing newline - Use Playwright baseURL in LoginPage instead of hardcoded localhost - Simplify redundant provider button visibility check - Remove unnecessary kubeadmin password guard from htpasswd login test Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 ++- frontend/e2e/pages/login-page.ts | 5 ++--- frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts | 5 ++--- frontend/e2e/utils/retry-model-error.ts | 5 +++++ 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 1ae9898cacc..42b73536ed5 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ cypress-a11y-report.json /dynamic-demo-plugin/**/dist **/.claude/settings.local.json **/chartstore-*/ -.playwright-mcp/ \ No newline at end of file +.artifacts/ +.playwright-mcp/ diff --git a/frontend/e2e/pages/login-page.ts b/frontend/e2e/pages/login-page.ts index c9a71b01421..0994baf15ca 100644 --- a/frontend/e2e/pages/login-page.ts +++ b/frontend/e2e/pages/login-page.ts @@ -14,8 +14,7 @@ export class LoginPage extends BasePage { } async loginAs(provider: string, username: string, password: string): Promise { - const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; - await this.page.goto(baseURL, { timeout: 90_000, waitUntil: 'domcontentloaded' }); + await this.page.goto('./', { timeout: 90_000, waitUntil: 'domcontentloaded' }); const authDisabled = await this.page .evaluate(() => (window as any).SERVER_FLAGS?.authDisabled) @@ -30,7 +29,7 @@ export class LoginPage extends BasePage { this.loginButton.or(this.usernameInput).or(providerBtn).first(), ).toBeVisible({ timeout: 30_000 }); - if ((await providerBtn.count()) > 0 && (await providerBtn.isVisible())) { + if (await providerBtn.isVisible()) { await providerBtn.click(); await expect(this.usernameInput).toBeVisible({ timeout: 30_000 }); } diff --git a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts index 0a8583dc69e..ede4e24c493 100644 --- a/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts +++ b/frontend/e2e/tests/console/app/auth-multiuser-login.spec.ts @@ -10,17 +10,16 @@ test.describe('Auth test', { tag: ['@admin'] }, () => { // eslint-disable-next-line playwright/expect-expect test("logs in as 'test' user via htpasswd identity provider", async ({ page }) => { - const kubeadminPassword = process.env.BRIDGE_KUBEADMIN_PASSWORD; const htpasswdPassword = process.env.BRIDGE_HTPASSWD_PASSWORD; - if (!kubeadminPassword || !htpasswdPassword) { + if (!htpasswdPassword) { test.skip(); return; } const idp = process.env.BRIDGE_HTPASSWD_IDP || 'test'; const username = process.env.BRIDGE_HTPASSWD_USERNAME || 'test'; - const passwd = htpasswdPassword || 'test'; + const passwd = htpasswdPassword; const loginPage = new LoginPage(page); const nav = new NavPage(page); diff --git a/frontend/e2e/utils/retry-model-error.ts b/frontend/e2e/utils/retry-model-error.ts index 87eb60e1946..409fa644010 100644 --- a/frontend/e2e/utils/retry-model-error.ts +++ b/frontend/e2e/utils/retry-model-error.ts @@ -19,4 +19,9 @@ export async function retryOnModelNotFound(page: Page, maxRetries = 3): Promise< // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(3_000); } + + // eslint-disable-next-line no-restricted-syntax + if (await errorLocator.isVisible().catch(() => false)) { + throw new Error(`"Model does not exist" persisted after ${maxRetries} reload attempts`); + } }