-
Notifications
You must be signed in to change notification settings - Fork 702
CONSOLE-5235: Migrate basic app Cypress e2e tests to Playwright #16431
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import type { Locator } from '@playwright/test'; | ||
|
|
||
| import BasePage from './base-page'; | ||
|
|
||
| export class CatalogPage extends BasePage { | ||
| private readonly filterInput: Locator = this.page.locator( | ||
| 'input[placeholder*="Filter by keyword"]', | ||
| ); | ||
|
|
||
| async navigateToCatalog(): Promise<void> { | ||
| await this.goTo('/catalog/all-namespaces'); | ||
| await this.filterInput.waitFor({ state: 'visible', 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'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import type { Locator } from '@playwright/test'; | ||
|
|
||
| import BasePage from './base-page'; | ||
|
|
||
| export class DetailsPage extends BasePage { | ||
| private readonly skeletonView: Locator = this.page.getByTestId('skeleton-detail-view'); | ||
| private readonly resourceTitle: Locator = this.page.locator('[data-test-id="resource-title"]'); | ||
| readonly nodeTerminalError: Locator = this.page.getByTestId('node-terminal-error'); | ||
| readonly xtermViewport: Locator = this.page.locator('.xterm-viewport'); | ||
|
|
||
| get title(): Locator { | ||
| return this.resourceTitle; | ||
| } | ||
|
|
||
| async waitForLoaded(): Promise<void> { | ||
| await this.skeletonView.waitFor({ state: 'hidden', timeout: 30_000 }).catch(() => {}); | ||
| await this.resourceTitle.waitFor({ state: 'visible', timeout: 30_000 }); | ||
| } | ||
|
|
||
| tab(name: string): Locator { | ||
| return this.page.locator(`[data-test-id="horizontal-link-${name}"]`); | ||
| } | ||
|
|
||
| async selectTab(name: string): Promise<void> { | ||
| await this.navigateToTab(this.tab(name)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,68 @@ | ||||||||||||||||||||||||||||||||||||||||||
| import type { Locator } from '@playwright/test'; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| import BasePage from './base-page'; | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| export class ListPage extends BasePage { | ||||||||||||||||||||||||||||||||||||||||||
| private readonly pageHeading: Locator = this.page.getByTestId('page-heading').locator('h1'); | ||||||||||||||||||||||||||||||||||||||||||
| private readonly dataViewTable: Locator = this.page.getByTestId('data-view-table'); | ||||||||||||||||||||||||||||||||||||||||||
| private readonly dataViewCells: Locator = this.page.locator('[data-test^="data-view-cell-"]'); | ||||||||||||||||||||||||||||||||||||||||||
| private readonly dataViewFilters: Locator = this.page.locator( | ||||||||||||||||||||||||||||||||||||||||||
| '[data-ouia-component-id="DataViewFilters"]', | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
| private readonly nameFilterInput: Locator = this.page.locator('[aria-label="Filter by name"]'); | ||||||||||||||||||||||||||||||||||||||||||
| private readonly singleFilterGroup: Locator = this.page.locator( | ||||||||||||||||||||||||||||||||||||||||||
| '.co-console-data-view-single-filter .pf-v6-c-toolbar__group.pf-m-filter-group', | ||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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.locator(`[data-test="data-view-cell-${resourceName}-${cellName}"]`); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| async waitForRows(): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||
| await this.dataViewTable.waitFor({ state: 'visible', timeout: 30_000 }); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| async filterByName(name: string): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||
| const filterToggle = this.dataViewFilters.locator('.pf-v6-c-menu-toggle').first(); | ||||||||||||||||||||||||||||||||||||||||||
| await this.robustClick(filterToggle); | ||||||||||||||||||||||||||||||||||||||||||
| await this.robustClick(this.page.locator('.pf-v6-c-menu__list-item', { hasText: 'Name' })); | ||||||||||||||||||||||||||||||||||||||||||
| await this.nameFilterInput.waitFor({ state: 'visible' }); | ||||||||||||||||||||||||||||||||||||||||||
| await this.nameFilterInput.fill(name); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||
| 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 on lines
+54
to
+62
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# First, let's see the exact content of the file at the mentioned lines
head -70 frontend/e2e/pages/list-page.ts | tail -25Repository: openshift/console Length of output: 989 🏁 Script executed: #!/bin/bash
# Search for the method in the file and surrounding context
rg -A 15 "clickFirstRowLinkMatching" frontend/e2e/pages/list-page.tsRepository: openshift/console Length of output: 494 🏁 Script executed: #!/bin/bash
# Search for usage of clickFirstRowLinkMatching to see how it's called
rg "clickFirstRowLinkMatching" --type tsRepository: openshift/console Length of output: 292 🏁 Script executed (no clone): Length of output: 268 🏁 Script executed (no clone): Length of output: 495 Avoid stateful The method accepts a Strip problematic flags to ensure consistent matching behavior across loop iterations: Suggested fix async clickFirstRowLinkMatching(pattern: RegExp): Promise<void> {
const links = this.dataViewCells.locator('a');
const count = await links.count();
+ const safePattern = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
for (let i = 0; i < count; i++) {
const text = await links.nth(i).textContent();
- if (text && pattern.test(text)) {
+ if (text && safePattern.test(text)) {
await this.robustClick(links.nth(i));
return;
}
}
throw new Error(`No row link matching ${pattern} found`);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||
| return; | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| throw new Error(`No row link matching ${pattern} found`); | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import type { Locator } 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.locator( | ||
| '[data-test-dropdown-menu="show-full-log"]', | ||
| ); | ||
| private readonly wrapLinesOption: Locator = this.page.locator( | ||
| '[data-test-dropdown-menu="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.locator('input[placeholder="Search logs"]'); | ||
| readonly searchMatches: Locator = this.page.locator('.pf-m-match'); | ||
| readonly logText: Locator = this.page.locator('span[class$="c-log-viewer__text"]'); | ||
|
|
||
| async waitForLoaded(): Promise<void> { | ||
| await this.optionsToggle.waitFor({ state: 'visible', timeout: 60_000 }); | ||
| } | ||
|
|
||
| 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.locator(`[data-test-dropdown-menu="${name}"]`)); | ||
| } | ||
|
|
||
| async searchLogs(text: string): Promise<void> { | ||
| await this.searchInput.fill(text); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import type { Locator } from '@playwright/test'; | ||
|
|
||
| import BasePage from './base-page'; | ||
|
|
||
| export class MastheadPage extends BasePage { | ||
| private readonly logo: Locator = this.page.getByTestId('masthead-logo'); | ||
| private readonly quickCreateToggle: Locator = this.page.getByTestId('quick-create-dropdown'); | ||
| private readonly userDropdownToggle: Locator = this.page.getByTestId('user-dropdown-toggle'); | ||
| private readonly copyLoginCommandLink: Locator = this.page | ||
| .getByTestId('copy-login-command') | ||
| .locator('a'); | ||
| private readonly logOutItem: Locator = this.page.getByTestId('log-out'); | ||
| readonly pageHeading: Locator = this.page.getByTestId('page-heading').locator('h1'); | ||
|
|
||
| get logoLocator(): Locator { | ||
| return this.logo; | ||
| } | ||
|
|
||
| async openQuickCreate(): Promise<void> { | ||
| await this.quickCreateToggle.click(); | ||
| } | ||
|
|
||
| async clickQuickCreateItem(testId: string): Promise<void> { | ||
| const item = this.page.getByTestId(testId); | ||
| await item.waitFor({ state: 'visible' }); | ||
| await item.locator('a').click({ force: true }); | ||
| } | ||
|
|
||
| async openUserDropdown(): Promise<void> { | ||
| await this.userDropdownToggle.click(); | ||
| } | ||
|
|
||
| async isAuthDisabled(): Promise<boolean> { | ||
| return this.page.evaluate(() => { | ||
| const w = window as Window & { SERVER_FLAGS?: { authDisabled?: boolean } }; | ||
| return !!w.SERVER_FLAGS?.authDisabled; | ||
| }); | ||
| } | ||
|
|
||
| async clickCopyLoginCommand(): Promise<void> { | ||
| await this.copyLoginCommandLink.waitFor({ state: 'visible' }); | ||
| await this.copyLoginCommandLink.evaluate((el: HTMLAnchorElement) => | ||
| el.removeAttribute('target'), | ||
| ); | ||
| await this.copyLoginCommandLink.click(); | ||
| } | ||
|
|
||
| async clickLogOut(): Promise<void> { | ||
| await this.logOutItem.waitFor({ state: 'visible' }); | ||
| await this.logOutItem.click({ force: true }); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import type { Locator } from '@playwright/test'; | ||
|
|
||
| import BasePage from './base-page'; | ||
|
|
||
| export class OverviewPage extends BasePage { | ||
| private readonly listView: Locator = this.page.locator('.odc-topology-list-view'); | ||
| private readonly itemRows: Locator = this.page.locator('.odc-topology-list-view__item-row'); | ||
| private readonly kindLabels: Locator = this.page.locator('.odc-topology-list-view__kind-label'); | ||
| private readonly labelCells: Locator = this.page.locator('.odc-topology-list-view__label-cell'); | ||
| private readonly sidebar: Locator = this.page.locator('.resource-overview'); | ||
| private readonly sidebarHeading: Locator = this.page.locator('.resource-overview__heading h1'); | ||
|
|
||
| get listViewLocator(): Locator { | ||
| return this.listView; | ||
| } | ||
|
|
||
| get itemRowsLocator(): Locator { | ||
| return this.itemRows; | ||
| } | ||
|
|
||
| get sidebarLocator(): Locator { | ||
| return this.sidebar; | ||
| } | ||
|
|
||
| get sidebarHeadingLocator(): Locator { | ||
| return this.sidebarHeading; | ||
| } | ||
|
|
||
| kindLabel(label: string): Locator { | ||
| return this.kindLabels.filter({ hasText: label }); | ||
| } | ||
|
|
||
| labelCell(name: string): Locator { | ||
| return this.labelCells.filter({ hasText: name }); | ||
| } | ||
|
|
||
| async navigateToWorkloads(projectName: string): Promise<void> { | ||
| await this.goTo(`/k8s/cluster/projects/${projectName}/workloads?view=list`); | ||
| } | ||
|
|
||
| async clickListItem(name: string): Promise<void> { | ||
| await this.labelCell(name).click(); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
waitForPodReadycan return before the pod is actually ready.Line 494 checks only
phase === 'Running'; pods can be Running while containers are still unready, which can race downstream assertions.Proposed fix
async waitForPodReady(name: string, namespace: string, timeoutMs = 120_000): Promise<boolean> { return pollUntil( async () => { try { const pod = await this.k8sApi.readNamespacedPod({ name, namespace }); - return pod?.status?.phase === 'Running'; + const isRunning = pod?.status?.phase === 'Running'; + const readyCondition = + pod?.status?.conditions?.find((c) => c.type === 'Ready')?.status === 'True'; + const containersReady = (pod?.status?.containerStatuses ?? []).every((c) => c.ready); + return isRunning && readyCondition && containersReady; } catch { return false; } }, timeoutMs, 2_000, ); }🤖 Prompt for AI Agents