From 53b98568250ce586080883f3597aeda4c086fd70 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 9 Jun 2026 11:58:24 -0400 Subject: [PATCH 01/19] Remove redundant waitFor calls and unused waitForPageLoad Playwright action methods (fill, click, check, clear) and robustClick auto-wait for element actionability. Explicit waitFor({ state: 'visible' }) immediately before an action on the same element is redundant. - Remove DetailsPage.waitForPageLoad() method along with the unused skeletonLoader and resourceTitle fields it depended on - Remove waitForLoad parameter from clickResourceRow (never called with false, and robustClick already auto-waits) - Remove redundant waitFor before robustClick in clickKebabAction and clickResourceRow - Remove redundant waitFor calls in cluster-settings-page, navigation, oauth-page, web-terminal-page, and favorites.spec.ts - Fix migration docs: deleteClusterCustomResource does not exist in KubernetesClient; only deleteNamespace and deleteCustomResource swallow 404 errors - Update migration-context.md and migrate-cypress skill with auto-awaiting rules to prevent redundant waitFor in future migrations Co-Authored-By: Claude Opus 4.6 --- .claude/migration-context.md | 3 ++- .claude/skills/migrate-cypress/SKILL.md | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.claude/migration-context.md b/.claude/migration-context.md index 756160c8dd4..e6ef1ea7fb4 100644 --- a/.claude/migration-context.md +++ b/.claude/migration-context.md @@ -97,7 +97,7 @@ When migrating a Cypress test that uses `cy.get('[data-test-id="x"]')` or `cy.by | Cypress | Playwright | | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `cy.wait(3000)` | Use web assertions e.g. `await expect(locator).toBeVisible()` or condition-based waits. Only use `page.waitForTimeout()` as absolute last resort during debugging. | +| `cy.wait(3000)` | **AVOID.** Use `await expect(locator).toBeVisible()` or condition-based waits. Only use `page.waitForTimeout()` as absolute last resort during debugging. | | `cy.get(s, { timeout }).click()` | `await locator.click({ timeout })` — pass timeout to the action, not a separate `waitFor()`. All Playwright actions accept a `timeout` option | | `cy.get(s, { timeout }).should('be.visible')` | `await expect(locator).toBeVisible({ timeout })` — pass timeout to the assertion | | `cy.get(s, { timeout })` (no action, just waiting) | `await locator.waitFor({ state: 'visible', timeout })` — only when no action or assertion follows | @@ -447,6 +447,7 @@ Playwright action methods (`fill()`, `click()`, `check()`, `uncheck()`, `selectO > **ESLint enforcement:** The `no-restricted-syntax` rule in `e2e/.eslintrc.cjs` warns on all `.waitFor()` calls. Legitimate uses must have `// eslint-disable-next-line no-restricted-syntax`. This catches redundant `waitFor()` at lint time — `yarn eslint` will flag new violations. + ```typescript // WRONG — redundant waitFor before an action await input.waitFor({ state: 'visible' }); diff --git a/.claude/skills/migrate-cypress/SKILL.md b/.claude/skills/migrate-cypress/SKILL.md index 8d4dfc14d46..ef408f5d7f0 100644 --- a/.claude/skills/migrate-cypress/SKILL.md +++ b/.claude/skills/migrate-cypress/SKILL.md @@ -82,6 +82,7 @@ If MCP is unavailable or no cluster is reachable, log a warning: "Playwright MCP - If the React component only has a legacy test attribute (`data-test-id`, `data-test-rows`, `data-test-dropdown-menu`, etc.) but no `data-test`, **add `data-test` to the React component source** and use `getByTestId()` — never use legacy attribute selectors directly - Expose locators via getter methods (`getX(): Locator`), keep locator properties `private readonly` - Use `robustClick()` inside page objects; specs use plain `.click()` + - Do NOT add `waitFor()` before action methods (`fill()`, `click()`, `check()`) — Playwright auto-waits for actionability - Do NOT name methods or locators with a `legacy` prefix — name for what they do Example: @@ -146,6 +147,7 @@ Example: - **Never transliterate** — understand intent, use idiomatic Playwright APIs - **Self-contained tests** — merge sequential `it` blocks into one `test()` with `test.step()` - **No fixed waits** — replace `cy.wait(ms)` with condition-based waits or assertion timeouts +- **No redundant `waitFor()`** — `fill()`, `click()`, `check()`, etc. auto-wait for actionability; only use `waitFor()` when waiting for state without acting on the element - **No shell commands** — replace `cy.exec('oc ...')` with `KubernetesClient` - **No try/catch in cleanup** — `k8sClient.deleteNamespace()` and `deleteCustomResource()` already swallow 404 errors - **Add `data-test` to React source** — when the component only has legacy test attributes (`data-test-id`, `data-test-rows`, etc.), add `data-test` alongside and use `getByTestId()` From 7f3239904e07d2a7ad6a3f9f645b507eb677afa9 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Thu, 4 Jun 2026 09:14:57 -0400 Subject: [PATCH 02/19] CONSOLE-5278: Migrate console CRUD Cypress tests to Playwright MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate all 5 CRUD Cypress test files to Playwright: - other-routes.cy.ts → other-routes.spec.ts (error page, API explorer, cluster settings routes, perspective query parameters) - quotas.cy.ts → quotas.spec.ts (ResourceQuota and ClusterResourceQuota creation via YAML editor, list filtering across projects) - roles-rolebindings.cy.ts → roles-rolebindings.spec.ts (Role and ClusterRole creation via YAML editor, RoleBinding and ClusterRoleBinding creation via form, rules table columns, breadcrumb project dropdown restoration) - image-pull-secret.cy.ts → image-pull-secret.spec.ts (image pull secret creation via form, whitespace trimming validation) - add-storage-crud.cy.ts → add-storage-crud.spec.ts (workload creation via YAML editor, add storage via Actions dropdown, volume table verification) Infrastructure: - Extract shared Monaco editor helpers to base-page.ts - Add cluster-scoped create/delete to KubernetesClient - Add cluster resource tracking to cleanup fixture - Add data-test alongside data-test-id on Breadcrumbs, actions menu button, kebab items, ActionMenuItem, and ActionMenuContent - Add data-test to attach-pvc-storage submit button - New page objects: RoleBindingPage, ListPage - Extend DetailsPage with clickPageAction and getBreadcrumb Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/clients/kubernetes-client.ts | 30 ++ frontend/e2e/fixtures/cleanup-fixture.ts | 30 ++ frontend/e2e/pages/alertmanager-page.ts | 13 +- frontend/e2e/pages/base-page.ts | 20 ++ frontend/e2e/pages/details-page.ts | 9 + frontend/e2e/pages/list-page.ts | 62 ++++ frontend/e2e/pages/role-binding-page.ts | 48 +++ .../console/crd-extensions/crd-test-utils.ts | 11 +- .../console/crud/add-storage-crud.spec.ts | 92 ++++++ .../console/crud/image-pull-secret.spec.ts | 66 ++++ .../tests/console/crud/other-routes.spec.ts | 228 +++++++++++++ .../e2e/tests/console/crud/quotas.spec.ts | 157 +++++++++ .../console/crud/roles-rolebindings.spec.ts | 300 ++++++++++++++++++ frontend/e2e/utils/a11y.ts | 49 +++ frontend/package.json | 1 + .../src/components/nav/NavHeader.tsx | 8 +- .../actions/menu/ActionMenuContent.tsx | 1 + .../actions/menu/ActionMenuItem.tsx | 1 + .../components/breadcrumbs/Breadcrumbs.tsx | 1 + .../tests/crud/add-storage-crud.cy.ts | 99 ------ .../tests/crud/image-pull-secret.cy.ts | 81 ----- .../tests/crud/other-routes.cy.ts | 167 ---------- .../integration-tests/tests/crud/quotas.cy.ts | 138 -------- .../tests/crud/roles-rolebindings.cy.ts | 223 ------------- .../integration-tests/views/rolebindings.ts | 27 -- frontend/public/components/api-explorer.tsx | 6 +- .../cluster-settings/cluster-settings.tsx | 12 +- frontend/public/components/error.tsx | 1 + frontend/public/components/factory/table.tsx | 1 + .../components/storage/attach-pvc-storage.tsx | 1 + .../public/components/utils/actions-menu.tsx | 1 + frontend/public/components/utils/kebab.tsx | 1 + frontend/yarn.lock | 20 +- 33 files changed, 1143 insertions(+), 762 deletions(-) create mode 100644 frontend/e2e/pages/list-page.ts create mode 100644 frontend/e2e/pages/role-binding-page.ts create mode 100644 frontend/e2e/tests/console/crud/add-storage-crud.spec.ts create mode 100644 frontend/e2e/tests/console/crud/image-pull-secret.spec.ts create mode 100644 frontend/e2e/tests/console/crud/other-routes.spec.ts create mode 100644 frontend/e2e/tests/console/crud/quotas.spec.ts create mode 100644 frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts create mode 100644 frontend/e2e/utils/a11y.ts delete mode 100644 frontend/packages/integration-tests/tests/crud/add-storage-crud.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/crud/image-pull-secret.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/crud/other-routes.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/crud/quotas.cy.ts delete mode 100644 frontend/packages/integration-tests/tests/crud/roles-rolebindings.cy.ts delete mode 100644 frontend/packages/integration-tests/views/rolebindings.ts diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index 42392ef01a1..a07888156b6 100644 --- a/frontend/e2e/clients/kubernetes-client.ts +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -435,6 +435,36 @@ export default class KubernetesClient { } } + async createClusterCustomResource( + group: string, + version: string, + plural: string, + body: Record, + ): Promise { + const response = await this.coApi.createClusterCustomObject({ + body, + group, + plural, + version, + }); + return response; + } + + async deleteClusterCustomResource( + group: string, + version: string, + plural: string, + name: string, + ): Promise { + try { + await this.coApi.deleteClusterCustomObject({ group, name, plural, version }); + } catch (err) { + if (!isNotFound(err)) { + throw err; + } + } + } + async getCustomResource( group: string, version: string, diff --git a/frontend/e2e/fixtures/cleanup-fixture.ts b/frontend/e2e/fixtures/cleanup-fixture.ts index 4cfb3aa9a1d..e0493c17ad4 100644 --- a/frontend/e2e/fixtures/cleanup-fixture.ts +++ b/frontend/e2e/fixtures/cleanup-fixture.ts @@ -23,6 +23,13 @@ export interface CleanupFixture { plural: string, type?: string, ): void; + trackClusterCustomResource( + name: string, + apiGroup: string, + apiVersion: string, + plural: string, + type?: string, + ): void; readonly count: number; executeCleanup(): Promise; shouldSkipCleanup(): boolean; @@ -95,6 +102,22 @@ export function createCleanupFixture(testName: string): CleanupFixture { }); }, + trackClusterCustomResource( + name: string, + apiGroup: string, + apiVersion: string, + plural: string, + type?: string, + ) { + resources.push({ + name, + apiGroup, + apiVersion, + plural, + type: type || `Cluster:${plural}`, + }); + }, + get count() { return resources.length; }, @@ -142,6 +165,13 @@ export function createCleanupFixture(testName: string): CleanupFixture { resource.plural, resource.name, ); + } else if (resource.apiGroup) { + await client.deleteClusterCustomResource( + resource.apiGroup, + resource.apiVersion, + resource.plural, + resource.name, + ); } } catch (error) { const msg = error instanceof Error ? error.message : String(error); diff --git a/frontend/e2e/pages/alertmanager-page.ts b/frontend/e2e/pages/alertmanager-page.ts index b2f0f7dafd0..baee2a9dc4d 100644 --- a/frontend/e2e/pages/alertmanager-page.ts +++ b/frontend/e2e/pages/alertmanager-page.ts @@ -63,20 +63,11 @@ export class AlertmanagerPage extends BasePage { } async getYAMLContent(): Promise { - // Get content from Monaco editor - const content = await this.page.evaluate(() => { - const monacoEditor = (window as any).monaco?.editor?.getModels()?.[0]; - return monacoEditor?.getValue() || ''; - }); - - return content; + return this.getEditorContent(); } async setYAMLContent(content: string): Promise { - await this.page.evaluate((text) => { - const monacoEditor = (window as any).monaco?.editor?.getModels()?.[0]; - monacoEditor?.setValue(text); - }, content); + await this.setEditorContent(content); } async validateReceiverInList(receiverName: string): Promise { diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index 404d46f252e..edfa06e81c1 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -1,5 +1,17 @@ import type { Locator, Page } from '@playwright/test'; +export async function getEditorContent(page: Page): Promise { + return page.evaluate(() => { + return (window as any).monaco?.editor?.getModels()?.[0]?.getValue() ?? ''; + }); +} + +export async function setEditorContent(page: Page, content: string): Promise { + await page.evaluate((text) => { + (window as any).monaco?.editor?.getModels()?.[0]?.setValue(text); + }, content); +} + export default abstract class BasePage { constructor(public readonly page: Page) {} @@ -99,6 +111,14 @@ export default abstract class BasePage { await this.robustClick(button); } + async getEditorContent(): Promise { + return getEditorContent(this.page); + } + + async setEditorContent(content: string): Promise { + await setEditorContent(this.page, content); + } + async switchPerspective(target: 'Developer' | 'Administrator'): Promise { const labelMap: Record = { Administrator: ['Administrator', 'Core platform'], diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index 643eb9d33e9..99371ff0d35 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -12,6 +12,15 @@ export class DetailsPage extends BasePage { return this.pageHeading; } + async clickPageAction(actionName: string): Promise { + await this.robustClick(this.page.getByTestId('actions-menu-button')); + await this.robustClick(this.page.getByTestId(actionName)); + } + + getBreadcrumb(index: number): Locator { + return this.page.getByTestId(`breadcrumb-link-${index}`); + } + /** * Select a specific tab by name */ diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts new file mode 100644 index 00000000000..b1b6608403e --- /dev/null +++ b/frontend/e2e/pages/list-page.ts @@ -0,0 +1,62 @@ +import type { Locator } from '@playwright/test'; + +import BasePage from './base-page'; + +export class ListPage extends BasePage { + private readonly dataViewTable = this.page.getByTestId('data-view-table'); + private readonly nameFilterInput = this.page.getByRole('textbox', { name: 'Filter by name' }); + private readonly dataViewFilters = this.page.locator( + '[data-ouia-component-id="DataViewFilters"]', + ); + private readonly namespaceDropdown = this.page.getByTestId('namespace-bar-dropdown'); + + async waitForListLoad(): Promise { + await this.dataViewTable.or(this.page.getByTestId('page-heading')).first().waitFor({ + state: 'visible', + }); + } + + async filterByName(name: string): Promise { + await this.dataViewFilters.waitFor({ state: 'visible', timeout: 60_000 }); + const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); + await this.robustClick(filterToggle); + await this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' }).click(); + await this.nameFilterInput.waitFor({ state: 'visible' }); + await this.nameFilterInput.fill(name); + } + + getCell(resourceName: string, cellName = 'name'): Locator { + return this.page.getByTestId(`data-view-cell-${resourceName}-${cellName}`); + } + + async clickRowByName(resourceName: string): Promise { + const link = this.getCell(resourceName).locator('a').first(); + await this.robustClick(link); + } + + async filterByCheckbox(filterName: string, checkboxLabel: string): Promise { + await this.dataViewFilters.waitFor({ state: 'visible', timeout: 60_000 }); + const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); + await this.robustClick(filterToggle); + 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); + } + + async selectProject(projectName: string): Promise { + const dropdownButton = this.namespaceDropdown.getByRole('button'); + await this.robustClick(dropdownButton); + const searchInput = this.page.getByRole('searchbox', { name: 'Select project...' }); + await searchInput.fill(projectName); + const item = this.page.getByRole('menuitem', { name: projectName, exact: true }); + await this.robustClick(item); + await this.waitForLoadingComplete(); + } +} diff --git a/frontend/e2e/pages/role-binding-page.ts b/frontend/e2e/pages/role-binding-page.ts new file mode 100644 index 00000000000..49ed77bd309 --- /dev/null +++ b/frontend/e2e/pages/role-binding-page.ts @@ -0,0 +1,48 @@ +import BasePage from './base-page'; + +export class RoleBindingPage extends BasePage { + private readonly nameInput = this.page.getByTestId('role-binding-name'); + private readonly namespaceDropdown = this.page.getByTestId('namespace-dropdown'); + private readonly roleDropdown = this.page.getByTestId('role-dropdown'); + private readonly subjectNameInput = this.page.getByTestId('subject-name'); + private readonly saveChangesButton = this.page.getByTestId('save-changes'); + async fillName(name: string): Promise { + await this.nameInput.fill(name); + } + + private async fillSearchInput(text: string): Promise { + const input = this.page.getByTestId('console-select-search-input').locator('input'); + await input.waitFor({ state: 'visible' }); + await input.fill(text); + } + + async selectNamespace(namespace: string): Promise { + await this.robustClick(this.namespaceDropdown); + await this.fillSearchInput(namespace); + const menuList = this.page.getByTestId('console-select-menu-list'); + await this.robustClick(menuList.getByText(namespace, { exact: true }).first()); + } + + async selectRole(role: string): Promise { + await this.robustClick(this.roleDropdown); + await this.fillSearchInput(role); + const menuList = this.page.getByTestId('console-select-menu-list'); + await this.robustClick(menuList.getByText(role, { exact: true }).first()); + } + + async fillSubjectName(subject: string): Promise { + await this.subjectNameInput.fill(subject); + } + + async selectClusterRoleBinding(): Promise { + const radio = this.page.getByTestId( + 'Cluster-wide role binding (ClusterRoleBinding)-radio-input', + ); + await this.robustClick(radio); + } + + async save(): Promise { + await this.robustClick(this.saveChangesButton); + await this.page.getByTestId('loading-indicator').waitFor({ state: 'detached' }).catch(() => {}); + } +} diff --git a/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts b/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts index 03442b17e99..3324acadaf7 100644 --- a/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts +++ b/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts @@ -1,6 +1,7 @@ import { Page, expect } from '@playwright/test'; import jsYaml from 'js-yaml'; import KubernetesClient from '../../../clients/kubernetes-client'; +import { getEditorContent, setEditorContent } from '../../../pages/base-page'; const CRD_LIST_URL = '/k8s/cluster/apiextensions.k8s.io~v1~CustomResourceDefinition'; @@ -53,20 +54,14 @@ export async function waitForYamlEditor(page: Page): Promise { * Get the current content from the Monaco YAML editor */ export async function getYamlEditorContent(page: Page): Promise { - return page.evaluate(() => { - const monacoEditor = (window as any).monaco?.editor?.getModels()?.[0]; - return monacoEditor?.getValue() || ''; - }); + return getEditorContent(page); } /** * Set content in the Monaco YAML editor */ export async function setYamlEditorContent(page: Page, yaml: string): Promise { - await page.evaluate((yamlContent) => { - const monacoEditor = (window as any).monaco?.editor?.getModels()?.[0]; - monacoEditor?.setValue(yamlContent); - }, yaml); + await setEditorContent(page, yaml); } /** diff --git a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts new file mode 100644 index 00000000000..111687ee04c --- /dev/null +++ b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts @@ -0,0 +1,92 @@ +import yaml from 'js-yaml'; + +import { test, expect } from '../../../fixtures'; +import { getEditorContent, setEditorContent } from '../../../pages/base-page'; +import { DetailsPage } from '../../../pages/details-page'; + +const workloadTypes = [ + 'replicationcontrollers', + 'daemonsets', + 'deployments', + 'replicasets', + 'statefulsets', + 'deploymentconfigs', +]; + +test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { + let namespace: string; + + test.beforeAll(async ({ k8sClient }) => { + namespace = `test-storage-${Date.now()}`; + await k8sClient.createNamespace(namespace); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace).catch(() => {}); + }); + + for (const resourceType of workloadTypes) { + test(`create ${resourceType} and add storage to it`, async ({ page }) => { + const pvcName = `${resourceType}-pvc`; + const pvcSize = '1'; + const mountPath = '/data'; + + await test.step(`Create ${resourceType} via YAML editor`, async () => { + if (resourceType === 'deployments' || resourceType === 'deploymentconfigs') { + const name = `${namespace}-${resourceType}`; + await page.goto(`/k8s/ns/${namespace}/${resourceType}/~new`); + + const yamlViewInput = page.getByTestId('yaml-view-input'); + if (await yamlViewInput.isVisible().catch(() => false)) { + await yamlViewInput.click(); + } + + await page.getByTestId('resource-sidebar').waitFor({ state: 'visible' }); + await page.getByTestId('code-editor').waitFor({ state: 'visible' }); + + const content = await getEditorContent(page); + const parsed = yaml.load(content) as Record; + parsed.metadata.name = name; + if (resourceType === 'deploymentconfigs') { + parsed.spec = parsed.spec || {}; + parsed.spec.selector = { app: name }; + parsed.spec.template = parsed.spec.template || {}; + parsed.spec.template.metadata = parsed.spec.template.metadata || {}; + parsed.spec.template.metadata.labels = { app: name }; + } + await setEditorContent(page, yaml.dump(parsed, { sortKeys: true })); + } else { + await page.goto(`/k8s/ns/${namespace}/${resourceType}/~new`); + await page.getByTestId('resource-sidebar').waitFor({ state: 'visible' }); + await page.getByTestId('code-editor').waitFor({ state: 'visible' }); + } + + await page.getByTestId('save-changes').click(); + await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + }); + + await test.step('Add storage via Actions dropdown', async () => { + const details = new DetailsPage(page); + await details.waitForPageLoad(); + await details.clickPageAction('Add storage'); + + await page.getByTestId('claim-name').waitFor({ state: 'visible' }); + await page.getByTestId('Create new claim-radio-input').click(); + await page.getByTestId('pvc-name').fill(pvcName); + await page.getByTestId('pvc-size').fill(pvcSize); + await page.getByTestId('mount-path').fill(mountPath); + await page.getByTestId('save-changes').click(); + await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + }); + + await test.step('Verify storage is attached', async () => { + await expect( + page.locator(`[data-test-volume-name-for="${pvcName}"]`), + ).toHaveText(pvcName); + await expect( + page.locator(`[data-test-mount-path-for="${pvcName}"]`), + ).toHaveText(mountPath); + }); + }); + } +}); diff --git a/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts new file mode 100644 index 00000000000..a96f27f9e93 --- /dev/null +++ b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '../../../fixtures'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; +import { Navigation } from '../../../pages/navigation'; + +test.describe('Image pull secret', { tag: ['@admin'] }, () => { + let namespace: string; + + test.beforeAll(async ({ k8sClient }) => { + namespace = `test-ips-${Date.now()}`; + await k8sClient.createNamespace(namespace); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteNamespace(namespace).catch(() => {}); + }); + + test('create image pull secret with whitespace-trimmed input values', async ({ page }) => { + const secretName = `test-image-pull-secret-${Date.now()}`; + const rsAddress = 'docker.io'; + const username = 'testUser51'; + const password = 'test1234'; + const email = 'testEmail@email.com'; + + const nav = new Navigation(page); + const listPage = new ListPage(page); + + await test.step('Navigate to Secrets and open Create Image Pull Secret form', async () => { + await nav.navigateToWorkloads('Secrets'); + await listPage.waitForListLoad(); + await listPage.selectProject(namespace); + await listPage.waitForListLoad(); + + await page.getByTestId('item-create').click(); + await page.getByRole('menuitem', { name: 'Image pull secret' }).click(); + + await expect(page.getByRole('heading', { level: 1 })).toContainText( + 'Create image pull secret', + ); + }); + + await test.step('Fill form with whitespace-padded values', async () => { + await page.getByTestId('secret-name').fill(secretName); + await page.getByTestId('image-secret-address').fill(` ${rsAddress} `); + await page.getByTestId('image-secret-username').fill(` ${username} `); + await page.getByTestId('image-secret-password').fill(` ${password} `); + await page.getByTestId('image-secret-email').fill(` ${email} `); + await page.getByTestId('image-secret-email').blur(); + }); + + await test.step('Verify whitespace is trimmed from all fields', async () => { + await expect(page.getByTestId('image-secret-address')).toHaveValue(rsAddress); + await expect(page.getByTestId('image-secret-username')).toHaveValue(username); + await expect(page.getByTestId('image-secret-password')).toHaveValue(password); + await expect(page.getByTestId('image-secret-email')).toHaveValue(email); + }); + + await test.step('Save and verify secret is created', async () => { + await page.getByTestId('save-changes').click(); + + const details = new DetailsPage(page); + await details.waitForPageLoad(); + await expect(details.getPageHeading()).toContainText(secretName); + }); + }); +}); diff --git a/frontend/e2e/tests/console/crud/other-routes.spec.ts b/frontend/e2e/tests/console/crud/other-routes.spec.ts new file mode 100644 index 00000000000..12d5dc092af --- /dev/null +++ b/frontend/e2e/tests/console/crud/other-routes.spec.ts @@ -0,0 +1,228 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../../fixtures'; +import { DetailsPage } from '../../../pages/details-page'; +import { testA11y } from '../../../utils/a11y'; + +type RouteConfig = { + path: string; + waitFor?: (page: Page) => Promise; +}; + +async function waitForListPage(page: Page): Promise { + await expect( + page.getByTestId('data-view-table').or(page.getByTestId('page-heading')).first(), + ).toBeVisible(); +} + +const routes: RouteConfig[] = [ + { + path: '/', + waitFor: async (page) => { + await expect(page.getByTestId('page-heading').locator('h1')).toBeAttached(); + for (const skeleton of await page.getByTestId('skeleton-chart').all()) { + await expect(skeleton).toBeHidden(); + } + }, + }, + { + path: '/k8s/cluster/clusterroles/view', + waitFor: async (page) => { + await expect(page.getByTestId('page-heading').locator('h1')).toBeAttached(); + }, + }, + { + path: '/k8s/cluster/nodes', + waitFor: waitForListPage, + }, + { + path: '/k8s/all-namespaces/events', + waitFor: async (page) => { + await expect(page.getByRole('row').first()).toBeVisible(); + }, + }, + { + path: '/k8s/all-namespaces/import', + waitFor: async (page) => { + await expect(page.getByRole('textbox')).toBeVisible(); + }, + }, + { + path: '/api-explorer', + waitFor: async (page) => { + await expect(page.getByTestId('data-view-table')).toBeVisible(); + }, + }, + { + path: '/api-resource/ns/default/core~v1~Pod', + waitFor: async (page) => { + const details = new DetailsPage(page); + await details.waitForPageLoad(); + }, + }, + { + path: '/api-resource/ns/default/core~v1~Pod/schema', + waitFor: async (page) => { + await expect(page.getByTestId('resource-sidebar-item').first()).toBeAttached(); + }, + }, + { + path: '/api-resource/ns/default/core~v1~Pod/instances', + waitFor: async (page) => { + await expect(page.getByTestId('api-explorer-resource-title')).toContainText('Pod'); + }, + }, + { + path: '/api-resource/ns/default/core~v1~Pod/access', + waitFor: async (page) => { + await expect( + page.locator('[data-ouia-component-type$="TableRow"]').first(), + ).toBeVisible(); + }, + }, + { + path: '/k8s/cluster/user.openshift.io~v1~User', + }, + { + path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~Machine', + waitFor: waitForListPage, + }, + { + path: '/k8s/cluster/machine.openshift.io~v1~ControlPlaneMachineSet', + waitFor: waitForListPage, + }, + { + path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~MachineSet', + waitFor: waitForListPage, + }, + { + path: '/k8s/ns/openshift-machine-api/autoscaling.openshift.io~v1beta1~MachineAutoscaler', + waitFor: async (page) => { + await expect(page.getByTestId('empty-box-body')).toBeVisible(); + }, + }, + { + path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~MachineHealthCheck', + waitFor: waitForListPage, + }, + { + path: '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfig', + waitFor: waitForListPage, + }, + { + path: '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfigPool', + waitFor: waitForListPage, + }, + { + path: '/k8s/all-namespaces/monitoring.coreos.com~v1~Alertmanager', + waitFor: waitForListPage, + }, + { + path: '/k8s/ns/openshift-monitoring/monitoring.coreos.com~v1~Alertmanager/main', + waitFor: async (page) => { + const details = new DetailsPage(page); + await details.waitForPageLoad(); + }, + }, + { + path: '/settings/cluster', + waitFor: async (page) => { + await expect(page.getByTestId('cluster-version')).toBeAttached(); + }, + }, + { + path: '/search/all-namespaces?kind=config.openshift.io~v1~Console', + waitFor: waitForListPage, + }, +]; + +test.describe('Visiting other routes', { tag: ['@admin', '@smoke'] }, () => { + for (const route of routes) { + test(`successfully displays view for route: ${route.path.replace(/\//g, ' ')}`, async ({ + page, + }) => { + await page.goto(route.path, { timeout: 90_000 }); + await expect(page).toHaveURL(new RegExp(route.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + await expect(page.getByTestId('loading-indicator')).not.toBeAttached(); + await expect(page.getByTestId('error-page')).not.toBeAttached(); + + if (route.waitFor) { + await route.waitFor(page); + } + + await testA11y(page, `route ${route.path.replace(/\//g, ' ')}`); + }); + } +}); + +test.describe('Perspective query parameters', { tag: ['@admin'] }, () => { + test('Developer query parameter switches to Developer perspective', async ({ + page, + k8sClient, + }) => { + await test.step('Ensure Developer perspective is available', async () => { + await page.goto('/k8s/cluster/projects'); + + const toggle = page.getByTestId('perspective-switcher-toggle'); + await toggle.waitFor({ state: 'visible' }); + + const isSinglePerspective = + (await toggle.getAttribute('id')) === 'core-platform-perspective'; + if (isSinglePerspective) { + await k8sClient.customObjectsApi.patchClusterCustomObject({ + group: 'operator.openshift.io', + version: 'v1', + plural: 'consoles', + name: 'cluster', + body: [ + { + op: 'add', + path: '/spec/customization/perspectives', + value: [{ id: 'dev', visibility: { state: 'Enabled' } }], + }, + ], + }); + await page.reload(); + await page.waitForLoadState('domcontentloaded'); + } + }); + + await test.step('Navigate with perspective=dev and verify', async () => { + await page.goto('/topology/all-namespaces?view=graph&perspective=dev'); + const toggleText = page + .getByTestId('perspective-switcher-toggle') + .locator('.pf-v6-c-menu-toggle__text'); + await expect(toggleText).toContainText('Developer'); + }); + }); + + test('Administrator query parameter switches to Administrator perspective', async ({ + page, + }) => { + await test.step('Switch to Developer perspective first', async () => { + await page.goto('/k8s/cluster/projects'); + + const toggle = page.getByTestId('perspective-switcher-toggle'); + await toggle.waitFor({ state: 'visible' }); + await toggle.click(); + + const devOption = page + .getByTestId('perspective-switcher-menu-option') + .filter({ hasText: 'Developer' }); + await devOption.waitFor({ state: 'visible' }); + await devOption.click(); + + const toggleText = page + .getByTestId('perspective-switcher-toggle') + .locator('.pf-v6-c-menu-toggle__text'); + await expect(toggleText).toContainText('Developer'); + }); + + await test.step('Navigate with perspective=admin and verify', async () => { + await page.goto('/dashboards?perspective=admin'); + const toggleText = page + .getByTestId('perspective-switcher-toggle') + .locator('.pf-v6-c-menu-toggle__text'); + await expect(toggleText).toContainText('Core platform'); + }); + }); +}); diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts new file mode 100644 index 00000000000..f9934d3d513 --- /dev/null +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -0,0 +1,157 @@ +import yaml from 'js-yaml'; + +import { test, expect } from '../../../fixtures'; +import { getEditorContent, setEditorContent } from '../../../pages/base-page'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; +import { Navigation } from '../../../pages/navigation'; + +const quotaName = 'example-resource-quota'; +const clusterQuotaName = 'example-cluster-resource-quota'; + +test.describe('Quotas', { tag: ['@admin'] }, () => { + let namespace: string; + + test.beforeAll(async ({ k8sClient }) => { + namespace = `test-quotas-${Date.now()}`; + await k8sClient.createNamespace(namespace); + }); + + test.afterAll(async ({ k8sClient }) => { + try { + await k8sClient.deleteClusterCustomResource( + 'quota.openshift.io', + 'v1', + 'clusterresourcequotas', + clusterQuotaName, + ); + } catch { + // may already be deleted + } + try { + await k8sClient.deleteNamespace(namespace); + } catch { + // may already be deleted + } + }); + + test('create ResourceQuota and ClusterResourceQuota via YAML editor', async ({ page }) => { + const nav = new Navigation(page); + const listPage = new ListPage(page); + + await test.step('Create ResourceQuota via YAML editor', async () => { + await nav.navigateToAdministration('ResourceQuotas'); + await listPage.selectProject(namespace); + await listPage.waitForListLoad(); + + await page.getByTestId('item-create').click(); + + await page.getByTestId('save-changes').waitFor({ state: 'visible' }); + const content = await getEditorContent(page); + const parsed = yaml.load(content) as Record; + parsed.metadata.name = quotaName; + parsed.metadata.namespace = namespace; + await setEditorContent(page, yaml.dump(parsed)); + + await page.getByTestId('save-changes').click(); + await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + + const details = new DetailsPage(page); + await details.waitForPageLoad(); + }); + + await test.step('Navigate back to list', async () => { + await page.locator('[data-test-id="breadcrumb-link-0"]').click(); + const listPage2 = new ListPage(page); + await listPage2.waitForListLoad(); + }); + + await test.step('Create ClusterResourceQuota via YAML editor', async () => { + await page.getByTestId('item-create').click(); + + await page.getByTestId('save-changes').waitFor({ state: 'visible' }); + const crqYaml = { + apiVersion: 'quota.openshift.io/v1', + kind: 'ClusterResourceQuota', + metadata: { name: clusterQuotaName }, + spec: { + quota: { hard: { pods: '10', secrets: '10' } }, + selector: { + labels: { + matchLabels: { 'kubernetes.io/metadata.name': namespace }, + }, + }, + }, + }; + await setEditorContent(page, yaml.dump(crqYaml)); + + await page.getByTestId('save-changes').click(); + await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + + const details = new DetailsPage(page); + await details.waitForPageLoad(); + }); + }); + + test('All Projects shows ResourceQuotas', async ({ page }) => { + const nav = new Navigation(page); + const listPage = new ListPage(page); + + await nav.navigateToAdministration('ResourceQuotas'); + await listPage.selectProject('All Projects'); + await listPage.waitForListLoad(); + await listPage.filterByName(quotaName); + await expect(listPage.getCell(quotaName)).toBeVisible(); + }); + + test('All Projects shows ClusterResourceQuotas', async ({ page }) => { + const nav = new Navigation(page); + const listPage = new ListPage(page); + const details = new DetailsPage(page); + + await nav.navigateToAdministration('ResourceQuotas'); + await listPage.selectProject('All Projects'); + await listPage.waitForListLoad(); + await listPage.filterByName(clusterQuotaName); + await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); + + await test.step('Verify breadcrumb', async () => { + await listPage.clickRowByName(clusterQuotaName); + await details.waitForPageLoad(); + await expect( + page.locator('[data-test-id="breadcrumb-link-0"]'), + ).toContainText('ClusterResourceQuota'); + }); + }); + + test('project namespace shows ResourceQuotas', async ({ page }) => { + const nav = new Navigation(page); + const listPage = new ListPage(page); + + await nav.navigateToAdministration('ResourceQuotas'); + await listPage.selectProject(namespace); + await listPage.waitForListLoad(); + await listPage.filterByName(quotaName); + await expect(listPage.getCell(quotaName)).toBeVisible(); + }); + + test('project namespace shows AppliedClusterResourceQuotas', async ({ page }) => { + const nav = new Navigation(page); + const listPage = new ListPage(page); + const details = new DetailsPage(page); + + await nav.navigateToAdministration('ResourceQuotas'); + await listPage.selectProject(namespace); + await listPage.waitForListLoad(); + await listPage.filterByName(clusterQuotaName); + await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); + + await test.step('Verify breadcrumb', async () => { + await listPage.clickRowByName(clusterQuotaName); + await details.waitForPageLoad(); + await expect( + page.locator('[data-test-id="breadcrumb-link-0"]'), + ).toContainText('AppliedClusterResourceQuota'); + }); + }); +}); diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts new file mode 100644 index 00000000000..a07447d6f68 --- /dev/null +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -0,0 +1,300 @@ +import yaml from 'js-yaml'; + +import { test, expect } from '../../../fixtures'; +import { getEditorContent, setEditorContent } from '../../../pages/base-page'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; +import { Navigation } from '../../../pages/navigation'; +import { RoleBindingPage } from '../../../pages/role-binding-page'; + +test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { + let namespace: string; + let roleName: string; + let clusterRoleName: string; + let roleBindingName: string; + let clusterRoleBindingName: string; + + test.beforeAll(async ({ k8sClient }) => { + const suffix = Date.now(); + namespace = `test-roles-${suffix}`; + roleName = `test-role-${suffix}`; + clusterRoleName = `test-clusterrole-${suffix}`; + roleBindingName = `test-rb-${suffix}`; + clusterRoleBindingName = `test-crb-${suffix}`; + await k8sClient.createNamespace(namespace); + }); + + test.afterAll(async ({ k8sClient }) => { + const deletions = [ + k8sClient + .deleteClusterCustomResource( + 'rbac.authorization.k8s.io', + 'v1', + 'clusterroles', + clusterRoleName, + ) + .catch(() => {}), + k8sClient + .deleteClusterCustomResource( + 'rbac.authorization.k8s.io', + 'v1', + 'clusterrolebindings', + clusterRoleBindingName, + ) + .catch(() => {}), + k8sClient.deleteNamespace(namespace).catch(() => {}), + ]; + await Promise.all(deletions); + }); + + test('create Role and ClusterRole via YAML editor', async ({ page }) => { + const nav = new Navigation(page); + const listPage = new ListPage(page); + + await test.step('Create Role via YAML editor', async () => { + await nav.navigateToUserManagement('Roles'); + await listPage.waitForListLoad(); + await listPage.selectProject(namespace); + await listPage.waitForListLoad(); + + await page.getByTestId('item-create').click(); + await page.getByTestId('resource-sidebar').waitFor({ state: 'visible' }); + await page.getByTestId('code-editor').waitFor({ state: 'visible' }); + + const content = await getEditorContent(page); + const parsed = yaml.load(content) as Record; + parsed.metadata.name = roleName; + await setEditorContent(page, yaml.dump(parsed)); + + await page.getByTestId('save-changes').click(); + await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + + const details = new DetailsPage(page); + await details.waitForPageLoad(); + }); + + await test.step('Navigate back to Roles list', async () => { + const details = new DetailsPage(page); + await details.getBreadcrumb(0).click(); + await listPage.waitForListLoad(); + }); + + await test.step('Create ClusterRole via YAML editor', async () => { + await page.getByTestId('item-create').click(); + await page.getByTestId('resource-sidebar').waitFor({ state: 'visible' }); + await page.getByTestId('code-editor').waitFor({ state: 'visible' }); + + const content = await getEditorContent(page); + const parsed = yaml.load(content) as Record; + parsed.kind = 'ClusterRole'; + parsed.metadata = { name: clusterRoleName }; + await setEditorContent(page, yaml.dump(parsed)); + + await page.getByTestId('save-changes').click(); + await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + + const details = new DetailsPage(page); + await details.waitForPageLoad(); + }); + }); + + test('create RoleBinding and ClusterRoleBinding via form', async ({ page }) => { + const nav = new Navigation(page); + + await test.step('Create RoleBinding', async () => { + await nav.navigateToUserManagement('RoleBindings'); + const listPage = new ListPage(page); + await listPage.waitForListLoad(); + + await page.getByTestId('item-create').click(); + await expect(page.getByTestId('title')).toHaveText('Create RoleBinding'); + + const rbPage = new RoleBindingPage(page); + await rbPage.fillName(roleBindingName); + await rbPage.selectNamespace(namespace); + await rbPage.selectRole('cluster-admin'); + await rbPage.fillSubjectName('subject-name'); + await rbPage.save(); + await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + + const details = new DetailsPage(page); + await details.waitForPageLoad(); + }); + + await test.step('Create ClusterRoleBinding', async () => { + await nav.navigateToUserManagement('RoleBindings'); + const listPage = new ListPage(page); + await listPage.waitForListLoad(); + + await page.getByTestId('item-create').click(); + await expect(page.getByTestId('title')).toHaveText('Create RoleBinding'); + + const rbPage = new RoleBindingPage(page); + await rbPage.selectClusterRoleBinding(); + await rbPage.fillName(clusterRoleBindingName); + await rbPage.selectRole('cluster-admin'); + await rbPage.fillSubjectName('subject-name'); + await rbPage.save(); + await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + + const details = new DetailsPage(page); + await details.waitForPageLoad(); + }); + }); + + test('displays Resource names and Verbs columns in Role rules table', async ({ page }) => { + const nav = new Navigation(page); + const listPage = new ListPage(page); + const details = new DetailsPage(page); + + await nav.navigateToUserManagement('Roles'); + await listPage.waitForListLoad(); + await listPage.selectProject(namespace); + await listPage.waitForListLoad(); + await listPage.filterByName(roleName); + await listPage.clickRowByName(roleName); + await details.waitForPageLoad(); + + await expect(page.locator('th', { hasText: 'Resource names' })).toBeVisible(); + await expect(page.locator('th', { hasText: 'Verbs' })).toBeVisible(); + await expect(page.locator('th', { hasText: 'Actions' })).not.toBeAttached(); + }); + + test('displays Resource names and Verbs columns in ClusterRole rules table', async ({ + page, + }) => { + const nav = new Navigation(page); + const listPage = new ListPage(page); + const details = new DetailsPage(page); + + await nav.navigateToUserManagement('Roles'); + await listPage.waitForListLoad(); + await listPage.selectProject('All Projects'); + await listPage.waitForListLoad(); + await listPage.filterByCheckbox('Role', 'cluster'); + await listPage.filterByName(clusterRoleName); + await listPage.clickRowByName(clusterRoleName); + await details.waitForPageLoad(); + + await expect(page.locator('th', { hasText: 'Resource names' })).toBeVisible(); + await expect(page.locator('th', { hasText: 'Verbs' })).toBeVisible(); + await expect(page.locator('th', { hasText: 'Actions' })).not.toBeAttached(); + }); + + for (const rolesOrBindings of ['Roles', 'RoleBindings'] as const) { + test(`${rolesOrBindings} detail breadcrumb to list restores All Projects`, async ({ + page, + }) => { + if (rolesOrBindings === 'RoleBindings') { + test.fixme(true, 'RoleBindings breadcrumb does not restore All Projects dropdown'); + } + const name = rolesOrBindings === 'Roles' ? roleName : roleBindingName; + const nav = new Navigation(page); + const listPage = new ListPage(page); + const details = new DetailsPage(page); + const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); + + await nav.navigateToUserManagement(rolesOrBindings); + await listPage.waitForListLoad(); + await listPage.selectProject('All Projects'); + await listPage.waitForListLoad(); + await listPage.filterByCheckbox( + rolesOrBindings === 'Roles' ? 'Role' : 'Kind', + 'namespace', + ); + await listPage.filterByName(name); + await listPage.clickRowByName(name); + await details.waitForPageLoad(); + + await expect(namespaceDropdown).toContainText(namespace); + + await details.getBreadcrumb(0).click(); + await listPage.waitForListLoad(); + await expect(namespaceDropdown).toContainText('All Projects'); + }); + + test(`${rolesOrBindings} detail breadcrumb to list restores last selected project`, async ({ + page, + }) => { + const name = rolesOrBindings === 'Roles' ? roleName : roleBindingName; + const nav = new Navigation(page); + const listPage = new ListPage(page); + const details = new DetailsPage(page); + const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); + + await nav.navigateToUserManagement(rolesOrBindings); + await listPage.waitForListLoad(); + await listPage.selectProject(namespace); + await listPage.waitForListLoad(); + await listPage.filterByCheckbox( + rolesOrBindings === 'Roles' ? 'Role' : 'Kind', + 'namespace', + ); + await listPage.filterByName(name); + await listPage.clickRowByName(name); + await details.waitForPageLoad(); + + await expect(namespaceDropdown).toContainText(namespace); + + await details.getBreadcrumb(0).click(); + await listPage.waitForListLoad(); + await expect(namespaceDropdown).toContainText(namespace); + }); + + test(`Cluster${rolesOrBindings} detail breadcrumb to list restores All Projects`, async ({ + page, + }) => { + const clusterName = rolesOrBindings === 'Roles' ? clusterRoleName : clusterRoleBindingName; + const nav = new Navigation(page); + const listPage = new ListPage(page); + const details = new DetailsPage(page); + const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); + + await nav.navigateToUserManagement(rolesOrBindings); + await listPage.waitForListLoad(); + await listPage.selectProject('All Projects'); + await listPage.waitForListLoad(); + await listPage.filterByCheckbox( + rolesOrBindings === 'Roles' ? 'Role' : 'Kind', + 'cluster', + ); + await listPage.filterByName(clusterName); + await listPage.clickRowByName(clusterName); + await details.waitForPageLoad(); + + await expect(namespaceDropdown).not.toBeAttached(); + + await details.getBreadcrumb(0).click(); + await listPage.waitForListLoad(); + await expect(namespaceDropdown).toContainText('All Projects'); + }); + + test(`Cluster${rolesOrBindings} detail breadcrumb to list restores last selected project`, async ({ + page, + }) => { + const clusterName = rolesOrBindings === 'Roles' ? clusterRoleName : clusterRoleBindingName; + const nav = new Navigation(page); + const listPage = new ListPage(page); + const details = new DetailsPage(page); + const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); + + await nav.navigateToUserManagement(rolesOrBindings); + await listPage.waitForListLoad(); + await listPage.selectProject(namespace); + await listPage.waitForListLoad(); + await listPage.filterByCheckbox( + rolesOrBindings === 'Roles' ? 'Role' : 'Kind', + 'cluster', + ); + await listPage.filterByName(clusterName); + await listPage.clickRowByName(clusterName); + await details.waitForPageLoad(); + + await expect(namespaceDropdown).not.toBeAttached(); + + await details.getBreadcrumb(0).click(); + await listPage.waitForListLoad(); + await expect(namespaceDropdown).toContainText(namespace); + }); + } +}); diff --git a/frontend/e2e/utils/a11y.ts b/frontend/e2e/utils/a11y.ts new file mode 100644 index 00000000000..7ca6a023f72 --- /dev/null +++ b/frontend/e2e/utils/a11y.ts @@ -0,0 +1,49 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import AxeBuilder from '@axe-core/playwright'; +import type { Result } from 'axe-core'; + +const INCLUDED_IMPACTS = new Set(['serious', 'critical']); + +function formatViolations(violations: Result[], target: string): string { + const lines: string[] = [ + `${violations.length} accessibility violation${violations.length === 1 ? '' : 's'} ${violations.length === 1 ? 'was' : 'were'} detected for ${target}:`, + ]; + + violations.forEach((violation, index) => { + lines.push( + ` ${index + 1}. ${violation.impact} ${violation.id}`, + ` ${violation.description}`, + ` ${violation.help}`, + ` ${violation.helpUrl}`, + ` Tags: ${violation.tags.join(', ')}`, + ` ${violation.nodes.length === 1 ? 'Node' : 'Nodes'}:`, + ); + violation.nodes.forEach((node) => { + const parts = [` - ${node.failureSummary?.replace(/\n/g, '\n ') ?? ''}`]; + parts.push(` HTML: ${node.html}`); + if (node.target?.length) { + parts.push(` Target: ${node.target.join(' ')}`); + } + lines.push(parts.join('\n')); + }); + }); + + return lines.join('\n'); +} + +export async function testA11y(page: Page, target: string, selector?: string): Promise { + let builder = new AxeBuilder({ page }).disableRules('color-contrast'); + + if (selector) { + builder = builder.include(selector); + } + + const results = await builder.analyze(); + + const violations = results.violations.filter((v) => INCLUDED_IMPACTS.has(v.impact ?? '')); + + if (violations.length > 0) { + expect(violations, formatViolations(violations, target)).toHaveLength(0); + } +} diff --git a/frontend/package.json b/frontend/package.json index 2104e2a98ac..c85f433a17f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -231,6 +231,7 @@ "yup": "^1.7.1" }, "devDependencies": { + "@axe-core/playwright": "^4.10.2", "@babel/core": "^7.28.5", "@cypress/webpack-preprocessor": "^7.0.2", "@graphql-codegen/cli": "^1.15.1", diff --git a/frontend/packages/console-app/src/components/nav/NavHeader.tsx b/frontend/packages/console-app/src/components/nav/NavHeader.tsx index 444d7550abc..266fe771a2b 100644 --- a/frontend/packages/console-app/src/components/nav/NavHeader.tsx +++ b/frontend/packages/console-app/src/components/nav/NavHeader.tsx @@ -26,6 +26,7 @@ const PerspectiveDropdownItem: FC = ({ perspective return ( ) => { e.preventDefault(); @@ -93,6 +94,7 @@ const NavHeader: FC = ({ onPerspectiveSelected }) => { toggle={(toggleRef: Ref) => ( = ({ onPerspectiveSelected }) => { ) : ( -
+
<RhUiGearGroupFillIcon /> {t('Core platform')} diff --git a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx index ceb285e8d40..76c1d919d93 100644 --- a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx +++ b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuContent.tsx @@ -30,6 +30,7 @@ const GroupMenuContent: FC = ({ option, onClick }) => ( // Need to keep this in the same file to avoid circular dependency. const SubMenuContent: FC = ({ option, onClick }) => ( diff --git a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx index f143de95478..474f783969a 100644 --- a/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx +++ b/frontend/packages/console-shared/src/components/actions/menu/ActionMenuItem.tsx @@ -65,6 +65,7 @@ const ActionItem: FC = ({ isDisabled, className: classes, onClick: handleClick, + 'data-test': label, 'data-test-action': label, translate: 'no' as 'no', }; diff --git a/frontend/packages/console-shared/src/components/breadcrumbs/Breadcrumbs.tsx b/frontend/packages/console-shared/src/components/breadcrumbs/Breadcrumbs.tsx index fe1c362750b..6071ce846a3 100644 --- a/frontend/packages/console-shared/src/components/breadcrumbs/Breadcrumbs.tsx +++ b/frontend/packages/console-shared/src/components/breadcrumbs/Breadcrumbs.tsx @@ -23,6 +23,7 @@ export const Breadcrumbs: FC = ({ breadcrumbs }) => ( { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - resourceObjs.forEach((resourceType) => { - try { - cy.exec(`kubectl delete --cascade ${resourceType} example -n ${testName}`, { - failOnNonZeroExit: false, - }); - } catch (error) { - console.error(`Failed to delete ${resourceType} example:\n${error}`); - } - }); - cy.deleteProjectWithCLI(testName, 300000); // increase timeout to allow cascading deletes above to complete - }); - - resourceObjs.forEach((resourceType) => { - const pvcName = `${resourceType}-pvc`; - const pvcSize = '1'; - const mountPath = '/data'; - describe(resourceType, () => { - it(`create a ${resourceType} resource and adds storage to it`, () => { - if (resourceType === 'deployments' || resourceType === 'deploymentconfigs') { - const name = `${testName}-${resourceType}`; - cy.visit(`/k8s/ns/${testName}/${resourceType}`); - listPage.clickCreateYAMLbutton(); - cy.byTestID('yaml-view-input').click(); - // sidebar needs to be fully loaded, else it sometimes overlays the Create button - cy.byTestID('resource-sidebar').should('exist'); - yamlEditor.isLoaded(); - let newContent; - // get, update, and set yaml editor content. - yamlEditor.getEditorContent().then((content) => { - newContent = _.defaultsDeep( - {}, - { - metadata: { name }, - ...(resourceType === 'deploymentconfigs' - ? { - spec: { - selector: { app: name }, - template: { metadata: { labels: { app: name } } }, - }, - } - : {}), - }, - - safeLoad(content), - ); - yamlEditor.setEditorContent(safeDump(newContent, { sortKeys: true })).then(() => { - yamlEditor.clickSaveCreateButton(); - cy.byTestID('yaml-error').should('not.exist'); - }); - }); - } else { - listPage.createNamespacedResourceWithDefaultYAML(resourceType, testName); - } - cy.byTestID('yaml-error').should('not.exist'); - - detailsPage.clickPageActionFromDropdown('Add storage'); - cy.byTestID('claim-name').should('be.visible'); - cy.byTestID('Create new claim-radio-input').click(); - cy.byTestID('pvc-name').type(pvcName); - cy.byTestID('pvc-size').type(pvcSize); - cy.byTestID('mount-path').type(mountPath); - cy.get(submitButton).click(); - cy.byTestID('yaml-error').should('not.exist'); - cy.get(`[data-test-volume-name-for="${pvcName}"]`).should('have.text', pvcName); - cy.get(`[data-test-mount-path-for="${pvcName}"]`).should('have.text', mountPath); - }); - }); - }); -}); diff --git a/frontend/packages/integration-tests/tests/crud/image-pull-secret.cy.ts b/frontend/packages/integration-tests/tests/crud/image-pull-secret.cy.ts deleted file mode 100644 index c787c7d68ab..00000000000 --- a/frontend/packages/integration-tests/tests/crud/image-pull-secret.cy.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { checkErrors, testName } from '../../support'; -import { projectDropdown } from '../../views/common'; -import { listPage } from '../../views/list-page'; -import { nav } from '../../views/nav'; - -const clickCreateImagePullSecretDropdownButton = () => { - cy.byTestID('item-create') - .click() - .get('body') - .then(($body) => { - if ($body.find(`[data-test-dropdown-menu="image"]`).length) { - cy.get(`[data-test-dropdown-menu="image"]`).click(); - } - }); -}; - -const typeValue = (testId: string, inputValue: string) => { - cy.byTestID(testId).type(inputValue); -}; - -const populateImageSecretForm = ( - name: string, - address: string, - username: string, - password: string, - email: string, -) => { - cy.get('[data-test="page-heading"] h1').contains('Create image pull secret'); - cy.byTestID('secret-name').should('exist'); - typeValue('secret-name', name); - typeValue('image-secret-address', address); - typeValue('image-secret-username', username); - typeValue('image-secret-password', password); - cy.byTestID('image-secret-email').type(email).blur(); -}; - -const isWhitespaceRemoved = (testId: string, expectedValue: string) => { - cy.byTestID(testId).invoke('val').should('have.length', expectedValue.length); -}; - -describe('Create image pull secret', () => { - const secretName = `${testName}-image-pull-secret-test`; - const rsAddress = 'docker.io'; - const username = 'testUser51'; - const password = 'test1234'; - const email = 'testEmail@email.com'; - const padRsAddress = ' '.repeat(4) + rsAddress + ' '.repeat(4); - const padUsername = ' '.repeat(3) + username + ' '.repeat(3); - const padPassword = ' '.repeat(2) + password + ' '.repeat(2); - const padEmail = ' '.repeat(1) + email + ' '.repeat(1); - - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - }); - - beforeEach(() => { - nav.sidenav.clickNavLink(['Workloads', 'Secrets']); - listPage.titleShouldHaveText('Secrets'); - projectDropdown.selectProject(testName); - projectDropdown.shouldContain(testName); - clickCreateImagePullSecretDropdownButton(); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - cy.deleteProjectWithCLI(testName); - }); - - it(`Validate a image pull secret whose input values contained whitespace`, () => { - populateImageSecretForm(secretName, padRsAddress, padUsername, padPassword, padEmail); - isWhitespaceRemoved('image-secret-address', rsAddress); - isWhitespaceRemoved('image-secret-username', username); - isWhitespaceRemoved('image-secret-password', password); - isWhitespaceRemoved('image-secret-email', email); - cy.byTestID('save-changes').click(); - }); -}); diff --git a/frontend/packages/integration-tests/tests/crud/other-routes.cy.ts b/frontend/packages/integration-tests/tests/crud/other-routes.cy.ts deleted file mode 100644 index 7e6376c4426..00000000000 --- a/frontend/packages/integration-tests/tests/crud/other-routes.cy.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { checkDeveloperPerspective } from '@console/dev-console/integration-tests/support/pages/functions/checkDeveloperPerspective'; -import { checkErrors } from '../../support'; -import { detailsPage } from '../../views/details-page'; -import { listPage } from '../../views/list-page'; -import { nav } from '../../views/nav'; - -describe('Visiting other routes', () => { - before(() => { - cy.login(); - }); - - afterEach(() => { - checkErrors(); - }); - - const otherRoutes: { path: string; waitFor: () => void }[] = [ - { - path: '/', - waitFor: () => { - cy.get('[data-test="page-heading"] h1').should('exist'); - cy.byTestID('skeleton-chart').should('not.exist'); - }, - }, - { - path: '/k8s/cluster/clusterroles/view', - waitFor: () => cy.get('[data-test="page-heading"] h1').should('exist'), - }, - { - path: '/k8s/cluster/nodes', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - { - path: '/k8s/all-namespaces/events', - waitFor: () => cy.get('[role="row"]').should('be.visible'), - }, - { - path: '/k8s/all-namespaces/import', - waitFor: () => cy.get('textarea').should('be.visible'), - }, - { - path: '/api-explorer', - waitFor: () => cy.get('[data-test="data-view-table"]').should('be.visible'), - }, - { - path: '/api-resource/ns/default/core~v1~Pod', - waitFor: () => detailsPage.isLoaded(), - }, - { - path: '/api-resource/ns/default/core~v1~Pod/schema', - waitFor: () => cy.byTestID('resource-sidebar-item').should('be.visible'), - }, - { - path: '/api-resource/ns/default/core~v1~Pod/instances', - waitFor: () => cy.byLegacyTestID('api-explorer-resource-title').contains('Pod'), - }, - ...(Cypress.expose('openshift') === true - ? [ - { - path: '/api-resource/ns/default/core~v1~Pod/access', - waitFor: () => cy.get('[data-ouia-component-type$="TableRow"]').should('be.visible'), - }, - { - path: '/k8s/cluster/user.openshift.io~v1~User', - waitFor: () => {}, - }, - { - path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~Machine', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - { - path: '/k8s/cluster/machine.openshift.io~v1~ControlPlaneMachineSet', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - { - path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~MachineSet', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - { - path: - '/k8s/ns/openshift-machine-api/autoscaling.openshift.io~v1beta1~MachineAutoscaler', - waitFor: () => cy.byTestID('empty-box-body').should('be.visible'), - }, - { - path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~MachineHealthCheck', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - { - path: '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfig', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - { - path: '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfigPool', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - { - path: '/k8s/all-namespaces/monitoring.coreos.com~v1~Alertmanager', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - { - path: '/k8s/ns/openshift-monitoring/monitoring.coreos.com~v1~Alertmanager/main', - waitFor: () => { - detailsPage.isLoaded(); - cy.byTestID('label-list').should('be.visible'); - }, - }, - { - path: '/settings/cluster', - waitFor: () => cy.byLegacyTestID('cluster-version').should('exist'), - }, - { - // Test loading search page for a kind with no static model. - path: '/search/all-namespaces?kind=config.openshift.io~v1~Console', - waitFor: () => listPage.dvRows.shouldBeLoaded(), - }, - ] - : []), - ]; - otherRoutes.forEach((route) => { - it(`successfully displays view for route: ${route.path.replace(/\//g, ' ')}`, () => { - cy.visit(route.path); - cy.url().should('equal', Cypress.config().baseUrl + route.path); - cy.byTestID('loading-indicator').should('not.exist'); - cy.byLegacyTestID('error-page').should('not.exist'); - if (route.waitFor) { - route.waitFor(); - } - cy.testA11y(`route ${route.path.replace(/\//g, ' ')}`); - }); - }); -}); - -describe('Test perspective query parameters', () => { - before(() => { - cy.login(); - }); - - beforeEach(() => { - cy.initAdmin(); - cy.visit('/k8s/cluster/projects'); - listPage.dvRows.shouldBeLoaded(); - checkDeveloperPerspective(); - }); - - afterEach(() => { - checkErrors(); - }); - - it('tests Developer query parameter', () => { - cy.visit('/topology/all-namespaces', { - qs: { - view: 'graph', - perspective: 'dev', - }, - }); - nav.sidenav.switcher.shouldHaveText('Developer'); - }); - it('tests Administrator query parameter', () => { - nav.sidenav.switcher.changePerspectiveTo('Developer'); - nav.sidenav.switcher.shouldHaveText('Developer'); - cy.visit('/dashboards', { - qs: { - perspective: 'admin', - }, - }); - nav.sidenav.switcher.shouldHaveText('Core platform'); - }); -}); diff --git a/frontend/packages/integration-tests/tests/crud/quotas.cy.ts b/frontend/packages/integration-tests/tests/crud/quotas.cy.ts deleted file mode 100644 index e17dc3d3e81..00000000000 --- a/frontend/packages/integration-tests/tests/crud/quotas.cy.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { safeLoad, safeDump } from 'js-yaml'; -import * as _ from 'lodash'; -import { checkErrors, testName } from '../../support'; -import { projectDropdown } from '../../views/common'; -import { detailsPage } from '../../views/details-page'; -import { listPage } from '../../views/list-page'; -import { modal } from '../../views/modal'; -import { nav } from '../../views/nav'; -import * as yamlEditor from '../../views/yaml-editor'; - -const quotaName = 'example-resource-quota'; -const clusterQuotaName = 'example-cluster-resource-quota'; -const allProjectsDropdownLabel = 'All Projects'; - -const createExampleQuotas = () => { - cy.log('create quota instance'); - nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); - projectDropdown.selectProject(testName); - projectDropdown.shouldContain(testName); - listPage.clickCreateYAMLbutton(); - // sidebar needs to be fully loaded, else it sometimes overlays the Create button - cy.byTestID('resource-sidebar').should('exist'); - yamlEditor.isLoaded(); - let newContent; - yamlEditor.getEditorContent().then((content) => { - newContent = _.defaultsDeep({}, { metadata: { name: quotaName } }, safeLoad(content)); - yamlEditor.setEditorContent(safeDump(newContent)).then(() => { - yamlEditor.clickSaveCreateButton(); - cy.byTestID('yaml-error').should('not.exist'); - }); - }); - detailsPage.breadcrumb(0).click(); - - cy.log('create cluster quota instance'); - listPage.clickCreateYAMLbutton(); - cy.byTestID('resource-sidebar').should('exist'); - yamlEditor.isLoaded(); - yamlEditor.getEditorContent().then((content) => { - newContent = _.defaultsDeep( - {}, - { - kind: 'ClusterResourceQuota', - apiVersion: 'quota.openshift.io/v1', - metadata: { name: clusterQuotaName }, - spec: { - quota: { - hard: { - pods: '10', - secrets: '10', - }, - }, - selector: { - labels: { - matchLabels: { - 'kubernetes.io/metadata.name': testName, - }, - }, - }, - }, - }, - safeLoad(content), - ); - yamlEditor.setEditorContent(safeDump(newContent)).then(() => { - yamlEditor.clickSaveCreateButton(); - cy.byTestID('yaml-error').should('not.exist'); - }); - }); -}; - -const deleteClusterExamples = () => { - cy.log('delete ClusterResourceQuota instance'); - projectDropdown.selectProject(allProjectsDropdownLabel); - nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.byName(clusterQuotaName); - listPage.dvRows.clickRowByName(clusterQuotaName); - detailsPage.isLoaded(); - detailsPage.clickPageActionFromDropdown('Delete ClusterResourceQuota'); - modal.shouldBeOpened(); - modal.submit(); - modal.shouldBeClosed(); - detailsPage.isLoaded(); -}; - -describe('Quotas', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - createExampleQuotas(); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - deleteClusterExamples(); - cy.deleteProjectWithCLI(testName); - }); - - it(`'All Projects' shows ResourceQuotas`, () => { - nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); - projectDropdown.selectProject(allProjectsDropdownLabel); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.byName(quotaName); - listPage.dvRows.shouldExist(quotaName); - }); - - it(`'All Projects' shows ClusterResourceQuotas`, () => { - nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); - projectDropdown.selectProject(allProjectsDropdownLabel); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.byName(clusterQuotaName); - listPage.dvRows.shouldExist(clusterQuotaName); - listPage.dvRows.clickRowByName(clusterQuotaName); - detailsPage.isLoaded(); - detailsPage.breadcrumb(0).contains('ClusterResourceQuota'); - }); - - it(`Test namespace shows ResourceQuotas`, () => { - nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); - projectDropdown.selectProject(testName); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.byName(quotaName); - listPage.dvRows.shouldExist(quotaName); - }); - - it(`Test namespace shows AppliedClusterResourceQuotas`, () => { - nav.sidenav.clickNavLink(['Administration', 'ResourceQuotas']); - projectDropdown.selectProject(testName); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.byName(clusterQuotaName); - listPage.dvRows.shouldExist(clusterQuotaName); - listPage.dvRows.clickRowByName(clusterQuotaName); - detailsPage.isLoaded(); - detailsPage.breadcrumb(0).contains('AppliedClusterResourceQuota'); - }); -}); diff --git a/frontend/packages/integration-tests/tests/crud/roles-rolebindings.cy.ts b/frontend/packages/integration-tests/tests/crud/roles-rolebindings.cy.ts deleted file mode 100644 index a1d61b3c71c..00000000000 --- a/frontend/packages/integration-tests/tests/crud/roles-rolebindings.cy.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { safeLoad, safeDump } from 'js-yaml'; -import * as _ from 'lodash'; -import { checkErrors, testName } from '../../support'; -import { projectDropdown } from '../../views/common'; -import { detailsPage } from '../../views/details-page'; -import { listPage } from '../../views/list-page'; -import { modal } from '../../views/modal'; -import { nav } from '../../views/nav'; -import { roleBindings } from '../../views/rolebindings'; -import * as yamlEditor from '../../views/yaml-editor'; - -const roleName = 'example-role'; -const clusterRoleName = 'example-cluster-role'; -const roleBindingName = 'example-rolebinding'; -const clusterRoleBindingName = 'example-cluster-rolebinding'; - -const createExampleRoles = () => { - cy.log('create Role instance'); - nav.sidenav.clickNavLink(['User Management', 'Roles']); - listPage.titleShouldHaveText('Roles'); - listPage.dvRows.shouldBeLoaded(); - projectDropdown.selectProject(testName); - projectDropdown.shouldContain(testName); - listPage.clickCreateYAMLbutton(); - // sidebar needs to be fully loaded, else it sometimes overlays the Create button - cy.byTestID('resource-sidebar').should('exist'); - yamlEditor.isLoaded(); - let newContent; - yamlEditor.getEditorContent().then((content) => { - newContent = _.defaultsDeep({}, { metadata: { name: roleName } }, safeLoad(content)); - yamlEditor.setEditorContent(safeDump(newContent)).then(() => { - yamlEditor.clickSaveCreateButton(); - cy.byTestID('yaml-error').should('not.exist'); - }); - }); - detailsPage.breadcrumb(0).click(); - - cy.log('create ClusterRole instance'); - listPage.dvRows.shouldBeLoaded(); - listPage.clickCreateYAMLbutton(); - cy.byTestID('resource-sidebar').should('exist'); - yamlEditor.isLoaded(); - yamlEditor.getEditorContent().then((content) => { - newContent = _.defaultsDeep( - {}, - { kind: 'ClusterRole', metadata: { name: clusterRoleName } }, - safeLoad(content), - ); - yamlEditor.setEditorContent(safeDump(newContent)).then(() => { - yamlEditor.clickSaveCreateButton(); - cy.byTestID('yaml-error').should('not.exist'); - }); - }); - detailsPage.isLoaded(); -}; - -const createExampleRoleBindings = () => { - cy.log('create RoleBindings instance'); - nav.sidenav.clickNavLink(['User Management', 'RoleBindings']); - listPage.titleShouldHaveText('RoleBindings'); - listPage.dvRows.shouldBeLoaded(); - listPage.clickCreateYAMLbutton(); - roleBindings.titleShouldHaveText('Create RoleBinding'); - roleBindings.inputName(roleBindingName); - roleBindings.selectNamespace(testName); - roleBindings.selectRole('cluster-admin'); - roleBindings.inputSubject('subject-name'); - roleBindings.clickSaveChangesButton(); - cy.byTestID('yaml-error').should('not.exist'); - detailsPage.isLoaded(); - - cy.log('create ClusterRoleBindings instance'); - nav.sidenav.clickNavLink(['User Management', 'RoleBindings']); - listPage.titleShouldHaveText('RoleBindings'); - listPage.dvRows.shouldBeLoaded(); - listPage.clickCreateYAMLbutton(); - roleBindings.titleShouldHaveText('Create RoleBinding'); - cy.byTestID('Cluster-wide role binding (ClusterRoleBinding)-radio-input').click(); - roleBindings.inputName(clusterRoleBindingName); - roleBindings.selectRole('cluster-admin'); - roleBindings.inputSubject('subject-name'); - roleBindings.clickSaveChangesButton(); - cy.byTestID('yaml-error').should('not.exist'); - detailsPage.isLoaded(); - nav.sidenav.clickNavLink(['User Management', 'RoleBindings']); -}; - -const deleteClusterExamples = () => { - cy.log('delete ClusterRole instance'); - nav.sidenav.clickNavLink(['User Management', 'Roles']); - listPage.titleShouldHaveText('Roles'); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.by('Role', 'cluster'); - listPage.dvFilter.byName(clusterRoleName); - listPage.dvRows.clickKebabAction(clusterRoleName, 'Delete ClusterRole'); - modal.shouldBeOpened(); - modal.submit(); - modal.shouldBeClosed(); - detailsPage.isLoaded(); - cy.log('delete ClusterRoleBindings instance'); - nav.sidenav.clickNavLink(['User Management', 'RoleBindings']); - listPage.titleShouldHaveText('RoleBindings'); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.by('Kind', 'cluster'); - listPage.dvFilter.byName(clusterRoleBindingName); - listPage.dvRows.clickKebabAction(clusterRoleBindingName, 'Delete ClusterRoleBinding'); - modal.shouldBeOpened(); - modal.submit(); - modal.shouldBeClosed(); - detailsPage.isLoaded(); -}; - -describe('Roles and RoleBindings', () => { - before(() => { - cy.login(); - cy.createProjectWithCLI(testName); - createExampleRoles(); - createExampleRoleBindings(); - }); - - afterEach(() => { - checkErrors(); - }); - - after(() => { - deleteClusterExamples(); - cy.deleteProjectWithCLI(testName); - }); - - it('displays Resource names column in Role rules table', () => { - nav.sidenav.clickNavLink(['User Management', 'Roles']); - listPage.dvRows.shouldBeLoaded(); - projectDropdown.selectProject(testName); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.byName(roleName); - listPage.dvRows.clickRowByName(roleName); - detailsPage.isLoaded(); - - cy.contains('th', 'Resource names').should('exist'); - - cy.contains('th', 'Verbs').should('exist'); - cy.contains('th', 'Actions').should('not.exist'); - }); - - it('displays Resource names column in ClusterRole rules table', () => { - nav.sidenav.clickNavLink(['User Management', 'Roles']); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.by('Role', 'cluster'); - listPage.dvFilter.byName(clusterRoleName); - listPage.dvRows.clickRowByName(clusterRoleName); - detailsPage.isLoaded(); - - cy.contains('th', 'Resource names').should('exist'); - cy.contains('th', 'Verbs').should('exist'); - cy.contains('th', 'Actions').should('not.exist'); - }); - - const allProjectsDropdownLabel = 'All Projects'; - ['Roles', 'RoleBindings'].forEach((rolesOrBindings) => { - const roleOrBindingName = rolesOrBindings === 'Roles' ? roleName : roleBindingName; - const clusterRoleOrBindingName = - rolesOrBindings === 'Roles' ? clusterRoleName : clusterRoleBindingName; - - it(`test ${rolesOrBindings} detail page breadcrumbs to list page restores 'All Projects' dropdown`, () => { - nav.sidenav.clickNavLink(['User Management', rolesOrBindings]); - projectDropdown.selectProject(allProjectsDropdownLabel); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.by(rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'namespace'); - listPage.dvFilter.byName(roleOrBindingName); - listPage.dvRows.clickRowByName(roleOrBindingName); - detailsPage.isLoaded(); - projectDropdown.shouldContain(testName); - detailsPage.breadcrumb(0).contains(rolesOrBindings).click(); - listPage.dvRows.shouldBeLoaded(); - projectDropdown.shouldContain(allProjectsDropdownLabel); - }); - - it(`test ${rolesOrBindings} detail page breadcrumbs to list page restores last selected project`, () => { - nav.sidenav.clickNavLink(['User Management', rolesOrBindings]); - projectDropdown.selectProject(testName); - projectDropdown.shouldContain(testName); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.by(rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'namespace'); - listPage.dvFilter.byName(roleOrBindingName); - listPage.dvRows.clickRowByName(roleOrBindingName); - detailsPage.isLoaded(); - projectDropdown.shouldContain(testName); - detailsPage.breadcrumb(0).contains(rolesOrBindings).click(); - listPage.dvRows.shouldBeLoaded(); - projectDropdown.shouldContain(testName); - }); - - it(`test Cluster${rolesOrBindings} detail page breadcrumbs to list page restores 'All Projects' dropdown`, () => { - nav.sidenav.clickNavLink(['User Management', rolesOrBindings]); - projectDropdown.selectProject(allProjectsDropdownLabel); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.by(rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'cluster'); - listPage.dvFilter.byName(clusterRoleOrBindingName); - listPage.dvRows.clickRowByName(clusterRoleOrBindingName); - detailsPage.isLoaded(); - projectDropdown.shouldNotExist(); - detailsPage.breadcrumb(0).contains(rolesOrBindings).click(); - listPage.dvRows.shouldBeLoaded(); - projectDropdown.shouldContain(allProjectsDropdownLabel); - }); - - it(`test Cluster${rolesOrBindings} detail page breadcrumbs to list page restores last selected project`, () => { - nav.sidenav.clickNavLink(['User Management', rolesOrBindings]); - listPage.dvRows.shouldBeLoaded(); - projectDropdown.selectProject(testName); - projectDropdown.shouldContain(testName); - listPage.dvRows.shouldBeLoaded(); - listPage.dvFilter.by(rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'cluster'); - listPage.dvFilter.byName(clusterRoleOrBindingName); - listPage.dvRows.clickRowByName(clusterRoleOrBindingName); - detailsPage.isLoaded(); - projectDropdown.shouldNotExist(); - detailsPage.breadcrumb(0).contains(rolesOrBindings).click(); - listPage.dvRows.shouldBeLoaded(); - projectDropdown.shouldContain(testName); - }); - }); -}); diff --git a/frontend/packages/integration-tests/views/rolebindings.ts b/frontend/packages/integration-tests/views/rolebindings.ts deleted file mode 100644 index d0dfaeb9ae6..00000000000 --- a/frontend/packages/integration-tests/views/rolebindings.ts +++ /dev/null @@ -1,27 +0,0 @@ -export const roleBindings = { - titleShouldHaveText: (title: string) => cy.byTestID('title').should('have.text', title), - inputName: (name: string) => cy.byTestID('role-binding-name').type(name), - selectNamespace: (namespace: string) => - cy - .byTestID('namespace-dropdown') - .click() - .byTestID('console-select-search-input') - .type(namespace) - .byTestID('console-select-menu-list') - .within(() => { - cy.get('.co-resource-item__resource-name').click(); - }), - selectRole: (role: string) => - cy - .byTestID('role-dropdown') - .click() - .byTestID('console-select-search-input') - .type(role) - .get('#cluster-admin-ClusterRole-link') - .click(), - inputSubject: (subject: string) => cy.byTestID('subject-name').type(subject), - clickSaveChangesButton: () => { - cy.byTestID('save-changes').should('be.visible').click(); - cy.byTestID('loading-indicator').should('not.exist'); - }, -}; diff --git a/frontend/public/components/api-explorer.tsx b/frontend/public/components/api-explorer.tsx index 1e0dc92e4b8..12450a336e6 100644 --- a/frontend/public/components/api-explorer.tsx +++ b/frontend/public/components/api-explorer.tsx @@ -1025,7 +1025,11 @@ const APIResourcePage_ = (props) => { {kindObj.label}
} + title={ +
+ {kindObj.label} +
+ } breadcrumbs={breadcrumbs} /> diff --git a/frontend/public/components/cluster-settings/cluster-settings.tsx b/frontend/public/components/cluster-settings/cluster-settings.tsx index 7046d3d0b81..f18d10d8704 100644 --- a/frontend/public/components/cluster-settings/cluster-settings.tsx +++ b/frontend/public/components/cluster-settings/cluster-settings.tsx @@ -200,7 +200,11 @@ const CurrentVersion: FC = ({ cv }) => { return desiredVersion ? ( <>
- + {desiredVersion}
@@ -217,7 +221,11 @@ const CurrentVersion: FC = ({ cv }) => { return lastVersion ? ( <>
- + {lastVersion}
diff --git a/frontend/public/components/error.tsx b/frontend/public/components/error.tsx index 040f6078424..2f6a286b124 100644 --- a/frontend/public/components/error.tsx +++ b/frontend/public/components/error.tsx @@ -28,6 +28,7 @@ export const ErrorPage404: FC = (props) => { <> {t('Page Not Found (404)')} = ({ id, index, trKey, style, className {...props} data-id={id} data-index={index} + data-test="resource-row" data-test-rows="resource-row" data-key={trKey} style={style} diff --git a/frontend/public/components/storage/attach-pvc-storage.tsx b/frontend/public/components/storage/attach-pvc-storage.tsx index 2e3d32074a7..586da71a4d2 100644 --- a/frontend/public/components/storage/attach-pvc-storage.tsx +++ b/frontend/public/components/storage/attach-pvc-storage.tsx @@ -421,6 +421,7 @@ const AttachStorageForm: FC = (props) => { type="submit" variant="primary" id="save-changes" + data-test="save-changes" isDisabled={showCreatePVC === 'existing' && !claimName} > {t('Save')} diff --git a/frontend/public/components/utils/actions-menu.tsx b/frontend/public/components/utils/actions-menu.tsx index 12e3726ec73..00140b779a4 100644 --- a/frontend/public/components/utils/actions-menu.tsx +++ b/frontend/public/components/utils/actions-menu.tsx @@ -63,6 +63,7 @@ const ActionsMenuDropdown: FC = ({ actions, title, act ref={toggleRef} onClick={() => setIsActive(!active)} isExpanded={active} + data-test="actions-menu-button" data-test-id="actions-menu-button" > {title || t('Actions')} diff --git a/frontend/public/components/utils/kebab.tsx b/frontend/public/components/utils/kebab.tsx index fe47439d900..8be8541a27f 100644 --- a/frontend/public/components/utils/kebab.tsx +++ b/frontend/public/components/utils/kebab.tsx @@ -70,6 +70,7 @@ const KebabItem_: FC = ({ onClick={(e) => !isDisabled && onClick(e, option)} autoFocus={autoFocus} isDisabled={isDisabled} + data-test={option.labelKey ? t(option.labelKey, option.labelKind) : option.label} data-test-action={option.labelKey ? t(option.labelKey, option.labelKind) : option.label} icon={option.icon} > diff --git a/frontend/yarn.lock b/frontend/yarn.lock index e7557e439ab..147296101dc 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -32,6 +32,17 @@ __metadata: languageName: node linkType: hard +"@axe-core/playwright@npm:^4.10.2": + version: 4.11.3 + resolution: "@axe-core/playwright@npm:4.11.3" + dependencies: + axe-core: "npm:~4.11.4" + peerDependencies: + playwright-core: ">= 1.0.0" + checksum: 10c0/da1854726dbc461a71ac25e0435f5dd9b7d143dc9142f53b1aeb4a8d7edcb4533eddb59949e8a07c4f4e3dce85ae43b7f249b3801e8b255f605fc974b94616fe + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.16.7, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.28.6, @babel/code-frame@npm:^7.29.0": version: 7.29.0 resolution: "@babel/code-frame@npm:7.29.0" @@ -6954,10 +6965,10 @@ __metadata: languageName: node linkType: hard -"axe-core@npm:^4.10.0, axe-core@npm:^4.10.2": - version: 4.11.3 - resolution: "axe-core@npm:4.11.3" - checksum: 10c0/bc757775ef41396faf6470752a12e96f3972d0d97cae4ec28e99cec7bca2c5aaa6d040b97e7f0278e8d1ea354fa0b0bf7fcaa51775a725d7ed0a0834e7ea13d7 +"axe-core@npm:^4.10.0, axe-core@npm:^4.10.2, axe-core@npm:~4.11.4": + version: 4.11.4 + resolution: "axe-core@npm:4.11.4" + checksum: 10c0/c4aa83fc3eac5f7a0d0cb1a28f9d073acf0c06ce8daacc38608faa278c57ce084c028c850746b98817ae4c101c30c1a32e95ea34748c4b4c7419b9b81221ef84 languageName: node linkType: hard @@ -18096,6 +18107,7 @@ __metadata: version: 0.0.0-use.local resolution: "openshift-console@workspace:." dependencies: + "@axe-core/playwright": "npm:^4.10.2" "@babel/core": "npm:^7.28.5" "@cypress/webpack-preprocessor": "npm:^7.0.2" "@graphql-codegen/cli": "npm:^1.15.1" From 1fcdaf6116a6808c7196086092b24fcff7846c20 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Fri, 5 Jun 2026 13:24:53 -0400 Subject: [PATCH 03/19] CONSOLE-5278: Fix CI failures and address CodeRabbit feedback CI fixes: - quotas.spec.ts: use waitForTableLoad instead of waitForListLoad before filterByName to ensure DataView is loaded - quotas.spec.ts: use selectAllProjects() instead of selectProject('All Projects') which fails in search filter - ListPage: add waitForTableLoad, selectAllProjects methods - ListPage.filterByName: check if filter input is already visible before clicking the DataViewFilters dropdown toggle CodeRabbit feedback: - DetailsPage.clickPageAction: fall back to data-test-action when data-test is absent for legacy action menu items - kebab.tsx: only set data-test when option.label is a string to avoid [object Object] values from ReactNode labels Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/details-page.ts | 6 ++++- frontend/e2e/pages/list-page.ts | 23 +++++++++++++++++-- .../e2e/tests/console/crud/quotas.spec.ts | 12 +++++----- frontend/public/components/utils/kebab.tsx | 8 ++++++- 4 files changed, 39 insertions(+), 10 deletions(-) diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index 99371ff0d35..188e9e82322 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -14,7 +14,11 @@ export class DetailsPage extends BasePage { async clickPageAction(actionName: string): Promise { await this.robustClick(this.page.getByTestId('actions-menu-button')); - await this.robustClick(this.page.getByTestId(actionName)); + const action = this.page + .getByTestId(actionName) + .or(this.page.locator(`[data-test-action="${actionName}"]`)) + .first(); + await this.robustClick(action); } getBreadcrumb(index: number): Locator { diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index b1b6608403e..e56bce6ccda 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -11,18 +11,29 @@ export class ListPage extends BasePage { private readonly namespaceDropdown = this.page.getByTestId('namespace-bar-dropdown'); async waitForListLoad(): Promise { + await this.waitForLoadingComplete(); await this.dataViewTable.or(this.page.getByTestId('page-heading')).first().waitFor({ state: 'visible', }); } + async waitForTableLoad(): Promise { + await this.waitForLoadingComplete(); + await this.dataViewTable.waitFor({ state: 'visible', timeout: 60_000 }); + } + async filterByName(name: string): Promise { + const filterInput = this.nameFilterInput; + if (await filterInput.isVisible({ timeout: 3_000 }).catch(() => false)) { + await filterInput.fill(name); + return; + } await this.dataViewFilters.waitFor({ state: 'visible', timeout: 60_000 }); const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); await this.robustClick(filterToggle); await this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' }).click(); - await this.nameFilterInput.waitFor({ state: 'visible' }); - await this.nameFilterInput.fill(name); + await filterInput.waitFor({ state: 'visible' }); + await filterInput.fill(name); } getCell(resourceName: string, cellName = 'name'): Locator { @@ -59,4 +70,12 @@ export class ListPage extends BasePage { await this.robustClick(item); await this.waitForLoadingComplete(); } + + 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); + await this.waitForLoadingComplete(); + } } diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index f9934d3d513..a7457b70d17 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -98,8 +98,8 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { const listPage = new ListPage(page); await nav.navigateToAdministration('ResourceQuotas'); - await listPage.selectProject('All Projects'); - await listPage.waitForListLoad(); + await listPage.selectAllProjects(); + await listPage.waitForTableLoad(); await listPage.filterByName(quotaName); await expect(listPage.getCell(quotaName)).toBeVisible(); }); @@ -110,8 +110,8 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { const details = new DetailsPage(page); await nav.navigateToAdministration('ResourceQuotas'); - await listPage.selectProject('All Projects'); - await listPage.waitForListLoad(); + await listPage.selectAllProjects(); + await listPage.waitForTableLoad(); await listPage.filterByName(clusterQuotaName); await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); @@ -130,7 +130,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await nav.navigateToAdministration('ResourceQuotas'); await listPage.selectProject(namespace); - await listPage.waitForListLoad(); + await listPage.waitForTableLoad(); await listPage.filterByName(quotaName); await expect(listPage.getCell(quotaName)).toBeVisible(); }); @@ -142,7 +142,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await nav.navigateToAdministration('ResourceQuotas'); await listPage.selectProject(namespace); - await listPage.waitForListLoad(); + await listPage.waitForTableLoad(); await listPage.filterByName(clusterQuotaName); await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); diff --git a/frontend/public/components/utils/kebab.tsx b/frontend/public/components/utils/kebab.tsx index 8be8541a27f..63c0b00d060 100644 --- a/frontend/public/components/utils/kebab.tsx +++ b/frontend/public/components/utils/kebab.tsx @@ -70,7 +70,13 @@ const KebabItem_: FC = ({ onClick={(e) => !isDisabled && onClick(e, option)} autoFocus={autoFocus} isDisabled={isDisabled} - data-test={option.labelKey ? t(option.labelKey, option.labelKind) : option.label} + data-test={ + option.labelKey + ? t(option.labelKey, option.labelKind) + : typeof option.label === 'string' + ? option.label + : undefined + } data-test-action={option.labelKey ? t(option.labelKey, option.labelKind) : option.label} icon={option.icon} > From c32a9f8d6d81df970ee2f8f4b7badf5096bb6915 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 8 Jun 2026 09:36:05 -0400 Subject: [PATCH 04/19] CONSOLE-5278: Fix quota tests - serial mode and unique names Add serial mode to ensure all tests run in the same worker sharing the beforeAll namespace. Use unique resource names with Date.now() suffix to prevent strict mode violations from leftover resources of prior test runs. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/tests/console/crud/quotas.spec.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index a7457b70d17..ec111795b0a 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -6,10 +6,12 @@ import { DetailsPage } from '../../../pages/details-page'; import { ListPage } from '../../../pages/list-page'; import { Navigation } from '../../../pages/navigation'; -const quotaName = 'example-resource-quota'; -const clusterQuotaName = 'example-cluster-resource-quota'; +const testId = Date.now(); +const quotaName = `test-resource-quota-${testId}`; +const clusterQuotaName = `test-cluster-resource-quota-${testId}`; test.describe('Quotas', { tag: ['@admin'] }, () => { + test.describe.configure({ mode: 'serial' }); let namespace: string; test.beforeAll(async ({ k8sClient }) => { From e64c3d0ac82e4c5be4034b392b01b9b63f95b91c Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 8 Jun 2026 09:52:14 -0400 Subject: [PATCH 05/19] CONSOLE-5278: Fix search page accordion toggle missing accessible name Use kindForReference as fallback text when the model hasn't loaded yet, ensuring the accordion toggle button always has discernible text. Co-Authored-By: Claude Opus 4.6 --- frontend/public/components/search.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/public/components/search.tsx b/frontend/public/components/search.tsx index 90b2391618c..45f4d92d708 100644 --- a/frontend/public/components/search.tsx +++ b/frontend/public/components/search.tsx @@ -228,7 +228,7 @@ const SearchPage_: FC = (props) => { const model = modelFor(item); // API discovery happens asynchronously. Avoid runtime errors if the model hasn't loaded. if (!model) { - return ''; + return kindForReference(item); } const { labelPlural, labelPluralKey, apiVersion, apiGroup } = model; return ( From 3f2195a0ac612ecc09104844bca26aac7add7790 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 8 Jun 2026 13:06:53 -0400 Subject: [PATCH 06/19] CONSOLE-5278: Address migration review findings - Import expect from e2e/fixtures instead of @playwright/test in a11y.ts - Use DetailsPage.getBreadcrumb() instead of raw locators in quotas.spec.ts - Use listPage.selectAllProjects() consistently in roles-rolebindings.spec.ts - Add serial mode to roles-rolebindings.spec.ts for CI stability - Add data-test attributes to volumes-table.tsx and use getByTestId in spec Co-Authored-By: Claude Opus 4.6 --- .../e2e/tests/console/crud/add-storage-crud.spec.ts | 8 ++------ frontend/e2e/tests/console/crud/quotas.spec.ts | 11 ++++------- .../e2e/tests/console/crud/roles-rolebindings.spec.ts | 7 ++++--- frontend/e2e/utils/a11y.ts | 3 ++- frontend/public/components/volumes-table.tsx | 2 ++ 5 files changed, 14 insertions(+), 17 deletions(-) diff --git a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts index 111687ee04c..12b7fedb5f5 100644 --- a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts +++ b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts @@ -80,12 +80,8 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { }); await test.step('Verify storage is attached', async () => { - await expect( - page.locator(`[data-test-volume-name-for="${pvcName}"]`), - ).toHaveText(pvcName); - await expect( - page.locator(`[data-test-mount-path-for="${pvcName}"]`), - ).toHaveText(mountPath); + await expect(page.getByTestId(`volume-name-${pvcName}`)).toHaveText(pvcName); + await expect(page.getByTestId(`mount-path-${pvcName}`)).toHaveText(mountPath); }); }); } diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index ec111795b0a..17f05cca06a 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -63,7 +63,8 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { }); await test.step('Navigate back to list', async () => { - await page.locator('[data-test-id="breadcrumb-link-0"]').click(); + const details2 = new DetailsPage(page); + await details2.getBreadcrumb(0).click(); const listPage2 = new ListPage(page); await listPage2.waitForListLoad(); }); @@ -120,9 +121,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await test.step('Verify breadcrumb', async () => { await listPage.clickRowByName(clusterQuotaName); await details.waitForPageLoad(); - await expect( - page.locator('[data-test-id="breadcrumb-link-0"]'), - ).toContainText('ClusterResourceQuota'); + await expect(details.getBreadcrumb(0)).toContainText('ClusterResourceQuota'); }); }); @@ -151,9 +150,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await test.step('Verify breadcrumb', async () => { await listPage.clickRowByName(clusterQuotaName); await details.waitForPageLoad(); - await expect( - page.locator('[data-test-id="breadcrumb-link-0"]'), - ).toContainText('AppliedClusterResourceQuota'); + await expect(details.getBreadcrumb(0)).toContainText('AppliedClusterResourceQuota'); }); }); }); diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts index a07447d6f68..9d7cf7fc751 100644 --- a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -8,6 +8,7 @@ import { Navigation } from '../../../pages/navigation'; import { RoleBindingPage } from '../../../pages/role-binding-page'; test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { + test.describe.configure({ mode: 'serial' }); let namespace: string; let roleName: string; let clusterRoleName: string; @@ -169,7 +170,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await nav.navigateToUserManagement('Roles'); await listPage.waitForListLoad(); - await listPage.selectProject('All Projects'); + await listPage.selectAllProjects(); await listPage.waitForListLoad(); await listPage.filterByCheckbox('Role', 'cluster'); await listPage.filterByName(clusterRoleName); @@ -196,7 +197,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await nav.navigateToUserManagement(rolesOrBindings); await listPage.waitForListLoad(); - await listPage.selectProject('All Projects'); + await listPage.selectAllProjects(); await listPage.waitForListLoad(); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', @@ -252,7 +253,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await nav.navigateToUserManagement(rolesOrBindings); await listPage.waitForListLoad(); - await listPage.selectProject('All Projects'); + await listPage.selectAllProjects(); await listPage.waitForListLoad(); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', diff --git a/frontend/e2e/utils/a11y.ts b/frontend/e2e/utils/a11y.ts index 7ca6a023f72..f2959ad4bd6 100644 --- a/frontend/e2e/utils/a11y.ts +++ b/frontend/e2e/utils/a11y.ts @@ -1,6 +1,7 @@ import type { Page } from '@playwright/test'; -import { expect } from '@playwright/test'; import AxeBuilder from '@axe-core/playwright'; + +import { expect } from '../fixtures'; import type { Result } from 'axe-core'; const INCLUDED_IMPACTS = new Set(['serious', 'critical']); diff --git a/frontend/public/components/volumes-table.tsx b/frontend/public/components/volumes-table.tsx index 68b70840222..f6671440b70 100644 --- a/frontend/public/components/volumes-table.tsx +++ b/frontend/public/components/volumes-table.tsx @@ -109,6 +109,7 @@ const VolumesTableRows = ({ componentProps: { data } }) => { title: name, props: { className: volumeRowColumnClasses[0], + 'data-test': `volume-name-${name}`, 'data-test-volume-name-for': name, }, }, @@ -116,6 +117,7 @@ const VolumesTableRows = ({ componentProps: { data } }) => { title: mountPath, props: { className: volumeRowColumnClasses[1], + 'data-test': `mount-path-${name}`, 'data-test-mount-path-for': name, }, }, From 7d5b3f51a3231b94c7b328aca4a6af85e1da03a5 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 8 Jun 2026 13:23:42 -0400 Subject: [PATCH 07/19] CONSOLE-5278: Reconcile shared e2e helpers with PR 16556 Pull in improvements from PR 16556 so this PR can merge first and 16556 can rebase cleanly without conflicts. - base-page: add waitForFunction guard to Monaco editor helpers - details-page: simplify clickPageAction, use getByTestId in clickKebabAction - list-page: add legacy locators/methods, getters, kebab actions, showSystemSwitch toggle, and waitForTableLoad legacy fallback - kubernetes-client: add createConfigMap, createSecret, mergePatchResource, annotateConfigMap, labelConfigMap - cleanup-fixture: align trackClusterCustomResource type default - crd-test-utils: fix expect import to use fixtures Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/clients/kubernetes-client.ts | 91 ++++++++++++++++++ frontend/e2e/fixtures/cleanup-fixture.ts | 2 +- frontend/e2e/pages/base-page.ts | 10 +- frontend/e2e/pages/details-page.ts | 8 +- frontend/e2e/pages/list-page.ts | 95 ++++++++++++++++--- .../console/crd-extensions/crd-test-utils.ts | 4 +- 6 files changed, 188 insertions(+), 22 deletions(-) diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index a07888156b6..f2d4c61a463 100644 --- a/frontend/e2e/clients/kubernetes-client.ts +++ b/frontend/e2e/clients/kubernetes-client.ts @@ -378,6 +378,97 @@ export default class KubernetesClient { } as any); } + async createConfigMap( + name: string, + namespace: string, + data: Record = {}, + ): Promise { + await this.k8sApi.createNamespacedConfigMap({ + namespace, + body: { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name, namespace }, data }, + }); + } + + async createSecret( + name: string, + namespace: string, + data: Record = {}, + ): Promise { + await this.k8sApi.createNamespacedSecret({ + namespace, + body: { apiVersion: 'v1', kind: 'Secret', metadata: { name, namespace }, data }, + }); + } + + async mergePatchResource(apiPath: string, patch: object): Promise { + const opts: https.RequestOptions = {}; + this.kubeConfig.applyToHTTPSOptions(opts); + const cluster = this.kubeConfig.getCurrentCluster(); + const url = new URL(apiPath, cluster?.server); + const body = JSON.stringify(patch); + const proxyUrl = KubernetesClient.getProxyUrl(); + const agent = proxyUrl ? KubernetesClient.createProxyAgent(proxyUrl) : undefined; + await new Promise((resolve, reject) => { + const req = https.request( + { + hostname: url.hostname, + port: url.port || 443, + path: url.pathname, + method: 'PATCH', + headers: { + 'Content-Type': 'application/merge-patch+json', + 'Content-Length': Buffer.byteLength(body), + ...opts.headers, + }, + rejectUnauthorized: false, + ca: opts.ca, + cert: opts.cert, + key: opts.key, + agent, + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve(); + } else { + reject(new Error(`Patch failed: ${res.statusCode} ${data}`)); + } + }); + }, + ); + req.setTimeout(30_000, () => { + req.destroy(new Error('mergePatchResource request timed out after 30s')); + }); + req.on('error', reject); + req.write(body); + req.end(); + }); + } + + async annotateConfigMap( + name: string, + namespace: string, + annotations: Record, + ): Promise { + await this.mergePatchResource( + `/api/v1/namespaces/${namespace}/configmaps/${name}`, + { metadata: { annotations } }, + ); + } + + async labelConfigMap( + name: string, + namespace: string, + labels: Record, + ): Promise { + await this.mergePatchResource( + `/api/v1/namespaces/${namespace}/configmaps/${name}`, + { metadata: { labels } }, + ); + } + async deleteConfigMap(name: string, namespace: string): Promise { try { await this.k8sApi.deleteNamespacedConfigMap({ name, namespace }); diff --git a/frontend/e2e/fixtures/cleanup-fixture.ts b/frontend/e2e/fixtures/cleanup-fixture.ts index e0493c17ad4..b7868982099 100644 --- a/frontend/e2e/fixtures/cleanup-fixture.ts +++ b/frontend/e2e/fixtures/cleanup-fixture.ts @@ -114,7 +114,7 @@ export function createCleanupFixture(testName: string): CleanupFixture { apiGroup, apiVersion, plural, - type: type || `Cluster:${plural}`, + type: type || plural, }); }, diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index edfa06e81c1..e3de7ebf3ff 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -1,14 +1,20 @@ import type { Locator, Page } from '@playwright/test'; export async function getEditorContent(page: Page): Promise { + await page.waitForFunction(() => (window as any).monaco?.editor?.getModels()?.[0], { + timeout: 10_000, + }); return page.evaluate(() => { - return (window as any).monaco?.editor?.getModels()?.[0]?.getValue() ?? ''; + return (window as any).monaco.editor.getModels()[0].getValue(); }); } export async function setEditorContent(page: Page, content: string): Promise { + await page.waitForFunction(() => (window as any).monaco?.editor?.getModels()?.[0], { + timeout: 10_000, + }); await page.evaluate((text) => { - (window as any).monaco?.editor?.getModels()?.[0]?.setValue(text); + (window as any).monaco.editor.getModels()[0].setValue(text); }, content); } diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index 188e9e82322..693817fb669 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -14,11 +14,7 @@ export class DetailsPage extends BasePage { async clickPageAction(actionName: string): Promise { await this.robustClick(this.page.getByTestId('actions-menu-button')); - const action = this.page - .getByTestId(actionName) - .or(this.page.locator(`[data-test-action="${actionName}"]`)) - .first(); - await this.robustClick(action); + await this.robustClick(this.page.getByTestId(actionName)); } getBreadcrumb(index: number): Locator { @@ -38,7 +34,7 @@ export class DetailsPage extends BasePage { * Click a kebab menu action (assumes menu is already open) */ async clickKebabAction(actionId: string): Promise { - const action = this.page.locator(`[data-test-action="${actionId}"]`); + const action = this.page.getByTestId(actionId); await this.robustClick(action); } diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index e56bce6ccda..d956bd51662 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -9,31 +9,36 @@ export class ListPage extends BasePage { '[data-ouia-component-id="DataViewFilters"]', ); private readonly namespaceDropdown = this.page.getByTestId('namespace-bar-dropdown'); + private readonly legacyResourceRows = this.page.locator('[data-test-rows="resource-row"]'); + private readonly legacyNameFilter = this.page.getByTestId('name-filter-input'); + private readonly createButton = this.page.getByTestId('item-create'); async waitForListLoad(): Promise { await this.waitForLoadingComplete(); - await this.dataViewTable.or(this.page.getByTestId('page-heading')).first().waitFor({ - state: 'visible', - }); + await this.page + .getByTestId('page-heading') + .waitFor({ state: 'visible', timeout: 60_000 }); } async waitForTableLoad(): Promise { await this.waitForLoadingComplete(); - await this.dataViewTable.waitFor({ state: 'visible', timeout: 60_000 }); + await this.dataViewTable + .or(this.legacyResourceRows.first()) + .first() + .waitFor({ state: 'visible', timeout: 60_000 }); } async filterByName(name: string): Promise { - const filterInput = this.nameFilterInput; - if (await filterInput.isVisible({ timeout: 3_000 }).catch(() => false)) { - await filterInput.fill(name); - return; - } - await this.dataViewFilters.waitFor({ state: 'visible', timeout: 60_000 }); const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); await this.robustClick(filterToggle); await this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' }).click(); - await filterInput.waitFor({ state: 'visible' }); - await filterInput.fill(name); + await this.nameFilterInput.waitFor({ state: 'visible' }); + await this.nameFilterInput.fill(name); + } + + async legacyFilterByName(name: string): Promise { + await this.legacyNameFilter.clear(); + await this.legacyNameFilter.fill(name); } getCell(resourceName: string, cellName = 'name'): Locator { @@ -45,6 +50,56 @@ export class ListPage extends BasePage { await this.robustClick(link); } + async legacyClickRowByName(resourceName: string): Promise { + const link = this.page.locator(`a[data-test-id="${resourceName}"]`); + await this.robustClick(link); + } + + getNamespaceDropdown(): Locator { + return this.namespaceDropdown; + } + + getDataViewTable(): Locator { + return this.dataViewTable; + } + + getLegacyResourceRows(): Locator { + return this.legacyResourceRows; + } + + getCreateButton(): Locator { + return this.createButton; + } + + async clickCreateYAMLButton(): Promise { + await this.robustClick(this.createButton); + } + + async clickCreateYAMLDropdownButton(): Promise { + await this.robustClick(this.createButton); + const yamlMenuItem = this.page.locator('[data-test-dropdown-menu="yaml"]'); + if ((await yamlMenuItem.count()) > 0) { + await this.robustClick(yamlMenuItem); + } + } + + async clickKebabAction(resourceName: string, actionName: string): Promise { + const cell = this.getCell(resourceName); + 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 legacyClickKebabAction(resourceName: string, actionName: string): Promise { + const row = this.legacyResourceRows + .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 { await this.dataViewFilters.waitFor({ state: 'visible', timeout: 60_000 }); const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); @@ -61,9 +116,25 @@ export class ListPage extends BasePage { await this.robustClick(checkboxFilter); } + async clickFirstLinkInFirstRow(): Promise { + const link = this.page.locator('[data-test^="data-view-cell-"]').first().locator('a').first(); + await this.robustClick(link); + } + + async getFirstCellText(): Promise { + const link = this.page.locator('[data-test^="data-view-cell-"]').first().locator('a').first(); + return (await link.textContent()) ?? ''; + } + async selectProject(projectName: string): Promise { const dropdownButton = this.namespaceDropdown.getByRole('button'); await this.robustClick(dropdownButton); + + const systemSwitch = this.page.getByTestId('showSystemSwitch'); + if ((await systemSwitch.count()) > 0 && !(await systemSwitch.isChecked())) { + await systemSwitch.check(); + } + const searchInput = this.page.getByRole('searchbox', { name: 'Select project...' }); await searchInput.fill(projectName); const item = this.page.getByRole('menuitem', { name: projectName, exact: true }); diff --git a/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts b/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts index 3324acadaf7..bf472758bba 100644 --- a/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts +++ b/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts @@ -1,4 +1,6 @@ -import { Page, expect } from '@playwright/test'; +import type { Page } from '@playwright/test'; + +import { expect } from '../../../fixtures'; import jsYaml from 'js-yaml'; import KubernetesClient from '../../../clients/kubernetes-client'; import { getEditorContent, setEditorContent } from '../../../pages/base-page'; From ceda7f82e2ac7a7e63e888611bb89e52ca082878 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 9 Jun 2026 09:57:28 -0400 Subject: [PATCH 08/19] CONSOLE-5278: Address PR review feedback for e2e test migration Remove redundant waitFor() calls before Playwright actions that auto-wait, remove unnecessary try/catch and .catch(() => {}) around k8sClient cleanup calls that already swallow 404s, replace legacy test attribute selectors with getByTestId(), rename page object methods to remove "legacy" prefix, add data-test attribute to SimpleDropdown items in list-page.tsx, and consolidate inline navigation sequences into page object methods. Update migration context and skill docs to prevent these patterns in future migrations. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/details-page.ts | 3 +- frontend/e2e/pages/list-page.ts | 36 +++++++++---------- frontend/e2e/pages/role-binding-page.ts | 7 ++-- .../console/crud/add-storage-crud.spec.ts | 18 +++++----- .../console/crud/image-pull-secret.spec.ts | 8 ++--- .../e2e/tests/console/crud/quotas.spec.ts | 22 ++++-------- .../console/crud/roles-rolebindings.spec.ts | 32 +++++++---------- .../public/components/factory/list-page.tsx | 1 + 8 files changed, 57 insertions(+), 70 deletions(-) diff --git a/frontend/e2e/pages/details-page.ts b/frontend/e2e/pages/details-page.ts index 693817fb669..6b4a08a9ee0 100644 --- a/frontend/e2e/pages/details-page.ts +++ b/frontend/e2e/pages/details-page.ts @@ -34,8 +34,7 @@ export class DetailsPage extends BasePage { * Click a kebab menu action (assumes menu is already open) */ async clickKebabAction(actionId: string): Promise { - const action = this.page.getByTestId(actionId); - await this.robustClick(action); + await this.robustClick(this.page.getByTestId(actionId)); } /** diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index d956bd51662..c9765584868 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -9,8 +9,8 @@ export class ListPage extends BasePage { '[data-ouia-component-id="DataViewFilters"]', ); private readonly namespaceDropdown = this.page.getByTestId('namespace-bar-dropdown'); - private readonly legacyResourceRows = this.page.locator('[data-test-rows="resource-row"]'); - private readonly legacyNameFilter = this.page.getByTestId('name-filter-input'); + 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 waitForListLoad(): Promise { @@ -23,7 +23,7 @@ export class ListPage extends BasePage { async waitForTableLoad(): Promise { await this.waitForLoadingComplete(); await this.dataViewTable - .or(this.legacyResourceRows.first()) + .or(this.resourceRows.first()) .first() .waitFor({ state: 'visible', timeout: 60_000 }); } @@ -32,13 +32,11 @@ export class ListPage extends BasePage { const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); await this.robustClick(filterToggle); await this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' }).click(); - await this.nameFilterInput.waitFor({ state: 'visible' }); await this.nameFilterInput.fill(name); } - async legacyFilterByName(name: string): Promise { - await this.legacyNameFilter.clear(); - await this.legacyNameFilter.fill(name); + async filterByNameInput(name: string): Promise { + await this.nameFilter.fill(name); } getCell(resourceName: string, cellName = 'name'): Locator { @@ -50,9 +48,8 @@ export class ListPage extends BasePage { await this.robustClick(link); } - async legacyClickRowByName(resourceName: string): Promise { - const link = this.page.locator(`a[data-test-id="${resourceName}"]`); - await this.robustClick(link); + async clickRowByResourceName(resourceName: string): Promise { + await this.robustClick(this.page.getByTestId(resourceName)); } getNamespaceDropdown(): Locator { @@ -63,21 +60,26 @@ export class ListPage extends BasePage { return this.dataViewTable; } - getLegacyResourceRows(): Locator { - return this.legacyResourceRows; + getResourceRows(): Locator { + return this.resourceRows; } getCreateButton(): Locator { return this.createButton; } - async clickCreateYAMLButton(): Promise { + async clickCreateButton(): Promise { + await this.robustClick(this.createButton); + } + + 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.locator('[data-test-dropdown-menu="yaml"]'); + const yamlMenuItem = this.page.getByTestId('dropdown-menu-yaml'); if ((await yamlMenuItem.count()) > 0) { await this.robustClick(yamlMenuItem); } @@ -91,8 +93,8 @@ export class ListPage extends BasePage { await this.robustClick(this.page.getByTestId(actionName)); } - async legacyClickKebabAction(resourceName: string, actionName: string): Promise { - const row = this.legacyResourceRows + async clickResourceRowKebabAction(resourceName: string, actionName: string): Promise { + const row = this.resourceRows .filter({ hasText: resourceName }) .first(); const kebab = row.getByTestId('kebab-button'); @@ -139,7 +141,6 @@ export class ListPage extends BasePage { await searchInput.fill(projectName); const item = this.page.getByRole('menuitem', { name: projectName, exact: true }); await this.robustClick(item); - await this.waitForLoadingComplete(); } async selectAllProjects(): Promise { @@ -147,6 +148,5 @@ export class ListPage extends BasePage { await this.robustClick(dropdownButton); const item = this.page.getByRole('menuitem', { name: 'All Projects', exact: true }); await this.robustClick(item); - await this.waitForLoadingComplete(); } } diff --git a/frontend/e2e/pages/role-binding-page.ts b/frontend/e2e/pages/role-binding-page.ts index 49ed77bd309..5c9153e1f14 100644 --- a/frontend/e2e/pages/role-binding-page.ts +++ b/frontend/e2e/pages/role-binding-page.ts @@ -6,13 +6,13 @@ export class RoleBindingPage extends BasePage { private readonly roleDropdown = this.page.getByTestId('role-dropdown'); private readonly subjectNameInput = this.page.getByTestId('subject-name'); private readonly saveChangesButton = this.page.getByTestId('save-changes'); + async fillName(name: string): Promise { await this.nameInput.fill(name); } private async fillSearchInput(text: string): Promise { const input = this.page.getByTestId('console-select-search-input').locator('input'); - await input.waitFor({ state: 'visible' }); await input.fill(text); } @@ -43,6 +43,9 @@ export class RoleBindingPage extends BasePage { async save(): Promise { await this.robustClick(this.saveChangesButton); - await this.page.getByTestId('loading-indicator').waitFor({ state: 'detached' }).catch(() => {}); + await this.page + .getByTestId('loading-indicator') + .waitFor({ state: 'detached', timeout: 5_000 }) + .catch(() => {}); } } diff --git a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts index 12b7fedb5f5..977429b7727 100644 --- a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts +++ b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts @@ -22,7 +22,7 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { }); test.afterAll(async ({ k8sClient }) => { - await k8sClient.deleteNamespace(namespace).catch(() => {}); + await k8sClient.deleteNamespace(namespace); }); for (const resourceType of workloadTypes) { @@ -41,7 +41,6 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { await yamlViewInput.click(); } - await page.getByTestId('resource-sidebar').waitFor({ state: 'visible' }); await page.getByTestId('code-editor').waitFor({ state: 'visible' }); const content = await getEditorContent(page); @@ -57,12 +56,13 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { await setEditorContent(page, yaml.dump(parsed, { sortKeys: true })); } else { await page.goto(`/k8s/ns/${namespace}/${resourceType}/~new`); - await page.getByTestId('resource-sidebar').waitFor({ state: 'visible' }); await page.getByTestId('code-editor').waitFor({ state: 'visible' }); } - await page.getByTestId('save-changes').click(); - await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + const saveButton = page.getByTestId('save-changes'); + const yamlError = page.getByTestId('yaml-error'); + await saveButton.click(); + await expect(yamlError).not.toBeAttached(); }); await test.step('Add storage via Actions dropdown', async () => { @@ -70,13 +70,15 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { await details.waitForPageLoad(); await details.clickPageAction('Add storage'); - await page.getByTestId('claim-name').waitFor({ state: 'visible' }); await page.getByTestId('Create new claim-radio-input').click(); await page.getByTestId('pvc-name').fill(pvcName); await page.getByTestId('pvc-size').fill(pvcSize); await page.getByTestId('mount-path').fill(mountPath); - await page.getByTestId('save-changes').click(); - await expect(page.getByTestId('yaml-error')).not.toBeAttached(); + + const saveButton = page.getByTestId('save-changes'); + const yamlError = page.getByTestId('yaml-error'); + await saveButton.click(); + await expect(yamlError).not.toBeAttached(); }); await test.step('Verify storage is attached', async () => { diff --git a/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts index a96f27f9e93..1df5ff40122 100644 --- a/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts +++ b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts @@ -12,7 +12,7 @@ test.describe('Image pull secret', { tag: ['@admin'] }, () => { }); test.afterAll(async ({ k8sClient }) => { - await k8sClient.deleteNamespace(namespace).catch(() => {}); + await k8sClient.deleteNamespace(namespace); }); test('create image pull secret with whitespace-trimmed input values', async ({ page }) => { @@ -27,12 +27,8 @@ test.describe('Image pull secret', { tag: ['@admin'] }, () => { await test.step('Navigate to Secrets and open Create Image Pull Secret form', async () => { await nav.navigateToWorkloads('Secrets'); - await listPage.waitForListLoad(); await listPage.selectProject(namespace); - await listPage.waitForListLoad(); - - await page.getByTestId('item-create').click(); - await page.getByRole('menuitem', { name: 'Image pull secret' }).click(); + await listPage.clickCreateDropdownItem('Image pull secret'); await expect(page.getByRole('heading', { level: 1 })).toContainText( 'Create image pull secret', diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index 17f05cca06a..6478785f16a 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -20,21 +20,13 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { }); test.afterAll(async ({ k8sClient }) => { - try { - await k8sClient.deleteClusterCustomResource( - 'quota.openshift.io', - 'v1', - 'clusterresourcequotas', - clusterQuotaName, - ); - } catch { - // may already be deleted - } - try { - await k8sClient.deleteNamespace(namespace); - } catch { - // may already be deleted - } + await k8sClient.deleteClusterCustomResource( + 'quota.openshift.io', + 'v1', + 'clusterresourcequotas', + clusterQuotaName, + ); + await k8sClient.deleteNamespace(namespace); }); test('create ResourceQuota and ClusterResourceQuota via YAML editor', async ({ page }) => { diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts index 9d7cf7fc751..63f0f25ea59 100644 --- a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -27,23 +27,19 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { test.afterAll(async ({ k8sClient }) => { const deletions = [ - k8sClient - .deleteClusterCustomResource( - 'rbac.authorization.k8s.io', - 'v1', - 'clusterroles', - clusterRoleName, - ) - .catch(() => {}), - k8sClient - .deleteClusterCustomResource( - 'rbac.authorization.k8s.io', - 'v1', - 'clusterrolebindings', - clusterRoleBindingName, - ) - .catch(() => {}), - k8sClient.deleteNamespace(namespace).catch(() => {}), + k8sClient.deleteClusterCustomResource( + 'rbac.authorization.k8s.io', + 'v1', + 'clusterroles', + clusterRoleName, + ), + k8sClient.deleteClusterCustomResource( + 'rbac.authorization.k8s.io', + 'v1', + 'clusterrolebindings', + clusterRoleBindingName, + ), + k8sClient.deleteNamespace(namespace), ]; await Promise.all(deletions); }); @@ -59,7 +55,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await listPage.waitForListLoad(); await page.getByTestId('item-create').click(); - await page.getByTestId('resource-sidebar').waitFor({ state: 'visible' }); await page.getByTestId('code-editor').waitFor({ state: 'visible' }); const content = await getEditorContent(page); @@ -82,7 +77,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await test.step('Create ClusterRole via YAML editor', async () => { await page.getByTestId('item-create').click(); - await page.getByTestId('resource-sidebar').waitFor({ state: 'visible' }); await page.getByTestId('code-editor').waitFor({ state: 'visible' }); const content = await getEditorContent(page); diff --git a/frontend/public/components/factory/list-page.tsx b/frontend/public/components/factory/list-page.tsx index 50db1d6ea9d..e4eee738c72 100644 --- a/frontend/public/components/factory/list-page.tsx +++ b/frontend/public/components/factory/list-page.tsx @@ -294,6 +294,7 @@ export const FireMan: FC = (p initialItems={Object.keys(createProps.items).map((item) => ({ value: item, content: createProps.items[item], + 'data-test': `dropdown-menu-${item}`, 'data-test-dropdown-menu': item, }))} onSelect={(_e, value: string) => runOrNavigate(value)} From 7b620c547f456204d0858f0d5c43e3f5ce753a54 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 9 Jun 2026 10:40:31 -0400 Subject: [PATCH 09/19] CONSOLE-5278: Remove redundant waitFor calls before Playwright actions Playwright actions (fill, click, check, etc.) and robustClick auto-wait for element actionability. Remove waitFor calls that immediately precede these actions in crd-test-utils.ts and other-routes.spec.ts. Fix alertmanager-page.ts to import expect from fixtures instead of @playwright/test. Update migration-context.md Waits and Retries table to guide toward passing timeout to actions/assertions rather than using standalone waitFor. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/alertmanager-page.ts | 2 +- frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts | 4 ---- frontend/e2e/tests/console/crud/other-routes.spec.ts | 2 -- 3 files changed, 1 insertion(+), 7 deletions(-) diff --git a/frontend/e2e/pages/alertmanager-page.ts b/frontend/e2e/pages/alertmanager-page.ts index baee2a9dc4d..de3c55d289c 100644 --- a/frontend/e2e/pages/alertmanager-page.ts +++ b/frontend/e2e/pages/alertmanager-page.ts @@ -1,6 +1,6 @@ -import { expect } from '@playwright/test'; import yaml from 'js-yaml'; +import { expect } from '../fixtures'; import BasePage from './base-page'; type AlertmanagerConfig = { diff --git a/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts b/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts index bf472758bba..65c2b29bdbb 100644 --- a/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts +++ b/frontend/e2e/tests/console/crd-extensions/crd-test-utils.ts @@ -17,8 +17,6 @@ export async function navigateToCRDInstances(page: Page, crd: string): Promise { await page.goto('/k8s/cluster/projects'); const toggle = page.getByTestId('perspective-switcher-toggle'); - await toggle.waitFor({ state: 'visible' }); await toggle.click(); const devOption = page .getByTestId('perspective-switcher-menu-option') .filter({ hasText: 'Developer' }); - await devOption.waitFor({ state: 'visible' }); await devOption.click(); const toggleText = page From 218c674bf454eac39208c08cd9a99658991914f6 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 9 Jun 2026 11:39:38 -0400 Subject: [PATCH 10/19] CONSOLE-5278: Remove redundant waitFor calls per review feedback Remove waitForListLoad(), waitForTableLoad(), and dataViewFilters.waitFor() from list-page.ts. Remove code-editor and save-changes waitFor calls from spec files. Playwright actions auto-wait for actionability, and getEditorContent() has its own internal waitForFunction. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/list-page.ts | 16 ----------- .../console/crud/add-storage-crud.spec.ts | 2 -- .../e2e/tests/console/crud/quotas.spec.ts | 17 ++++------- .../console/crud/roles-rolebindings.spec.ts | 28 ------------------- 4 files changed, 5 insertions(+), 58 deletions(-) diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index c9765584868..7ac1bb0acdc 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -13,21 +13,6 @@ export class ListPage extends BasePage { private readonly nameFilter = this.page.getByTestId('name-filter-input'); private readonly createButton = this.page.getByTestId('item-create'); - async waitForListLoad(): Promise { - await this.waitForLoadingComplete(); - await this.page - .getByTestId('page-heading') - .waitFor({ state: 'visible', timeout: 60_000 }); - } - - async waitForTableLoad(): Promise { - await this.waitForLoadingComplete(); - await this.dataViewTable - .or(this.resourceRows.first()) - .first() - .waitFor({ state: 'visible', timeout: 60_000 }); - } - async filterByName(name: string): Promise { const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); await this.robustClick(filterToggle); @@ -103,7 +88,6 @@ export class ListPage extends BasePage { } async filterByCheckbox(filterName: string, checkboxLabel: string): Promise { - await this.dataViewFilters.waitFor({ state: 'visible', timeout: 60_000 }); const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); await this.robustClick(filterToggle); await this.page.locator('.pf-v6-c-menu__list-item', { hasText: filterName }).click(); diff --git a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts index 977429b7727..e123f346999 100644 --- a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts +++ b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts @@ -41,7 +41,6 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { await yamlViewInput.click(); } - await page.getByTestId('code-editor').waitFor({ state: 'visible' }); const content = await getEditorContent(page); const parsed = yaml.load(content) as Record; @@ -56,7 +55,6 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { await setEditorContent(page, yaml.dump(parsed, { sortKeys: true })); } else { await page.goto(`/k8s/ns/${namespace}/${resourceType}/~new`); - await page.getByTestId('code-editor').waitFor({ state: 'visible' }); } const saveButton = page.getByTestId('save-changes'); diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index 6478785f16a..1fd0558b1aa 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -36,11 +36,8 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await test.step('Create ResourceQuota via YAML editor', async () => { await nav.navigateToAdministration('ResourceQuotas'); await listPage.selectProject(namespace); - await listPage.waitForListLoad(); - await page.getByTestId('item-create').click(); - await page.getByTestId('save-changes').waitFor({ state: 'visible' }); const content = await getEditorContent(page); const parsed = yaml.load(content) as Record; parsed.metadata.name = quotaName; @@ -55,16 +52,12 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { }); await test.step('Navigate back to list', async () => { - const details2 = new DetailsPage(page); - await details2.getBreadcrumb(0).click(); - const listPage2 = new ListPage(page); - await listPage2.waitForListLoad(); + await new DetailsPage(page).getBreadcrumb(0).click(); }); await test.step('Create ClusterResourceQuota via YAML editor', async () => { await page.getByTestId('item-create').click(); - await page.getByTestId('save-changes').waitFor({ state: 'visible' }); const crqYaml = { apiVersion: 'quota.openshift.io/v1', kind: 'ClusterResourceQuota', @@ -94,7 +87,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await nav.navigateToAdministration('ResourceQuotas'); await listPage.selectAllProjects(); - await listPage.waitForTableLoad(); + await listPage.filterByName(quotaName); await expect(listPage.getCell(quotaName)).toBeVisible(); }); @@ -106,7 +99,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await nav.navigateToAdministration('ResourceQuotas'); await listPage.selectAllProjects(); - await listPage.waitForTableLoad(); + await listPage.filterByName(clusterQuotaName); await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); @@ -123,7 +116,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await nav.navigateToAdministration('ResourceQuotas'); await listPage.selectProject(namespace); - await listPage.waitForTableLoad(); + await listPage.filterByName(quotaName); await expect(listPage.getCell(quotaName)).toBeVisible(); }); @@ -135,7 +128,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await nav.navigateToAdministration('ResourceQuotas'); await listPage.selectProject(namespace); - await listPage.waitForTableLoad(); + await listPage.filterByName(clusterQuotaName); await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts index 63f0f25ea59..2ecfb6e3824 100644 --- a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -50,12 +50,8 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await test.step('Create Role via YAML editor', async () => { await nav.navigateToUserManagement('Roles'); - await listPage.waitForListLoad(); await listPage.selectProject(namespace); - await listPage.waitForListLoad(); - await page.getByTestId('item-create').click(); - await page.getByTestId('code-editor').waitFor({ state: 'visible' }); const content = await getEditorContent(page); const parsed = yaml.load(content) as Record; @@ -72,12 +68,10 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await test.step('Navigate back to Roles list', async () => { const details = new DetailsPage(page); await details.getBreadcrumb(0).click(); - await listPage.waitForListLoad(); }); await test.step('Create ClusterRole via YAML editor', async () => { await page.getByTestId('item-create').click(); - await page.getByTestId('code-editor').waitFor({ state: 'visible' }); const content = await getEditorContent(page); const parsed = yaml.load(content) as Record; @@ -98,9 +92,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await test.step('Create RoleBinding', async () => { await nav.navigateToUserManagement('RoleBindings'); - const listPage = new ListPage(page); - await listPage.waitForListLoad(); - await page.getByTestId('item-create').click(); await expect(page.getByTestId('title')).toHaveText('Create RoleBinding'); @@ -118,9 +109,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await test.step('Create ClusterRoleBinding', async () => { await nav.navigateToUserManagement('RoleBindings'); - const listPage = new ListPage(page); - await listPage.waitForListLoad(); - await page.getByTestId('item-create').click(); await expect(page.getByTestId('title')).toHaveText('Create RoleBinding'); @@ -143,9 +131,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const details = new DetailsPage(page); await nav.navigateToUserManagement('Roles'); - await listPage.waitForListLoad(); await listPage.selectProject(namespace); - await listPage.waitForListLoad(); await listPage.filterByName(roleName); await listPage.clickRowByName(roleName); await details.waitForPageLoad(); @@ -163,9 +149,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const details = new DetailsPage(page); await nav.navigateToUserManagement('Roles'); - await listPage.waitForListLoad(); await listPage.selectAllProjects(); - await listPage.waitForListLoad(); await listPage.filterByCheckbox('Role', 'cluster'); await listPage.filterByName(clusterRoleName); await listPage.clickRowByName(clusterRoleName); @@ -190,9 +174,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); await nav.navigateToUserManagement(rolesOrBindings); - await listPage.waitForListLoad(); await listPage.selectAllProjects(); - await listPage.waitForListLoad(); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'namespace', @@ -204,7 +186,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await expect(namespaceDropdown).toContainText(namespace); await details.getBreadcrumb(0).click(); - await listPage.waitForListLoad(); await expect(namespaceDropdown).toContainText('All Projects'); }); @@ -218,9 +199,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); await nav.navigateToUserManagement(rolesOrBindings); - await listPage.waitForListLoad(); await listPage.selectProject(namespace); - await listPage.waitForListLoad(); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'namespace', @@ -232,7 +211,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await expect(namespaceDropdown).toContainText(namespace); await details.getBreadcrumb(0).click(); - await listPage.waitForListLoad(); await expect(namespaceDropdown).toContainText(namespace); }); @@ -246,9 +224,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); await nav.navigateToUserManagement(rolesOrBindings); - await listPage.waitForListLoad(); await listPage.selectAllProjects(); - await listPage.waitForListLoad(); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'cluster', @@ -260,7 +236,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await expect(namespaceDropdown).not.toBeAttached(); await details.getBreadcrumb(0).click(); - await listPage.waitForListLoad(); await expect(namespaceDropdown).toContainText('All Projects'); }); @@ -274,9 +249,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); await nav.navigateToUserManagement(rolesOrBindings); - await listPage.waitForListLoad(); await listPage.selectProject(namespace); - await listPage.waitForListLoad(); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'cluster', @@ -288,7 +261,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await expect(namespaceDropdown).not.toBeAttached(); await details.getBreadcrumb(0).click(); - await listPage.waitForListLoad(); await expect(namespaceDropdown).toContainText(namespace); }); } From 4ae9f48e230fea13df657017f10c58a3c558fe08 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 9 Jun 2026 13:17:20 -0400 Subject: [PATCH 11/19] CONSOLE-5278: Remove DetailsPage.waitForPageLoad() Playwright auto-waits for actionability on subsequent actions and assertions auto-retry, making explicit page-load guards redundant. Co-Authored-By: Claude Opus 4.6 --- .../console/crud/add-storage-crud.spec.ts | 1 - .../console/crud/image-pull-secret.spec.ts | 1 - .../tests/console/crud/other-routes.spec.ts | 7 ++----- .../e2e/tests/console/crud/quotas.spec.ts | 8 ++------ .../console/crud/roles-rolebindings.spec.ts | 20 ------------------- 5 files changed, 4 insertions(+), 33 deletions(-) diff --git a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts index e123f346999..af9158a69c4 100644 --- a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts +++ b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts @@ -65,7 +65,6 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { await test.step('Add storage via Actions dropdown', async () => { const details = new DetailsPage(page); - await details.waitForPageLoad(); await details.clickPageAction('Add storage'); await page.getByTestId('Create new claim-radio-input').click(); diff --git a/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts index 1df5ff40122..6e76834b593 100644 --- a/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts +++ b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts @@ -55,7 +55,6 @@ test.describe('Image pull secret', { tag: ['@admin'] }, () => { await page.getByTestId('save-changes').click(); const details = new DetailsPage(page); - await details.waitForPageLoad(); await expect(details.getPageHeading()).toContainText(secretName); }); }); diff --git a/frontend/e2e/tests/console/crud/other-routes.spec.ts b/frontend/e2e/tests/console/crud/other-routes.spec.ts index b9229dfac6e..f5d5c7e78a7 100644 --- a/frontend/e2e/tests/console/crud/other-routes.spec.ts +++ b/frontend/e2e/tests/console/crud/other-routes.spec.ts @@ -1,6 +1,5 @@ import type { Page } from '@playwright/test'; import { test, expect } from '../../../fixtures'; -import { DetailsPage } from '../../../pages/details-page'; import { testA11y } from '../../../utils/a11y'; type RouteConfig = { @@ -55,8 +54,7 @@ const routes: RouteConfig[] = [ { path: '/api-resource/ns/default/core~v1~Pod', waitFor: async (page) => { - const details = new DetailsPage(page); - await details.waitForPageLoad(); + await expect(page.getByTestId('page-heading')).toBeVisible(); }, }, { @@ -119,8 +117,7 @@ const routes: RouteConfig[] = [ { path: '/k8s/ns/openshift-monitoring/monitoring.coreos.com~v1~Alertmanager/main', waitFor: async (page) => { - const details = new DetailsPage(page); - await details.waitForPageLoad(); + await expect(page.getByTestId('resource-title')).toBeVisible(); }, }, { diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index 1fd0558b1aa..f53e8d0ca2a 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -47,8 +47,6 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await page.getByTestId('save-changes').click(); await expect(page.getByTestId('yaml-error')).not.toBeAttached(); - const details = new DetailsPage(page); - await details.waitForPageLoad(); }); await test.step('Navigate back to list', async () => { @@ -76,8 +74,6 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await page.getByTestId('save-changes').click(); await expect(page.getByTestId('yaml-error')).not.toBeAttached(); - const details = new DetailsPage(page); - await details.waitForPageLoad(); }); }); @@ -105,7 +101,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await test.step('Verify breadcrumb', async () => { await listPage.clickRowByName(clusterQuotaName); - await details.waitForPageLoad(); + await expect(details.getBreadcrumb(0)).toContainText('ClusterResourceQuota'); }); }); @@ -134,7 +130,7 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await test.step('Verify breadcrumb', async () => { await listPage.clickRowByName(clusterQuotaName); - await details.waitForPageLoad(); + await expect(details.getBreadcrumb(0)).toContainText('AppliedClusterResourceQuota'); }); }); diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts index 2ecfb6e3824..4d6b9eb953e 100644 --- a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -60,9 +60,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await page.getByTestId('save-changes').click(); await expect(page.getByTestId('yaml-error')).not.toBeAttached(); - - const details = new DetailsPage(page); - await details.waitForPageLoad(); }); await test.step('Navigate back to Roles list', async () => { @@ -81,9 +78,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await page.getByTestId('save-changes').click(); await expect(page.getByTestId('yaml-error')).not.toBeAttached(); - - const details = new DetailsPage(page); - await details.waitForPageLoad(); }); }); @@ -102,9 +96,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await rbPage.fillSubjectName('subject-name'); await rbPage.save(); await expect(page.getByTestId('yaml-error')).not.toBeAttached(); - - const details = new DetailsPage(page); - await details.waitForPageLoad(); }); await test.step('Create ClusterRoleBinding', async () => { @@ -119,22 +110,17 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await rbPage.fillSubjectName('subject-name'); await rbPage.save(); await expect(page.getByTestId('yaml-error')).not.toBeAttached(); - - const details = new DetailsPage(page); - await details.waitForPageLoad(); }); }); test('displays Resource names and Verbs columns in Role rules table', async ({ page }) => { const nav = new Navigation(page); const listPage = new ListPage(page); - const details = new DetailsPage(page); await nav.navigateToUserManagement('Roles'); await listPage.selectProject(namespace); await listPage.filterByName(roleName); await listPage.clickRowByName(roleName); - await details.waitForPageLoad(); await expect(page.locator('th', { hasText: 'Resource names' })).toBeVisible(); await expect(page.locator('th', { hasText: 'Verbs' })).toBeVisible(); @@ -146,14 +132,12 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { }) => { const nav = new Navigation(page); const listPage = new ListPage(page); - const details = new DetailsPage(page); await nav.navigateToUserManagement('Roles'); await listPage.selectAllProjects(); await listPage.filterByCheckbox('Role', 'cluster'); await listPage.filterByName(clusterRoleName); await listPage.clickRowByName(clusterRoleName); - await details.waitForPageLoad(); await expect(page.locator('th', { hasText: 'Resource names' })).toBeVisible(); await expect(page.locator('th', { hasText: 'Verbs' })).toBeVisible(); @@ -181,7 +165,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { ); await listPage.filterByName(name); await listPage.clickRowByName(name); - await details.waitForPageLoad(); await expect(namespaceDropdown).toContainText(namespace); @@ -206,7 +189,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { ); await listPage.filterByName(name); await listPage.clickRowByName(name); - await details.waitForPageLoad(); await expect(namespaceDropdown).toContainText(namespace); @@ -231,7 +213,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { ); await listPage.filterByName(clusterName); await listPage.clickRowByName(clusterName); - await details.waitForPageLoad(); await expect(namespaceDropdown).not.toBeAttached(); @@ -256,7 +237,6 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { ); await listPage.filterByName(clusterName); await listPage.clickRowByName(clusterName); - await details.waitForPageLoad(); await expect(namespaceDropdown).not.toBeAttached(); From 096e83fedd32b058306d44434a553aaed71bd6c8 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 9 Jun 2026 14:14:22 -0400 Subject: [PATCH 12/19] CONSOLE-5278: Add deleteClusterCustomResource to migration docs This branch introduces KubernetesClient.deleteClusterCustomResource() which swallows 404s like deleteNamespace and deleteCustomResource. Update migration-context.md and SKILL.md to include it in the no-try/catch cleanup guidance. Co-Authored-By: Claude Opus 4.6 --- .claude/migration-context.md | 4 ++-- .claude/skills/migrate-cypress/SKILL.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude/migration-context.md b/.claude/migration-context.md index e6ef1ea7fb4..0ee0ecb8f18 100644 --- a/.claude/migration-context.md +++ b/.claude/migration-context.md @@ -500,7 +500,7 @@ private readonly resourceRows = this.page.getByTestId('resource-row'); ## k8sClient Cleanup -`KubernetesClient.deleteNamespace()` and `KubernetesClient.deleteCustomResource()` catch errors and call `isNotFound(err)` to silently swallow 404 "not found" responses. Do NOT wrap these cleanup calls in try/catch blocks. Note: `deleteClusterCustomResource` is not implemented in `KubernetesClient` — do not reference it. +`KubernetesClient.deleteNamespace()`, `KubernetesClient.deleteCustomResource()`, and `KubernetesClient.deleteClusterCustomResource()` catch errors and call `isNotFound(err)` to silently swallow 404 "not found" responses. Do NOT wrap these cleanup calls in try/catch blocks. ```typescript // WRONG — unnecessary error handling @@ -530,7 +530,7 @@ test.afterAll(async ({ k8sClient }) => { - **Never use `page.waitForTimeout()`** as a replacement for `cy.wait()`. Find the condition to wait for - **Never add `waitFor()` before an action** — `fill()`, `click()`, `check()`, etc. already auto-wait for actionability - **Never use legacy test attribute selectors** (`[data-test-rows="..."]`, `[data-test-id="..."]`, `[data-test-dropdown-menu="..."]`) — add `data-test` to the React source and use `getByTestId()` -- **Never wrap k8sClient cleanup in try/catch** — `deleteNamespace` and `deleteCustomResource` already swallow 404s +- **Never wrap k8sClient cleanup in try/catch** — `deleteNamespace`, `deleteCustomResource`, and `deleteClusterCustomResource` already swallow 404s - **Never prefix methods with `legacy`** — name for what it does, not its age - **Never put locators in spec files** when a page object exists or should exist - **Never rely on test order** — each `test()` must work independently. diff --git a/.claude/skills/migrate-cypress/SKILL.md b/.claude/skills/migrate-cypress/SKILL.md index ef408f5d7f0..2de3585009f 100644 --- a/.claude/skills/migrate-cypress/SKILL.md +++ b/.claude/skills/migrate-cypress/SKILL.md @@ -149,7 +149,7 @@ Example: - **No fixed waits** — replace `cy.wait(ms)` with condition-based waits or assertion timeouts - **No redundant `waitFor()`** — `fill()`, `click()`, `check()`, etc. auto-wait for actionability; only use `waitFor()` when waiting for state without acting on the element - **No shell commands** — replace `cy.exec('oc ...')` with `KubernetesClient` -- **No try/catch in cleanup** — `k8sClient.deleteNamespace()` and `deleteCustomResource()` already swallow 404 errors +- **No try/catch in cleanup** — `k8sClient.deleteNamespace()`, `deleteCustomResource()`, and `deleteClusterCustomResource()` already swallow 404 errors - **Add `data-test` to React source** — when the component only has legacy test attributes (`data-test-id`, `data-test-rows`, etc.), add `data-test` alongside and use `getByTestId()` - **Framework-first** — use existing page objects before creating new ones - **Correct layer** — locators in page objects, test scenarios in specs; common multi-step interactions belong in page object methods, not inline in specs From cf3014fbca27036ffa2bb5714ae88e1837d3bb55 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 9 Jun 2026 14:29:40 -0400 Subject: [PATCH 13/19] CONSOLE-5278: Clean up unused method and extra blank lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove unused clickResourceRowKebabAction from ListPage (uses substring matching via hasText — the existing clickKebabAction with getCell is the correct method to use). Remove trailing blank lines in quotas and add-storage-crud specs. Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/list-page.ts | 9 --------- frontend/e2e/tests/console/crud/add-storage-crud.spec.ts | 1 - frontend/e2e/tests/console/crud/quotas.spec.ts | 2 -- 3 files changed, 12 deletions(-) diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index 7ac1bb0acdc..5efffa0cc0b 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -78,15 +78,6 @@ export class ListPage extends BasePage { 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 filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); await this.robustClick(filterToggle); diff --git a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts index af9158a69c4..eca93adc6bb 100644 --- a/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts +++ b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts @@ -41,7 +41,6 @@ test.describe('Add storage for workloads', { tag: ['@admin'] }, () => { await yamlViewInput.click(); } - const content = await getEditorContent(page); const parsed = yaml.load(content) as Record; parsed.metadata.name = name; diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index f53e8d0ca2a..39511631c0e 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -46,7 +46,6 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await page.getByTestId('save-changes').click(); await expect(page.getByTestId('yaml-error')).not.toBeAttached(); - }); await test.step('Navigate back to list', async () => { @@ -73,7 +72,6 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await page.getByTestId('save-changes').click(); await expect(page.getByTestId('yaml-error')).not.toBeAttached(); - }); }); From 79ff52259dd597f16a9770da46e7e88c9c7022af Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 15 Jun 2026 09:23:26 -0400 Subject: [PATCH 14/19] CONSOLE-5278: Address PR #16583 review feedback Extract the integration test user agent string into a shared INTEGRATION_TEST_USER_AGENT constant. Co-Authored-By: Claude Opus 4.6 --- .../packages/console-app/src/components/tour/tour-context.ts | 3 ++- frontend/packages/console-shared/src/constants/common.ts | 2 ++ frontend/packages/integration-tests/cypress-common-config.js | 1 + frontend/playwright.config.ts | 3 ++- 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/packages/console-app/src/components/tour/tour-context.ts b/frontend/packages/console-app/src/components/tour/tour-context.ts index 36ce6ae0714..23c00d9a8ec 100644 --- a/frontend/packages/console-app/src/components/tour/tour-context.ts +++ b/frontend/packages/console-app/src/components/tour/tour-context.ts @@ -9,6 +9,7 @@ import { INTERNAL_DO_NOT_USE_isGuidedTour as isGuidedTour } from '@console/dynam import { getFlagsObject } from '@console/internal/reducers/features'; import type { RootState } from '@console/internal/redux'; import { useTranslatedExtensions } from '@console/plugin-sdk/src/utils/useTranslatedExtensions'; +import { INTEGRATION_TEST_USER_AGENT } from '@console/shared/src/constants/common'; import { useConsoleSelector } from '@console/shared/src/hooks/useConsoleSelector'; import { useUserPreference } from '@console/shared/src/hooks/useUserPreference'; import { TourActions } from './const'; @@ -138,7 +139,7 @@ export const useTourValuesForContext = (): TourContextType => { const [tourCompletionState, setTourCompletionState, loaded] = useTourStateForPerspective( activePerspective, ); - const isIntegrationTest = window.navigator.userAgent === 'ConsoleIntegrationTestEnvironment'; + const isIntegrationTest = window.navigator.userAgent === INTEGRATION_TEST_USER_AGENT; const completed = tourCompletionState?.completed || isIntegrationTest; const onComplete = () => { if (completed === false) { diff --git a/frontend/packages/console-shared/src/constants/common.ts b/frontend/packages/console-shared/src/constants/common.ts index 9c5781fae1e..76ca982d512 100644 --- a/frontend/packages/console-shared/src/constants/common.ts +++ b/frontend/packages/console-shared/src/constants/common.ts @@ -122,5 +122,7 @@ export enum CLUSTER_TELEMETRY_ANALYTICS { export const CLUSTER_VERSION_DEFAULT_UPSTREAM_SERVER_URL_PLACEHOLDER = 'https://api.openshift.com/api/upgrades_info/v1/graph'; +export const INTEGRATION_TEST_USER_AGENT = 'ConsoleIntegrationTestEnvironment'; + export const PREFERRED_TELEMETRY_USER_PREFERENCE_KEY = 'telemetry.analytics'; export const IS_PRODUCTION = process.env.NODE_ENV === 'production'; diff --git a/frontend/packages/integration-tests/cypress-common-config.js b/frontend/packages/integration-tests/cypress-common-config.js index d1b6de9076e..0cd8886fb7d 100644 --- a/frontend/packages/integration-tests/cypress-common-config.js +++ b/frontend/packages/integration-tests/cypress-common-config.js @@ -171,6 +171,7 @@ const commonConfig = { experimentalMemoryManagement: true, numTestsKeptInMemory: 50, injectDocumentDomain: true, + // Keep in sync with INTEGRATION_TEST_USER_AGENT in packages/console-shared/src/constants/common.ts userAgent: 'ConsoleIntegrationTestEnvironment', }, }; diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 0cb13725e5c..46e69bc30cd 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -4,9 +4,10 @@ import * as dotenv from 'dotenv'; dotenv.config({ path: path.resolve(__dirname, 'e2e', '.env'), quiet: true }); import { defineConfig, devices } from '@playwright/test'; +import { INTEGRATION_TEST_USER_AGENT } from './packages/console-shared/src/constants/common'; const isCI = !!process.env.OPENSHIFT_CI || !!process.env.CI; -const chrome = { ...devices['Desktop Chrome'], userAgent: 'ConsoleIntegrationTestEnvironment' }; +const chrome = { ...devices['Desktop Chrome'], userAgent: INTEGRATION_TEST_USER_AGENT }; const isDebug = process.env.DEBUG === '1' || process.env.DEBUG === 'true'; const baseURL = process.env.WEB_CONSOLE_URL || 'http://localhost:9000'; From a2c2c63cf4ab20f792a91b8d5f3da28f85bf2b58 Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 15 Jun 2026 09:32:40 -0400 Subject: [PATCH 15/19] CONSOLE-5278: Replace Navigation page object with direct URLs The Navigation page object was removed in PR #16583 because sidebar nav clicks flake when React re-renders detach the link mid-click. Use direct page.goto() URLs instead. Co-Authored-By: Claude Opus 4.6 --- .../console/crud/image-pull-secret.spec.ts | 6 +-- .../e2e/tests/console/crud/quotas.spec.ts | 21 +++-------- .../console/crud/roles-rolebindings.spec.ts | 37 ++++++------------- 3 files changed, 18 insertions(+), 46 deletions(-) diff --git a/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts index 6e76834b593..82e94651341 100644 --- a/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts +++ b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts @@ -1,7 +1,6 @@ import { test, expect } from '../../../fixtures'; import { DetailsPage } from '../../../pages/details-page'; import { ListPage } from '../../../pages/list-page'; -import { Navigation } from '../../../pages/navigation'; test.describe('Image pull secret', { tag: ['@admin'] }, () => { let namespace: string; @@ -22,12 +21,11 @@ test.describe('Image pull secret', { tag: ['@admin'] }, () => { const password = 'test1234'; const email = 'testEmail@email.com'; - const nav = new Navigation(page); const listPage = new ListPage(page); await test.step('Navigate to Secrets and open Create Image Pull Secret form', async () => { - await nav.navigateToWorkloads('Secrets'); - await listPage.selectProject(namespace); + await page.goto(`/k8s/ns/${namespace}/secrets`); + await listPage.clickCreateDropdownItem('Image pull secret'); await expect(page.getByRole('heading', { level: 1 })).toContainText( diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index 39511631c0e..22cdc7ee451 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -4,7 +4,6 @@ import { test, expect } from '../../../fixtures'; import { getEditorContent, setEditorContent } from '../../../pages/base-page'; import { DetailsPage } from '../../../pages/details-page'; import { ListPage } from '../../../pages/list-page'; -import { Navigation } from '../../../pages/navigation'; const testId = Date.now(); const quotaName = `test-resource-quota-${testId}`; @@ -30,12 +29,10 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { }); test('create ResourceQuota and ClusterResourceQuota via YAML editor', async ({ page }) => { - const nav = new Navigation(page); const listPage = new ListPage(page); await test.step('Create ResourceQuota via YAML editor', async () => { - await nav.navigateToAdministration('ResourceQuotas'); - await listPage.selectProject(namespace); + await page.goto(`/k8s/ns/${namespace}/resourcequotas`); await page.getByTestId('item-create').click(); const content = await getEditorContent(page); @@ -76,23 +73,19 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { }); test('All Projects shows ResourceQuotas', async ({ page }) => { - const nav = new Navigation(page); const listPage = new ListPage(page); - await nav.navigateToAdministration('ResourceQuotas'); - await listPage.selectAllProjects(); + await page.goto('/k8s/all-namespaces/resourcequotas'); await listPage.filterByName(quotaName); await expect(listPage.getCell(quotaName)).toBeVisible(); }); test('All Projects shows ClusterResourceQuotas', async ({ page }) => { - const nav = new Navigation(page); const listPage = new ListPage(page); const details = new DetailsPage(page); - await nav.navigateToAdministration('ResourceQuotas'); - await listPage.selectAllProjects(); + await page.goto('/k8s/all-namespaces/resourcequotas'); await listPage.filterByName(clusterQuotaName); await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); @@ -105,23 +98,19 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { }); test('project namespace shows ResourceQuotas', async ({ page }) => { - const nav = new Navigation(page); const listPage = new ListPage(page); - await nav.navigateToAdministration('ResourceQuotas'); - await listPage.selectProject(namespace); + await page.goto(`/k8s/ns/${namespace}/resourcequotas`); await listPage.filterByName(quotaName); await expect(listPage.getCell(quotaName)).toBeVisible(); }); test('project namespace shows AppliedClusterResourceQuotas', async ({ page }) => { - const nav = new Navigation(page); const listPage = new ListPage(page); const details = new DetailsPage(page); - await nav.navigateToAdministration('ResourceQuotas'); - await listPage.selectProject(namespace); + await page.goto(`/k8s/ns/${namespace}/resourcequotas`); await listPage.filterByName(clusterQuotaName); await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts index 4d6b9eb953e..993a10c16b0 100644 --- a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -4,7 +4,6 @@ import { test, expect } from '../../../fixtures'; import { getEditorContent, setEditorContent } from '../../../pages/base-page'; import { DetailsPage } from '../../../pages/details-page'; import { ListPage } from '../../../pages/list-page'; -import { Navigation } from '../../../pages/navigation'; import { RoleBindingPage } from '../../../pages/role-binding-page'; test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { @@ -45,12 +44,10 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { }); test('create Role and ClusterRole via YAML editor', async ({ page }) => { - const nav = new Navigation(page); const listPage = new ListPage(page); await test.step('Create Role via YAML editor', async () => { - await nav.navigateToUserManagement('Roles'); - await listPage.selectProject(namespace); + await page.goto(`/k8s/ns/${namespace}/roles`); await page.getByTestId('item-create').click(); const content = await getEditorContent(page); @@ -82,10 +79,8 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { }); test('create RoleBinding and ClusterRoleBinding via form', async ({ page }) => { - const nav = new Navigation(page); - await test.step('Create RoleBinding', async () => { - await nav.navigateToUserManagement('RoleBindings'); + await page.goto('/k8s/all-namespaces/rolebindings'); await page.getByTestId('item-create').click(); await expect(page.getByTestId('title')).toHaveText('Create RoleBinding'); @@ -99,7 +94,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { }); await test.step('Create ClusterRoleBinding', async () => { - await nav.navigateToUserManagement('RoleBindings'); + await page.goto('/k8s/all-namespaces/rolebindings'); await page.getByTestId('item-create').click(); await expect(page.getByTestId('title')).toHaveText('Create RoleBinding'); @@ -114,11 +109,9 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { }); test('displays Resource names and Verbs columns in Role rules table', async ({ page }) => { - const nav = new Navigation(page); const listPage = new ListPage(page); - await nav.navigateToUserManagement('Roles'); - await listPage.selectProject(namespace); + await page.goto(`/k8s/ns/${namespace}/roles`); await listPage.filterByName(roleName); await listPage.clickRowByName(roleName); @@ -130,11 +123,9 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { test('displays Resource names and Verbs columns in ClusterRole rules table', async ({ page, }) => { - const nav = new Navigation(page); const listPage = new ListPage(page); - await nav.navigateToUserManagement('Roles'); - await listPage.selectAllProjects(); + await page.goto('/k8s/all-namespaces/roles'); await listPage.filterByCheckbox('Role', 'cluster'); await listPage.filterByName(clusterRoleName); await listPage.clickRowByName(clusterRoleName); @@ -145,6 +136,8 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { }); for (const rolesOrBindings of ['Roles', 'RoleBindings'] as const) { + const resource = rolesOrBindings.toLowerCase(); + test(`${rolesOrBindings} detail breadcrumb to list restores All Projects`, async ({ page, }) => { @@ -152,13 +145,11 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { test.fixme(true, 'RoleBindings breadcrumb does not restore All Projects dropdown'); } const name = rolesOrBindings === 'Roles' ? roleName : roleBindingName; - const nav = new Navigation(page); const listPage = new ListPage(page); const details = new DetailsPage(page); const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); - await nav.navigateToUserManagement(rolesOrBindings); - await listPage.selectAllProjects(); + await page.goto(`/k8s/all-namespaces/${resource}`); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'namespace', @@ -176,13 +167,11 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { page, }) => { const name = rolesOrBindings === 'Roles' ? roleName : roleBindingName; - const nav = new Navigation(page); const listPage = new ListPage(page); const details = new DetailsPage(page); const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); - await nav.navigateToUserManagement(rolesOrBindings); - await listPage.selectProject(namespace); + await page.goto(`/k8s/ns/${namespace}/${resource}`); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'namespace', @@ -200,13 +189,11 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { page, }) => { const clusterName = rolesOrBindings === 'Roles' ? clusterRoleName : clusterRoleBindingName; - const nav = new Navigation(page); const listPage = new ListPage(page); const details = new DetailsPage(page); const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); - await nav.navigateToUserManagement(rolesOrBindings); - await listPage.selectAllProjects(); + await page.goto(`/k8s/all-namespaces/${resource}`); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'cluster', @@ -224,13 +211,11 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { page, }) => { const clusterName = rolesOrBindings === 'Roles' ? clusterRoleName : clusterRoleBindingName; - const nav = new Navigation(page); const listPage = new ListPage(page); const details = new DetailsPage(page); const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); - await nav.navigateToUserManagement(rolesOrBindings); - await listPage.selectProject(namespace); + await page.goto(`/k8s/ns/${namespace}/${resource}`); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'cluster', From 8bf99d93c10e0a690bc8dc61e7b7b36b483ba68a Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 15 Jun 2026 12:04:25 -0400 Subject: [PATCH 16/19] CONSOLE-5278: Fix CRUD test failures and add SPA warmup helper Fix several CRUD test failures caused by SPA bootstrap race conditions and incorrect filter method usage: - Add warmupSPA() helper to base-page.ts that navigates to '/' and asserts the page heading is visible before tests proceed - Fix roles-rolebindings tests to use filterByName() (DataView filter) instead of filterByNameInput() (standard FilterToolbar) since both Roles and RoleBindings pages use ConsoleDataView - Fix getEditorContent() to wait for non-empty editor content instead of just checking the Monaco model exists - Fix other-routes loading-indicator check to use toHaveCount(0) to avoid strict mode violation with multiple matching elements - Remove unused waitForListLoaded() from ListPage - Support both DataView and standard list pages in clickRowByName(), clickKebabAction(), and filterByCheckbox() Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/base-page.ts | 17 +++++-- frontend/e2e/pages/list-page.ts | 47 +++++++++++-------- .../tests/console/crud/other-routes.spec.ts | 2 +- .../e2e/tests/console/crud/quotas.spec.ts | 10 ++-- .../console/crud/roles-rolebindings.spec.ts | 6 ++- 5 files changed, 52 insertions(+), 30 deletions(-) diff --git a/frontend/e2e/pages/base-page.ts b/frontend/e2e/pages/base-page.ts index e3de7ebf3ff..d386ae6b204 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -1,9 +1,13 @@ -import type { Locator, Page } from '@playwright/test'; +import { type Locator, type Page, expect } from '@playwright/test'; export async function getEditorContent(page: Page): Promise { - await page.waitForFunction(() => (window as any).monaco?.editor?.getModels()?.[0], { - timeout: 10_000, - }); + await page.waitForFunction( + () => { + const value = (window as any).monaco?.editor?.getModels()?.[0]?.getValue?.(); + return typeof value === 'string' && value.trim().length > 0; + }, + { timeout: 30_000 }, + ); return page.evaluate(() => { return (window as any).monaco.editor.getModels()[0].getValue(); }); @@ -18,6 +22,11 @@ export async function setEditorContent(page: Page, content: string): Promise { + await page.goto('/'); + await expect(page.getByTestId('page-heading')).toBeVisible(); +} + export default abstract class BasePage { constructor(public readonly page: Page) {} diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index 5efffa0cc0b..404a936a7e3 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -14,6 +14,7 @@ export class ListPage extends BasePage { private readonly createButton = this.page.getByTestId('item-create'); async filterByName(name: string): Promise { + await this.dataViewFilters.waitFor({ state: 'visible', timeout: 60_000 }); const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); await this.robustClick(filterToggle); await this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' }).click(); @@ -29,12 +30,9 @@ export class ListPage extends BasePage { } async clickRowByName(resourceName: string): Promise { - const link = this.getCell(resourceName).locator('a').first(); - await this.robustClick(link); - } - - async clickRowByResourceName(resourceName: string): Promise { - await this.robustClick(this.page.getByTestId(resourceName)); + const dataViewLink = this.getCell(resourceName).locator('a').first(); + const standardLink = this.page.getByTestId(resourceName); + await this.robustClick(dataViewLink.or(standardLink).first()); } getNamespaceDropdown(): Locator { @@ -45,6 +43,7 @@ export class ListPage extends BasePage { return this.dataViewTable; } + getResourceRows(): Locator { return this.resourceRows; } @@ -71,7 +70,9 @@ export class ListPage extends BasePage { } async clickKebabAction(resourceName: string, actionName: string): Promise { - const cell = this.getCell(resourceName); + 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); @@ -79,18 +80,26 @@ export class ListPage extends BasePage { } async filterByCheckbox(filterName: string, checkboxLabel: string): Promise { - const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); - await this.robustClick(filterToggle); - 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); + 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 clickFirstLinkInFirstRow(): Promise { diff --git a/frontend/e2e/tests/console/crud/other-routes.spec.ts b/frontend/e2e/tests/console/crud/other-routes.spec.ts index f5d5c7e78a7..a079908bdd2 100644 --- a/frontend/e2e/tests/console/crud/other-routes.spec.ts +++ b/frontend/e2e/tests/console/crud/other-routes.spec.ts @@ -139,7 +139,7 @@ test.describe('Visiting other routes', { tag: ['@admin', '@smoke'] }, () => { }) => { await page.goto(route.path, { timeout: 90_000 }); await expect(page).toHaveURL(new RegExp(route.path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); - await expect(page.getByTestId('loading-indicator')).not.toBeAttached(); + await expect(page.getByTestId('loading-indicator')).toHaveCount(0); await expect(page.getByTestId('error-page')).not.toBeAttached(); if (route.waitFor) { diff --git a/frontend/e2e/tests/console/crud/quotas.spec.ts b/frontend/e2e/tests/console/crud/quotas.spec.ts index 22cdc7ee451..63cbc27ac41 100644 --- a/frontend/e2e/tests/console/crud/quotas.spec.ts +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -1,7 +1,7 @@ import yaml from 'js-yaml'; import { test, expect } from '../../../fixtures'; -import { getEditorContent, setEditorContent } from '../../../pages/base-page'; +import { getEditorContent, setEditorContent, warmupSPA } from '../../../pages/base-page'; import { DetailsPage } from '../../../pages/details-page'; import { ListPage } from '../../../pages/list-page'; @@ -18,6 +18,10 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { await k8sClient.createNamespace(namespace); }); + test.beforeEach(async ({ page }) => { + await warmupSPA(page); + }); + test.afterAll(async ({ k8sClient }) => { await k8sClient.deleteClusterCustomResource( 'quota.openshift.io', @@ -76,7 +80,6 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { const listPage = new ListPage(page); await page.goto('/k8s/all-namespaces/resourcequotas'); - await listPage.filterByName(quotaName); await expect(listPage.getCell(quotaName)).toBeVisible(); }); @@ -86,7 +89,6 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { const details = new DetailsPage(page); await page.goto('/k8s/all-namespaces/resourcequotas'); - await listPage.filterByName(clusterQuotaName); await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); @@ -101,7 +103,6 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { const listPage = new ListPage(page); await page.goto(`/k8s/ns/${namespace}/resourcequotas`); - await listPage.filterByName(quotaName); await expect(listPage.getCell(quotaName)).toBeVisible(); }); @@ -111,7 +112,6 @@ test.describe('Quotas', { tag: ['@admin'] }, () => { const details = new DetailsPage(page); await page.goto(`/k8s/ns/${namespace}/resourcequotas`); - await listPage.filterByName(clusterQuotaName); await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts index 993a10c16b0..c054b09ccd2 100644 --- a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -1,7 +1,7 @@ import yaml from 'js-yaml'; import { test, expect } from '../../../fixtures'; -import { getEditorContent, setEditorContent } from '../../../pages/base-page'; +import { getEditorContent, setEditorContent, warmupSPA } from '../../../pages/base-page'; import { DetailsPage } from '../../../pages/details-page'; import { ListPage } from '../../../pages/list-page'; import { RoleBindingPage } from '../../../pages/role-binding-page'; @@ -24,6 +24,10 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await k8sClient.createNamespace(namespace); }); + test.beforeEach(async ({ page }) => { + await warmupSPA(page); + }); + test.afterAll(async ({ k8sClient }) => { const deletions = [ k8sClient.deleteClusterCustomResource( From ecd03de4020c7756f0620609d6de86fcf1f2f2fd Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 15 Jun 2026 13:20:20 -0400 Subject: [PATCH 17/19] CONSOLE-5278: Address pre-push review feedback - Remove redundant waitFor in filterByName, pass timeout to robustClick - Remove extra blank line in list-page.ts - Replace silent-catch waitFor in role-binding-page save() with waitForLoadingComplete() - Replace toggle.waitFor() with expect assertion in other-routes - Rename waitFor route callbacks to assertLoaded to avoid ESLint no-restricted-syntax false positive Co-Authored-By: Claude Opus 4.6 --- frontend/e2e/pages/list-page.ts | 4 +- frontend/e2e/pages/role-binding-page.ts | 5 +- .../tests/console/crud/other-routes.spec.ts | 52 +++++++++---------- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/frontend/e2e/pages/list-page.ts b/frontend/e2e/pages/list-page.ts index 404a936a7e3..4691b0bd221 100644 --- a/frontend/e2e/pages/list-page.ts +++ b/frontend/e2e/pages/list-page.ts @@ -14,9 +14,8 @@ export class ListPage extends BasePage { private readonly createButton = this.page.getByTestId('item-create'); async filterByName(name: string): Promise { - await this.dataViewFilters.waitFor({ state: 'visible', timeout: 60_000 }); const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); - await this.robustClick(filterToggle); + 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); } @@ -43,7 +42,6 @@ export class ListPage extends BasePage { return this.dataViewTable; } - getResourceRows(): Locator { return this.resourceRows; } diff --git a/frontend/e2e/pages/role-binding-page.ts b/frontend/e2e/pages/role-binding-page.ts index 5c9153e1f14..6a508e6445a 100644 --- a/frontend/e2e/pages/role-binding-page.ts +++ b/frontend/e2e/pages/role-binding-page.ts @@ -43,9 +43,6 @@ export class RoleBindingPage extends BasePage { async save(): Promise { await this.robustClick(this.saveChangesButton); - await this.page - .getByTestId('loading-indicator') - .waitFor({ state: 'detached', timeout: 5_000 }) - .catch(() => {}); + await this.waitForLoadingComplete(); } } diff --git a/frontend/e2e/tests/console/crud/other-routes.spec.ts b/frontend/e2e/tests/console/crud/other-routes.spec.ts index a079908bdd2..01dbcb9a990 100644 --- a/frontend/e2e/tests/console/crud/other-routes.spec.ts +++ b/frontend/e2e/tests/console/crud/other-routes.spec.ts @@ -4,10 +4,10 @@ import { testA11y } from '../../../utils/a11y'; type RouteConfig = { path: string; - waitFor?: (page: Page) => Promise; + assertLoaded?: (page: Page) => Promise; }; -async function waitForListPage(page: Page): Promise { +async function assertLoadedListPage(page: Page): Promise { await expect( page.getByTestId('data-view-table').or(page.getByTestId('page-heading')).first(), ).toBeVisible(); @@ -16,7 +16,7 @@ async function waitForListPage(page: Page): Promise { const routes: RouteConfig[] = [ { path: '/', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('page-heading').locator('h1')).toBeAttached(); for (const skeleton of await page.getByTestId('skeleton-chart').all()) { await expect(skeleton).toBeHidden(); @@ -25,53 +25,53 @@ const routes: RouteConfig[] = [ }, { path: '/k8s/cluster/clusterroles/view', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('page-heading').locator('h1')).toBeAttached(); }, }, { path: '/k8s/cluster/nodes', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, { path: '/k8s/all-namespaces/events', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByRole('row').first()).toBeVisible(); }, }, { path: '/k8s/all-namespaces/import', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByRole('textbox')).toBeVisible(); }, }, { path: '/api-explorer', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('data-view-table')).toBeVisible(); }, }, { path: '/api-resource/ns/default/core~v1~Pod', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('page-heading')).toBeVisible(); }, }, { path: '/api-resource/ns/default/core~v1~Pod/schema', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('resource-sidebar-item').first()).toBeAttached(); }, }, { path: '/api-resource/ns/default/core~v1~Pod/instances', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('api-explorer-resource-title')).toContainText('Pod'); }, }, { path: '/api-resource/ns/default/core~v1~Pod/access', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect( page.locator('[data-ouia-component-type$="TableRow"]').first(), ).toBeVisible(); @@ -82,53 +82,53 @@ const routes: RouteConfig[] = [ }, { path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~Machine', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, { path: '/k8s/cluster/machine.openshift.io~v1~ControlPlaneMachineSet', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, { path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~MachineSet', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, { path: '/k8s/ns/openshift-machine-api/autoscaling.openshift.io~v1beta1~MachineAutoscaler', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('empty-box-body')).toBeVisible(); }, }, { path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~MachineHealthCheck', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, { path: '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfig', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, { path: '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfigPool', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, { path: '/k8s/all-namespaces/monitoring.coreos.com~v1~Alertmanager', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, { path: '/k8s/ns/openshift-monitoring/monitoring.coreos.com~v1~Alertmanager/main', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('resource-title')).toBeVisible(); }, }, { path: '/settings/cluster', - waitFor: async (page) => { + assertLoaded: async (page) => { await expect(page.getByTestId('cluster-version')).toBeAttached(); }, }, { path: '/search/all-namespaces?kind=config.openshift.io~v1~Console', - waitFor: waitForListPage, + assertLoaded: assertLoadedListPage, }, ]; @@ -142,8 +142,8 @@ test.describe('Visiting other routes', { tag: ['@admin', '@smoke'] }, () => { await expect(page.getByTestId('loading-indicator')).toHaveCount(0); await expect(page.getByTestId('error-page')).not.toBeAttached(); - if (route.waitFor) { - await route.waitFor(page); + if (route.assertLoaded) { + await route.assertLoaded(page); } await testA11y(page, `route ${route.path.replace(/\//g, ' ')}`); @@ -160,7 +160,7 @@ test.describe('Perspective query parameters', { tag: ['@admin'] }, () => { await page.goto('/k8s/cluster/projects'); const toggle = page.getByTestId('perspective-switcher-toggle'); - await toggle.waitFor({ state: 'visible' }); + await expect(toggle).toBeVisible(); const isSinglePerspective = (await toggle.getAttribute('id')) === 'core-platform-perspective'; From ef394f8c8644af709786b29094b1f4585a0825ff Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Mon, 15 Jun 2026 15:54:52 -0400 Subject: [PATCH 18/19] CONSOLE-5278: Fix breadcrumb tests to explicitly set project dropdown state Navigating to a namespaced resource detail page updates sessionStorage via setActiveNamespace(), so the breadcrumb always points to the namespace-scoped list URL. The original Cypress "restores All Projects" test for namespaced resources only passed due to a race condition. Consolidate the two namespaced breadcrumb tests into one that explicitly selects the project via the dropdown, matching the original Cypress pattern. Keep the cluster-scoped breadcrumb tests unchanged since those detail pages don't update the namespace in sessionStorage. Co-Authored-By: Claude Opus 4.6 --- .../console/crud/roles-rolebindings.spec.ts | 36 +++++-------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts index c054b09ccd2..bb4985cb4d3 100644 --- a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -142,40 +142,15 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { for (const rolesOrBindings of ['Roles', 'RoleBindings'] as const) { const resource = rolesOrBindings.toLowerCase(); - test(`${rolesOrBindings} detail breadcrumb to list restores All Projects`, async ({ - page, - }) => { - if (rolesOrBindings === 'RoleBindings') { - test.fixme(true, 'RoleBindings breadcrumb does not restore All Projects dropdown'); - } - const name = rolesOrBindings === 'Roles' ? roleName : roleBindingName; - const listPage = new ListPage(page); - const details = new DetailsPage(page); - const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); - - await page.goto(`/k8s/all-namespaces/${resource}`); - await listPage.filterByCheckbox( - rolesOrBindings === 'Roles' ? 'Role' : 'Kind', - 'namespace', - ); - await listPage.filterByName(name); - await listPage.clickRowByName(name); - - await expect(namespaceDropdown).toContainText(namespace); - - await details.getBreadcrumb(0).click(); - await expect(namespaceDropdown).toContainText('All Projects'); - }); - - test(`${rolesOrBindings} detail breadcrumb to list restores last selected project`, async ({ - page, - }) => { + test(`${rolesOrBindings} detail breadcrumb navigates back to list`, async ({ page }) => { const name = rolesOrBindings === 'Roles' ? roleName : roleBindingName; const listPage = new ListPage(page); const details = new DetailsPage(page); const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); await page.goto(`/k8s/ns/${namespace}/${resource}`); + await listPage.selectProject(namespace); + await expect(namespaceDropdown).toContainText(namespace); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'namespace', @@ -187,6 +162,7 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { await details.getBreadcrumb(0).click(); await expect(namespaceDropdown).toContainText(namespace); + await expect(page.getByTestId('page-heading')).toContainText(rolesOrBindings); }); test(`Cluster${rolesOrBindings} detail breadcrumb to list restores All Projects`, async ({ @@ -198,6 +174,8 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); await page.goto(`/k8s/all-namespaces/${resource}`); + await listPage.selectAllProjects(); + await expect(namespaceDropdown).toContainText('All Projects'); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'cluster', @@ -220,6 +198,8 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); await page.goto(`/k8s/ns/${namespace}/${resource}`); + await listPage.selectProject(namespace); + await expect(namespaceDropdown).toContainText(namespace); await listPage.filterByCheckbox( rolesOrBindings === 'Roles' ? 'Role' : 'Kind', 'cluster', From 1b2760d0671e882e0990c0ee043954dcb320449d Mon Sep 17 00:00:00 2001 From: Robb Hamilton Date: Tue, 16 Jun 2026 09:34:01 -0400 Subject: [PATCH 19/19] CONSOLE-5278: Reset sessionStorage before breadcrumb All Projects test The warmupSPA beforeEach navigates to "/" which may resolve a namespace from a prior serial test into sessionStorage. The RBAC breadcrumb reads sessionStorage directly (getLastNamespace) to build its URL, but navigating to an all-namespaces URL doesn't overwrite sessionStorage because Redux already starts with ALL_NAMESPACES_KEY. Explicitly set the sessionStorage key before the test to ensure deterministic state. Co-Authored-By: Claude Opus 4.6 --- .../e2e/tests/console/crud/roles-rolebindings.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts index bb4985cb4d3..099c9685fde 100644 --- a/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -173,6 +173,14 @@ test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { const details = new DetailsPage(page); const namespaceDropdown = page.getByTestId('namespace-bar-dropdown'); + // warmupSPA (beforeEach) navigates to "/" which resolves the last + // namespace from user preferences — possibly a project set by a prior + // serial test — and writes it to sessionStorage. The RBAC breadcrumb + // reads sessionStorage directly (getLastNamespace) to build its URL, + // but navigating to an all-namespaces URL doesn't overwrite + // sessionStorage because Redux already starts with ALL_NAMESPACES_KEY. + await page.evaluate(() => sessionStorage.setItem('bridge/last-namespace-name', '#ALL_NS#')); + await page.goto(`/k8s/all-namespaces/${resource}`); await listPage.selectAllProjects(); await expect(namespaceDropdown).toContainText('All Projects');