diff --git a/.claude/migration-context.md b/.claude/migration-context.md index 756160c8dd4..0ee0ecb8f18 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' }); @@ -499,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 @@ -529,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 8d4dfc14d46..2de3585009f 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,8 +147,9 @@ 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 +- **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 diff --git a/frontend/e2e/clients/kubernetes-client.ts b/frontend/e2e/clients/kubernetes-client.ts index 42392ef01a1..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 }); @@ -435,6 +526,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..b7868982099 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 || 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..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 = { @@ -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..d386ae6b204 100644 --- a/frontend/e2e/pages/base-page.ts +++ b/frontend/e2e/pages/base-page.ts @@ -1,4 +1,31 @@ -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( + () => { + 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(); + }); +} + +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); + }, content); +} + +export async function warmupSPA(page: Page): Promise { + await page.goto('/'); + await expect(page.getByTestId('page-heading')).toBeVisible(); +} export default abstract class BasePage { constructor(public readonly page: Page) {} @@ -99,6 +126,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..6b4a08a9ee0 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 */ @@ -25,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.locator(`[data-test-action="${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 new file mode 100644 index 00000000000..4691b0bd221 --- /dev/null +++ b/frontend/e2e/pages/list-page.ts @@ -0,0 +1,134 @@ +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'); + 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 filterByName(name: string): Promise { + const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); + await this.robustClick(filterToggle, { timeout: 60_000 }); + await this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' }).click(); + await this.nameFilterInput.fill(name); + } + + async filterByNameInput(name: string): Promise { + await this.nameFilter.fill(name); + } + + getCell(resourceName: string, cellName = 'name'): Locator { + return this.page.getByTestId(`data-view-cell-${resourceName}-${cellName}`); + } + + async clickRowByName(resourceName: string): Promise { + const dataViewLink = this.getCell(resourceName).locator('a').first(); + const standardLink = this.page.getByTestId(resourceName); + await this.robustClick(dataViewLink.or(standardLink).first()); + } + + getNamespaceDropdown(): Locator { + return this.namespaceDropdown; + } + + getDataViewTable(): Locator { + return this.dataViewTable; + } + + getResourceRows(): Locator { + return this.resourceRows; + } + + getCreateButton(): Locator { + return this.createButton; + } + + async clickCreateButton(): Promise { + await this.robustClick(this.createButton); + } + + async clickCreateDropdownItem(itemName: string): Promise { + await this.robustClick(this.createButton); + await this.page.getByRole('menuitem', { name: itemName }).click(); + } + + async clickCreateYAMLDropdownButton(): Promise { + await this.robustClick(this.createButton); + const yamlMenuItem = this.page.getByTestId('dropdown-menu-yaml'); + if ((await yamlMenuItem.count()) > 0) { + await this.robustClick(yamlMenuItem); + } + } + + async clickKebabAction(resourceName: string, actionName: string): Promise { + const dataViewCell = this.getCell(resourceName); + const standardCell = this.page.getByTestId(resourceName); + const cell = dataViewCell.or(standardCell).first(); + const row = cell.locator('xpath=ancestor::tr'); + const kebab = row.getByTestId('kebab-button'); + await this.robustClick(kebab); + await this.robustClick(this.page.getByTestId(actionName)); + } + + async filterByCheckbox(filterName: string, checkboxLabel: string): Promise { + const dataViewToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); + const standardToggle = this.page.getByTestId('filter-dropdown-toggle').locator('button'); + const toggle = dataViewToggle.or(standardToggle).first(); + await this.robustClick(toggle); + + if (await this.dataViewFilters.isVisible()) { + await this.page.locator('.pf-v6-c-menu__list-item', { hasText: filterName }).click(); + const checkboxFilter = this.page.locator( + '[data-ouia-component-id="DataViewCheckboxFilter"]', + ); + await this.robustClick(checkboxFilter); + const filterItem = this.page.locator( + `[data-ouia-component-id="DataViewCheckboxFilter-filter-item-${checkboxLabel}"]`, + ); + await this.robustClick(filterItem); + await this.robustClick(checkboxFilter); + } else { + const filterItem = this.page.locator(`[data-test-row-filter="${checkboxLabel}"]`); + await this.robustClick(filterItem); + } + } + + async 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 }); + await this.robustClick(item); + } + + async selectAllProjects(): Promise { + const dropdownButton = this.namespaceDropdown.getByRole('button'); + await this.robustClick(dropdownButton); + const item = this.page.getByRole('menuitem', { name: 'All Projects', exact: true }); + await this.robustClick(item); + } +} diff --git a/frontend/e2e/pages/role-binding-page.ts b/frontend/e2e/pages/role-binding-page.ts new file mode 100644 index 00000000000..6a508e6445a --- /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.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.waitForLoadingComplete(); + } +} 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..65c2b29bdbb 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,9 @@ -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'; const CRD_LIST_URL = '/k8s/cluster/apiextensions.k8s.io~v1~CustomResourceDefinition'; @@ -14,8 +17,6 @@ export async function navigateToCRDInstances(page: Page, crd: string): 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..eca93adc6bb --- /dev/null +++ b/frontend/e2e/tests/console/crud/add-storage-crud.spec.ts @@ -0,0 +1,86 @@ +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); + }); + + 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(); + } + + 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`); + } + + 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 () => { + const details = new DetailsPage(page); + await details.clickPageAction('Add storage'); + + 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); + + 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 () => { + 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/image-pull-secret.spec.ts b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts new file mode 100644 index 00000000000..82e94651341 --- /dev/null +++ b/frontend/e2e/tests/console/crud/image-pull-secret.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from '../../../fixtures'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; + +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); + }); + + 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 listPage = new ListPage(page); + + await test.step('Navigate to Secrets and open Create Image Pull Secret form', async () => { + await page.goto(`/k8s/ns/${namespace}/secrets`); + + await listPage.clickCreateDropdownItem('Image pull secret'); + + 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 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..01dbcb9a990 --- /dev/null +++ b/frontend/e2e/tests/console/crud/other-routes.spec.ts @@ -0,0 +1,223 @@ +import type { Page } from '@playwright/test'; +import { test, expect } from '../../../fixtures'; +import { testA11y } from '../../../utils/a11y'; + +type RouteConfig = { + path: string; + assertLoaded?: (page: Page) => Promise; +}; + +async function assertLoadedListPage(page: Page): Promise { + await expect( + page.getByTestId('data-view-table').or(page.getByTestId('page-heading')).first(), + ).toBeVisible(); +} + +const routes: RouteConfig[] = [ + { + path: '/', + 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(); + } + }, + }, + { + path: '/k8s/cluster/clusterroles/view', + assertLoaded: async (page) => { + await expect(page.getByTestId('page-heading').locator('h1')).toBeAttached(); + }, + }, + { + path: '/k8s/cluster/nodes', + assertLoaded: assertLoadedListPage, + }, + { + path: '/k8s/all-namespaces/events', + assertLoaded: async (page) => { + await expect(page.getByRole('row').first()).toBeVisible(); + }, + }, + { + path: '/k8s/all-namespaces/import', + assertLoaded: async (page) => { + await expect(page.getByRole('textbox')).toBeVisible(); + }, + }, + { + path: '/api-explorer', + assertLoaded: async (page) => { + await expect(page.getByTestId('data-view-table')).toBeVisible(); + }, + }, + { + path: '/api-resource/ns/default/core~v1~Pod', + assertLoaded: async (page) => { + await expect(page.getByTestId('page-heading')).toBeVisible(); + }, + }, + { + path: '/api-resource/ns/default/core~v1~Pod/schema', + assertLoaded: async (page) => { + await expect(page.getByTestId('resource-sidebar-item').first()).toBeAttached(); + }, + }, + { + path: '/api-resource/ns/default/core~v1~Pod/instances', + assertLoaded: async (page) => { + await expect(page.getByTestId('api-explorer-resource-title')).toContainText('Pod'); + }, + }, + { + path: '/api-resource/ns/default/core~v1~Pod/access', + assertLoaded: 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', + assertLoaded: assertLoadedListPage, + }, + { + path: '/k8s/cluster/machine.openshift.io~v1~ControlPlaneMachineSet', + assertLoaded: assertLoadedListPage, + }, + { + path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~MachineSet', + assertLoaded: assertLoadedListPage, + }, + { + path: '/k8s/ns/openshift-machine-api/autoscaling.openshift.io~v1beta1~MachineAutoscaler', + assertLoaded: async (page) => { + await expect(page.getByTestId('empty-box-body')).toBeVisible(); + }, + }, + { + path: '/k8s/ns/openshift-machine-api/machine.openshift.io~v1beta1~MachineHealthCheck', + assertLoaded: assertLoadedListPage, + }, + { + path: '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfig', + assertLoaded: assertLoadedListPage, + }, + { + path: '/k8s/cluster/machineconfiguration.openshift.io~v1~MachineConfigPool', + assertLoaded: assertLoadedListPage, + }, + { + path: '/k8s/all-namespaces/monitoring.coreos.com~v1~Alertmanager', + assertLoaded: assertLoadedListPage, + }, + { + path: '/k8s/ns/openshift-monitoring/monitoring.coreos.com~v1~Alertmanager/main', + assertLoaded: async (page) => { + await expect(page.getByTestId('resource-title')).toBeVisible(); + }, + }, + { + path: '/settings/cluster', + assertLoaded: async (page) => { + await expect(page.getByTestId('cluster-version')).toBeAttached(); + }, + }, + { + path: '/search/all-namespaces?kind=config.openshift.io~v1~Console', + assertLoaded: assertLoadedListPage, + }, +]; + +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')).toHaveCount(0); + await expect(page.getByTestId('error-page')).not.toBeAttached(); + + if (route.assertLoaded) { + await route.assertLoaded(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 expect(toggle).toBeVisible(); + + 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.click(); + + const devOption = page + .getByTestId('perspective-switcher-menu-option') + .filter({ hasText: 'Developer' }); + 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..63cbc27ac41 --- /dev/null +++ b/frontend/e2e/tests/console/crud/quotas.spec.ts @@ -0,0 +1,124 @@ +import yaml from 'js-yaml'; + +import { test, expect } from '../../../fixtures'; +import { getEditorContent, setEditorContent, warmupSPA } from '../../../pages/base-page'; +import { DetailsPage } from '../../../pages/details-page'; +import { ListPage } from '../../../pages/list-page'; + +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 }) => { + namespace = `test-quotas-${Date.now()}`; + await k8sClient.createNamespace(namespace); + }); + + test.beforeEach(async ({ page }) => { + await warmupSPA(page); + }); + + test.afterAll(async ({ k8sClient }) => { + await k8sClient.deleteClusterCustomResource( + 'quota.openshift.io', + 'v1', + 'clusterresourcequotas', + clusterQuotaName, + ); + await k8sClient.deleteNamespace(namespace); + }); + + test('create ResourceQuota and ClusterResourceQuota via YAML editor', async ({ page }) => { + const listPage = new ListPage(page); + + await test.step('Create ResourceQuota via YAML editor', async () => { + await page.goto(`/k8s/ns/${namespace}/resourcequotas`); + await page.getByTestId('item-create').click(); + + 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(); + }); + + await test.step('Navigate back to list', async () => { + await new DetailsPage(page).getBreadcrumb(0).click(); + }); + + await test.step('Create ClusterResourceQuota via YAML editor', async () => { + await page.getByTestId('item-create').click(); + + 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(); + }); + }); + + test('All Projects shows ResourceQuotas', async ({ page }) => { + const listPage = new ListPage(page); + + 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 listPage = new ListPage(page); + const details = new DetailsPage(page); + + await page.goto('/k8s/all-namespaces/resourcequotas'); + await listPage.filterByName(clusterQuotaName); + await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); + + await test.step('Verify breadcrumb', async () => { + await listPage.clickRowByName(clusterQuotaName); + + await expect(details.getBreadcrumb(0)).toContainText('ClusterResourceQuota'); + }); + }); + + test('project namespace shows ResourceQuotas', async ({ page }) => { + const listPage = new ListPage(page); + + 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 listPage = new ListPage(page); + const details = new DetailsPage(page); + + await page.goto(`/k8s/ns/${namespace}/resourcequotas`); + await listPage.filterByName(clusterQuotaName); + await expect(listPage.getCell(clusterQuotaName)).toBeVisible(); + + await test.step('Verify breadcrumb', async () => { + await listPage.clickRowByName(clusterQuotaName); + + 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 new file mode 100644 index 00000000000..099c9685fde --- /dev/null +++ b/frontend/e2e/tests/console/crud/roles-rolebindings.spec.ts @@ -0,0 +1,224 @@ +import yaml from 'js-yaml'; + +import { test, expect } from '../../../fixtures'; +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'; + +test.describe('Roles and RoleBindings', { tag: ['@admin'] }, () => { + test.describe.configure({ mode: 'serial' }); + 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.beforeEach(async ({ page }) => { + await warmupSPA(page); + }); + + test.afterAll(async ({ k8sClient }) => { + const deletions = [ + 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); + }); + + test('create Role and ClusterRole via YAML editor', async ({ page }) => { + const listPage = new ListPage(page); + + await test.step('Create Role via YAML editor', async () => { + await page.goto(`/k8s/ns/${namespace}/roles`); + await page.getByTestId('item-create').click(); + + 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(); + }); + + await test.step('Navigate back to Roles list', async () => { + const details = new DetailsPage(page); + await details.getBreadcrumb(0).click(); + }); + + await test.step('Create ClusterRole via YAML editor', async () => { + await page.getByTestId('item-create').click(); + + 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(); + }); + }); + + test('create RoleBinding and ClusterRoleBinding via form', async ({ page }) => { + await test.step('Create RoleBinding', async () => { + await page.goto('/k8s/all-namespaces/rolebindings'); + 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(); + }); + + await test.step('Create ClusterRoleBinding', async () => { + await page.goto('/k8s/all-namespaces/rolebindings'); + 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(); + }); + }); + + test('displays Resource names and Verbs columns in Role rules table', async ({ page }) => { + const listPage = new ListPage(page); + + await page.goto(`/k8s/ns/${namespace}/roles`); + await listPage.filterByName(roleName); + await listPage.clickRowByName(roleName); + + 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 listPage = new ListPage(page); + + await page.goto('/k8s/all-namespaces/roles'); + await listPage.filterByCheckbox('Role', 'cluster'); + await listPage.filterByName(clusterRoleName); + await listPage.clickRowByName(clusterRoleName); + + 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) { + const resource = rolesOrBindings.toLowerCase(); + + 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', + ); + await listPage.filterByName(name); + await listPage.clickRowByName(name); + + await expect(namespaceDropdown).toContainText(namespace); + + 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 ({ + page, + }) => { + const clusterName = rolesOrBindings === 'Roles' ? clusterRoleName : clusterRoleBindingName; + const listPage = new ListPage(page); + 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'); + await listPage.filterByCheckbox( + rolesOrBindings === 'Roles' ? 'Role' : 'Kind', + 'cluster', + ); + await listPage.filterByName(clusterName); + await listPage.clickRowByName(clusterName); + + await expect(namespaceDropdown).not.toBeAttached(); + + await details.getBreadcrumb(0).click(); + 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 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', + 'cluster', + ); + await listPage.filterByName(clusterName); + await listPage.clickRowByName(clusterName); + + await expect(namespaceDropdown).not.toBeAttached(); + + await details.getBreadcrumb(0).click(); + 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..f2959ad4bd6 --- /dev/null +++ b/frontend/e2e/utils/a11y.ts @@ -0,0 +1,50 @@ +import type { Page } 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']); + +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-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/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/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'; 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)')} = (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)} diff --git a/frontend/public/components/factory/table.tsx b/frontend/public/components/factory/table.tsx index e9fefb78d15..920d7c36512 100644 --- a/frontend/public/components/factory/table.tsx +++ b/frontend/public/components/factory/table.tsx @@ -118,6 +118,7 @@ export const TableRow: FC = ({ 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/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 ( 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..63c0b00d060 100644 --- a/frontend/public/components/utils/kebab.tsx +++ b/frontend/public/components/utils/kebab.tsx @@ -70,6 +70,13 @@ const KebabItem_: FC = ({ onClick={(e) => !isDisabled && onClick(e, option)} autoFocus={autoFocus} isDisabled={isDisabled} + 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} > 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, }, }, 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"