Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 47 additions & 28 deletions frontend/e2e/clients/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,34 +607,6 @@ export default class KubernetesClient {
});
}

async createDeployment(
name: string,
namespace: string,
labels?: Record<string, string>,
): Promise<void> {
await this.appsApi.createNamespacedDeployment({
namespace,
body: {
metadata: { name, namespace, labels },
spec: {
replicas: 1,
selector: { matchLabels: { app: name } },
template: {
metadata: { labels: { app: name } },
spec: {
containers: [
{
name: 'container',
image: 'registry.access.redhat.com/ubi9/ubi-minimal:latest',
command: ['sleep', 'infinity'],
},
],
},
},
},
},
});
}

async deletePod(name: string, namespace: string): Promise<void> {
try {
Expand All @@ -645,4 +617,51 @@ export default class KubernetesClient {
}
}
}

async waitForPodReady(name: string, namespace: string, timeoutMs = 120_000): Promise<boolean> {
return pollUntil(
async () => {
try {
const pod = await this.k8sApi.readNamespacedPod({ name, namespace });
if (pod?.status?.phase !== 'Running') {
return false;
}
const containers = pod?.status?.containerStatuses;
if (!containers || containers.length === 0) {
return false;
}
return containers.every((c) => c.ready === true);
} catch {
Comment thread
stefanonardo marked this conversation as resolved.
return false;
}
},
timeoutMs,
2_000,
);
}

async createDeployment(namespace: string, body: Partial<k8s.V1Deployment>): Promise<void> {
await this.appsApi.createNamespacedDeployment({ namespace, body: body as k8s.V1Deployment });
}

async waitForDeploymentReady(
name: string,
namespace: string,
timeoutMs = 120_000,
): Promise<boolean> {
return pollUntil(
async () => {
try {
const dep = await this.appsApi.readNamespacedDeployment({ name, namespace });
const ready = dep?.status?.readyReplicas ?? 0;
const desired = dep?.spec?.replicas ?? 1;
return ready >= desired;
} catch {
return false;
}
},
timeoutMs,
2_000,
);
}
}
5 changes: 5 additions & 0 deletions frontend/e2e/pages/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export default abstract class BasePage {
await this.waitForLoadingComplete();
}

protected async retryOnError(): Promise<void> {
await this.page.reload({ waitUntil: 'domcontentloaded' });
await this.waitForLoadingComplete();
}

protected locator(
selector: string,
options?: {
Expand Down
24 changes: 24 additions & 0 deletions frontend/e2e/pages/catalog-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { type Locator, expect } from '@playwright/test';

import BasePage from './base-page';

export class CatalogPage extends BasePage {
private readonly filterInput: Locator = this.page.getByPlaceholder('Filter by keyword');

async navigateToCatalog(): Promise<void> {
await this.goTo('/catalog/all-namespaces');
await expect(this.filterInput).toBeVisible({ timeout: 60_000 });
}

async filterByKeyword(keyword: string): Promise<void> {
await this.filterInput.fill(keyword);
}

catalogItem(testId: string): Locator {
return this.page.getByTestId(testId);
}

catalogItemIcon(testId: string): Locator {
return this.catalogItem(testId).locator('img.catalog-tile-pf-icon');
}
Comment on lines +6 to +23

@coderabbitai coderabbitai Bot May 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Prefer getByTestId/data-test selectors for new catalog locators.

Line 6 (getByPlaceholder) and Line 22 (CSS class selector) make this page object more brittle than necessary. Please switch these to getByTestId(...) backed by stable data-test attributes.

As per coding guidelines: “Always use page.getByTestId('x') for Playwright selectors… If a React element only has a legacy test attribute, add data-test to the element.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/pages/catalog-page.ts` around lines 6 - 23, Replace brittle
selectors by switching the filterInput and catalogItemIcon locators to use
getByTestId backed by data-test attributes: update the private filterInput (used
in navigateToCatalog and filterByKeyword) to
this.page.getByTestId('your-filter-testid') and change catalogItemIcon (used by
catalogItemIcon(testId)) to return
this.catalogItem(testId).getByTestId('catalog-icon-testid') (or a per-item
data-test attribute). Ensure the page elements have corresponding data-test
attributes in the app, and keep the existing waitFor/timeout logic in
navigateToCatalog and fill logic in filterByKeyword untouched.

@fsgreco fsgreco May 21, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still better @stefanonardo here you could use getByRole('img') if there's only one image in the tile. Minor improvement though, non blocker.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@fsgreco the icon <img> has alt="" so it won't appear in the accessibility tree. Setting a non-empty value requires too many changes which I think are out of scope for this PR

}
58 changes: 27 additions & 31 deletions frontend/e2e/pages/details-page.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import type { Locator } from '@playwright/test';
import { type Locator, expect } from '@playwright/test';

import BasePage from './base-page';

export class DetailsPage extends BasePage {
private readonly pageHeading = this.page.getByTestId('page-heading');
private readonly resourceTitle = this.page.getByTestId('resource-title');
private readonly skeletonLoader = this.page.getByTestId('skeleton-detail-view');
readonly nodeTerminalError: Locator = this.page.getByTestId('node-terminal-error');
readonly xtermViewport: Locator = this.page.locator('.xterm-viewport');

get title(): Locator {
return this.resourceTitle;
}

/**
* Get the page heading locator
*/
getPageHeading(): Locator {
return this.pageHeading;
}

async waitForPageLoad(): Promise<void> {
try {
// eslint-disable-next-line no-restricted-syntax
await this.skeletonLoader.waitFor({ state: 'detached', timeout: 30_000 });
} catch {
// Skeleton may have already disappeared
}
await expect(this.resourceTitle.or(this.pageHeading).first()).toBeVisible({
timeout: 30_000,
});
}

tab(name: string): Locator {
return this.page.getByTestId(`horizontal-link-${name}`);
}

async clickPageAction(actionName: string): Promise<void> {
await this.robustClick(this.page.getByTestId('actions-menu-button'));
await this.robustClick(this.page.getByTestId(actionName));
Expand All @@ -21,56 +42,31 @@ export class DetailsPage extends BasePage {
return this.page.getByTestId(`breadcrumb-link-${index}`);
}

/**
* Select a specific tab by name
*/
async selectTab(tabName: string): Promise<void> {
const tab = this.page.getByTestId(`horizontal-link-${tabName}`);
await this.robustClick(tab);
await this.waitForLoadingComplete();
async selectTab(name: string): Promise<void> {
await this.navigateToTab(this.tab(name));
}

/**
* Click a kebab menu action (assumes menu is already open)
*/
async clickKebabAction(actionId: string): Promise<void> {
await this.robustClick(this.page.getByTestId(actionId));
}

/**
* Get a resource row link by test ID (e.g., for ClusterOperators or Configuration resources)
* Uses data-test attribute (modern selector convention)
*/
getResourceRow(resourceId: string): Locator {
// Prefer the link with data-test (for Configuration resources)
// Fall back to any element with data-test or data-test-action
const link = this.page.locator(`a[data-test="${resourceId}"]`);
const fallback = this.page.locator(
`[data-test="${resourceId}"], [data-test-action="${resourceId}"]`,
);

// Return link if it exists, otherwise fallback
return link.or(fallback).first();
}

/**
* Click a resource row to navigate to its details
*/
async clickResourceRow(resourceId: string): Promise<void> {
const row = this.getResourceRow(resourceId);
await this.robustClick(row);
}

/**
* Get a resource row by test-action attribute (for Configuration resources)
*/
getResourceByAction(actionName: string): Locator {
return this.page.locator(`[data-test-action="${actionName}"]`);
}

/**
* Click a resource in Configuration tab and open its kebab menu
*/
async openResourceKebabMenu(actionName: string): Promise<void> {
const resourceRow = this.getResourceByAction(actionName);
const kebabButton = resourceRow.getByTestId('kebab-button');
Expand Down
63 changes: 58 additions & 5 deletions frontend/e2e/pages/list-page.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,56 @@
import type { Locator } from '@playwright/test';
import { type Locator, expect } from '@playwright/test';

import BasePage from './base-page';

export class ListPage extends BasePage {
private readonly dataViewTable = this.page.getByTestId('data-view-table');
private readonly pageHeading: Locator = this.page.getByTestId('page-heading').locator('h1');
private readonly dataViewTable: Locator = this.page.getByTestId('data-view-table');
private readonly dataViewCells: Locator = this.page.locator('[data-test^="data-view-cell-"]');
private readonly nameFilterInput = this.page.getByRole('textbox', { name: 'Filter by name' });
private readonly dataViewFilters = this.page.locator(
'[data-ouia-component-id="DataViewFilters"]',
);
private readonly singleFilterGroup: Locator = this.page.locator(
'.co-console-data-view-single-filter .pf-v6-c-toolbar__group.pf-m-filter-group',
);
Comment thread
stefanonardo marked this conversation as resolved.
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');

get heading(): Locator {
return this.pageHeading;
}

get table(): Locator {
return this.dataViewTable;
}

get cells(): Locator {
return this.dataViewCells;
}

get filterGroupToggles(): Locator {
return this.singleFilterGroup.locator('.pf-v6-c-menu-toggle');
}

cell(resourceName: string, cellName = 'name'): Locator {
return this.page.getByTestId(`data-view-cell-${resourceName}-${cellName}`);
}

resourceLink(name: string): Locator {
return this.page.getByTestId(name);
}

async waitForRows(): Promise<void> {
try {
await expect(this.dataViewTable).toBeVisible({ timeout: 15_000 });
} catch {
await this.retryOnError();
await expect(this.dataViewTable).toBeVisible({ timeout: 30_000 });
}
}

async filterByName(name: string): Promise<void> {
const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first();
await this.robustClick(filterToggle, { timeout: 60_000 });
Expand Down Expand Up @@ -109,9 +147,24 @@ export class ListPage extends BasePage {
}
}

async clickFirstLinkInFirstRow(): Promise<void> {
const link = this.page.locator('[data-test^="data-view-cell-"]').first().locator('a').first();
await this.robustClick(link);
async clickFirstRowLink(): Promise<void> {
const firstLink = this.dataViewCells.first().locator('a').first();
await this.robustClick(firstLink);
}

async clickFirstRowLinkMatching(pattern: RegExp): Promise<void> {
const safeFlags = pattern.flags.replace(/[gy]/g, '');
const safePattern = new RegExp(pattern.source, safeFlags);
const links = this.dataViewCells.locator('a');
const count = await links.count();
for (let i = 0; i < count; i++) {
const text = await links.nth(i).textContent();
if (text && safePattern.test(text)) {
await this.robustClick(links.nth(i));
Comment thread
stefanonardo marked this conversation as resolved.
return;
}
}
throw new Error(`No row link matching ${pattern} found`);
}

async getFirstCellText(): Promise<string> {
Expand Down
69 changes: 69 additions & 0 deletions frontend/e2e/pages/logs-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Locator } from '@playwright/test';
import { expect } from '@playwright/test';

import BasePage from './base-page';

export class LogsPage extends BasePage {
readonly lineCount: Locator = this.page.getByTestId('resource-log-no-lines');
private readonly optionsToggle: Locator = this.page.getByTestId('resource-log-options-toggle');
private readonly showFullLogOption: Locator = this.page.getByTestId('show-full-log');
private readonly wrapLinesOption: Locator = this.page.getByTestId('wrap-lines');
private readonly wrapCheckbox: Locator = this.wrapLinesOption.locator('input[type="checkbox"]');
private readonly containerSelect: Locator = this.page.getByTestId('container-select');
private readonly searchInput: Locator = this.page.getByPlaceholder('Search logs');
readonly searchMatches: Locator = this.page.locator('.pf-m-match');
readonly logText: Locator = this.page.locator('.pf-v6-c-log-viewer__text');

async waitForLoaded(): Promise<void> {
await expect
.poll(
async () => {
if (await this.optionsToggle.isVisible().catch(() => false)) {
return true;
}
const tryAgain = this.page.getByRole('button', { name: 'Try again' });
if (await tryAgain.isVisible().catch(() => false)) {
await this.page.reload({ waitUntil: 'domcontentloaded' });
}
return false;
},
{ timeout: 30_000, intervals: [3_000] },
)
.toBe(true);
}

async toggleOptions(): Promise<void> {
await this.robustClick(this.optionsToggle);
}

async clickShowFullLog(): Promise<void> {
await this.toggleOptions();
await this.robustClick(this.showFullLogOption);
}

async setWrap(enabled: boolean): Promise<void> {
await this.toggleOptions();
if (enabled) {
await this.wrapCheckbox.check();
} else {
await this.wrapCheckbox.uncheck();
}
await this.toggleOptions();
}

async isWrapChecked(): Promise<boolean> {
await this.toggleOptions();
const checked = await this.wrapCheckbox.isChecked();
await this.toggleOptions();
return checked;
}

async selectContainer(name: string): Promise<void> {
await this.robustClick(this.containerSelect);
await this.robustClick(this.page.getByTestId(name));
}

async searchLogs(text: string): Promise<void> {
await this.searchInput.fill(text);
}
}
Loading