Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,4 @@ cypress-a11y-report.json
/dynamic-demo-plugin/**/dist
**/.claude/settings.local.json
**/chartstore-*/
.playwright-mcp
61 changes: 61 additions & 0 deletions frontend/e2e/clients/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,4 +471,65 @@ export default class KubernetesClient {
const response = await this.k8sApi.listNamespacedPod({ namespace });
return response.items || [];
}

async createPod(namespace: string, body: Partial<k8s.V1Pod>): Promise<void> {
await this.k8sApi.createNamespacedPod({ namespace, body: body as k8s.V1Pod });
}

async deletePod(name: string, namespace: string): Promise<void> {
try {
await this.k8sApi.deleteNamespacedPod({ name, namespace });
} catch (err) {
if (!isNotFound(err)) {
throw err;
}
}
}

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 on lines +489 to +502
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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

waitForPodReady can 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,
     );
   }
#!/bin/bash
# Inspect readiness implementation and where it is used.
rg -n -C3 --type=ts '\bwaitForPodReady\s*\(' frontend/e2e
rg -n -C4 --type=ts "status\?\.phase === 'Running'" frontend/e2e/clients/kubernetes-client.ts
🤖 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/clients/kubernetes-client.ts` around lines 489 - 495, The
waitForPodReady function currently only checks pod.status.phase === 'Running'
and can return before containers are actually ready; update the poll predicate
in waitForPodReady to fetch the pod via this.k8sApi.readNamespacedPod and then
verify readiness by (a) confirming status.phase === 'Running' and (b) ensuring
either all status.containerStatuses exist and every containerStatus.ready ===
true OR that pod.status.conditions contains a condition with type === 'Ready'
and status === 'True'; handle missing fields defensively (treat absent
containerStatuses/conditions as not ready) and keep the existing timeout/polling
behavior so callers of waitForPodReady get a true only when the pod is actually
ready.

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,
);
}
}
26 changes: 26 additions & 0 deletions frontend/e2e/pages/catalog-page.ts
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');
}
}
27 changes: 27 additions & 0 deletions frontend/e2e/pages/details-page.ts
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));
}
}
68 changes: 68 additions & 0 deletions frontend/e2e/pages/list-page.ts
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
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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 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 -25

Repository: 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.ts

Repository: openshift/console

Length of output: 494


🏁 Script executed:

#!/bin/bash
# Search for usage of clickFirstRowLinkMatching to see how it's called
rg "clickFirstRowLinkMatching" --type ts

Repository: openshift/console

Length of output: 292


🏁 Script executed (no clone):

#!/bin/bash
# Verify RegExp.test() stateful behavior with g flag
node <<'EOF'
// Test 1: Regex with g flag - shows the stateful behavior
const patternWithG = /test/g;
console.log("Test with 'g' flag:");
console.log("First call:", patternWithG.test("test")); // true
console.log("LastIndex after first call:", patternWithG.lastIndex); // 4
console.log("Second call (same string):", patternWithG.test("test")); // false - fails!
console.log("LastIndex after second call:", patternWithG.lastIndex); // 0 (reset)
console.log("Third call:", patternWithG.test("test")); // true - succeeds again

// Test 2: Regex without g flag - no state
const patternWithoutG = /test/;
console.log("\nTest without 'g' flag:");
console.log("First call:", patternWithoutG.test("test")); // true
console.log("LastIndex:", patternWithoutG.lastIndex); // 0 (never changes)
console.log("Second call:", patternWithoutG.test("test")); // true
console.log("Third call:", patternWithoutG.test("test")); // true
EOF

Length of output: 268


🏁 Script executed (no clone):

#!/bin/bash
# Verify the suggested fix works correctly
node <<'EOF'
// Simulate the original code with g flag passed
function testOriginal() {
  const pattern = /test/g; // Someone passes a regex with g flag
  const texts = ["test", "test", "no match"];
  
  for (let i = 0; i < texts.length; i++) {
    const text = texts[i];
    console.log(`Iteration ${i}: testing "${text}" with pattern.test() - Result:`, pattern.test(text));
  }
}

// Simulate the suggested fix
function testFixed() {
  const pattern = /test/g; // Someone passes a regex with g flag
  const safePattern = new RegExp(pattern.source, pattern.flags.replace(/[gy]/g, ''));
  const texts = ["test", "test", "no match"];
  
  for (let i = 0; i < texts.length; i++) {
    const text = texts[i];
    console.log(`Iteration ${i}: testing "${text}" with safePattern.test() - Result:`, safePattern.test(text));
  }
}

console.log("Original approach (buggy with g flag):");
testOriginal();

console.log("\nFixed approach (safe):");
testFixed();
EOF

Length of output: 495


Avoid stateful RegExp behavior in row matching.

The method accepts a RegExp parameter without protection against the g (global) and y (sticky) flags, which maintain state across iterations via lastIndex. Callers passing such patterns will experience skipped matches. While the current callsite uses a non-flagged pattern, defensive programming should ensure robustness.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async clickFirstRowLinkMatching(pattern: RegExp): Promise<void> {
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 && pattern.test(text)) {
await this.robustClick(links.nth(i));
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 && safePattern.test(text)) {
await this.robustClick(links.nth(i));
return;
}
}
throw new Error(`No row link matching ${pattern} found`);
}
🤖 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/list-page.ts` around lines 54 - 60, The
clickFirstRowLinkMatching method can misbehave if callers pass a RegExp with
global or sticky flags because RegExp.lastIndex is stateful; defensively create
a fresh RegExp without the g or y flags from the incoming pattern before the
loop (use pattern.source and pattern.flags filtered to remove 'g' and 'y') and
then use that new RegExp for matching inside the loop, keeping the rest of the
logic (including robustClick) unchanged.

return;
}
}
throw new Error(`No row link matching ${pattern} found`);
}
}
58 changes: 58 additions & 0 deletions frontend/e2e/pages/logs-page.ts
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);
}
}
52 changes: 52 additions & 0 deletions frontend/e2e/pages/masthead-page.ts
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 });
}
}
44 changes: 44 additions & 0 deletions frontend/e2e/pages/overview-page.ts
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();
}
}
Loading