Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
53b9856
Remove redundant waitFor calls and unused waitForPageLoad
rhamilto Jun 9, 2026
7f32399
CONSOLE-5278: Migrate console CRUD Cypress tests to Playwright
rhamilto Jun 4, 2026
1fcdaf6
CONSOLE-5278: Fix CI failures and address CodeRabbit feedback
rhamilto Jun 5, 2026
c32a9f8
CONSOLE-5278: Fix quota tests - serial mode and unique names
rhamilto Jun 8, 2026
e64c3d0
CONSOLE-5278: Fix search page accordion toggle missing accessible name
rhamilto Jun 8, 2026
3f2195a
CONSOLE-5278: Address migration review findings
rhamilto Jun 8, 2026
7d5b3f5
CONSOLE-5278: Reconcile shared e2e helpers with PR 16556
rhamilto Jun 8, 2026
ceda7f8
CONSOLE-5278: Address PR review feedback for e2e test migration
rhamilto Jun 9, 2026
7b620c5
CONSOLE-5278: Remove redundant waitFor calls before Playwright actions
rhamilto Jun 9, 2026
218c674
CONSOLE-5278: Remove redundant waitFor calls per review feedback
rhamilto Jun 9, 2026
4ae9f48
CONSOLE-5278: Remove DetailsPage.waitForPageLoad()
rhamilto Jun 9, 2026
096e83f
CONSOLE-5278: Add deleteClusterCustomResource to migration docs
rhamilto Jun 9, 2026
cf3014f
CONSOLE-5278: Clean up unused method and extra blank lines
rhamilto Jun 9, 2026
79ff522
CONSOLE-5278: Address PR #16583 review feedback
rhamilto Jun 15, 2026
a2c2c63
CONSOLE-5278: Replace Navigation page object with direct URLs
rhamilto Jun 15, 2026
8bf99d9
CONSOLE-5278: Fix CRUD test failures and add SPA warmup helper
rhamilto Jun 15, 2026
ecd03de
CONSOLE-5278: Address pre-push review feedback
rhamilto Jun 15, 2026
ef394f8
CONSOLE-5278: Fix breadcrumb tests to explicitly set project dropdown…
rhamilto Jun 15, 2026
1b2760d
CONSOLE-5278: Reset sessionStorage before breadcrumb All Projects test
rhamilto Jun 16, 2026
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
7 changes: 4 additions & 3 deletions .claude/migration-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion .claude/skills/migrate-cypress/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
121 changes: 121 additions & 0 deletions frontend/e2e/clients/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,97 @@ export default class KubernetesClient {
} as any);
}

async createConfigMap(
name: string,
namespace: string,
data: Record<string, string> = {},
): Promise<void> {
await this.k8sApi.createNamespacedConfigMap({
namespace,
body: { apiVersion: 'v1', kind: 'ConfigMap', metadata: { name, namespace }, data },
});
}

async createSecret(
name: string,
namespace: string,
data: Record<string, string> = {},
): Promise<void> {
await this.k8sApi.createNamespacedSecret({
namespace,
body: { apiVersion: 'v1', kind: 'Secret', metadata: { name, namespace }, data },
});
}

async mergePatchResource(apiPath: string, patch: object): Promise<void> {
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<void>((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<string, string | null>,
): Promise<void> {
await this.mergePatchResource(
`/api/v1/namespaces/${namespace}/configmaps/${name}`,
{ metadata: { annotations } },
);
}

async labelConfigMap(
name: string,
namespace: string,
labels: Record<string, string | null>,
): Promise<void> {
await this.mergePatchResource(
`/api/v1/namespaces/${namespace}/configmaps/${name}`,
{ metadata: { labels } },
);
}

async deleteConfigMap(name: string, namespace: string): Promise<void> {
try {
await this.k8sApi.deleteNamespacedConfigMap({ name, namespace });
Expand Down Expand Up @@ -435,6 +526,36 @@ export default class KubernetesClient {
}
}

async createClusterCustomResource(
group: string,
version: string,
plural: string,
body: Record<string, unknown>,
): Promise<unknown> {
const response = await this.coApi.createClusterCustomObject({
body,
group,
plural,
version,
});
return response;
}

async deleteClusterCustomResource(
group: string,
version: string,
plural: string,
name: string,
): Promise<void> {
try {
await this.coApi.deleteClusterCustomObject({ group, name, plural, version });
} catch (err) {
if (!isNotFound(err)) {
throw err;
}
}
}

async getCustomResource(
group: string,
version: string,
Expand Down
30 changes: 30 additions & 0 deletions frontend/e2e/fixtures/cleanup-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
shouldSkipCleanup(): boolean;
Expand Down Expand Up @@ -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;
},
Expand Down Expand Up @@ -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);
Expand Down
15 changes: 3 additions & 12 deletions frontend/e2e/pages/alertmanager-page.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -63,20 +63,11 @@ export class AlertmanagerPage extends BasePage {
}

async getYAMLContent(): Promise<string> {
// 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<void> {
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<void> {
Expand Down
37 changes: 36 additions & 1 deletion frontend/e2e/pages/base-page.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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<void> {
await page.goto('/');
await expect(page.getByTestId('page-heading')).toBeVisible();
}

export default abstract class BasePage {
constructor(public readonly page: Page) {}
Expand Down Expand Up @@ -99,6 +126,14 @@ export default abstract class BasePage {
await this.robustClick(button);
}

async getEditorContent(): Promise<string> {
return getEditorContent(this.page);
}

async setEditorContent(content: string): Promise<void> {
await setEditorContent(this.page, content);
}

async switchPerspective(target: 'Developer' | 'Administrator'): Promise<void> {
const labelMap: Record<string, string[]> = {
Administrator: ['Administrator', 'Core platform'],
Expand Down
12 changes: 10 additions & 2 deletions frontend/e2e/pages/details-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export class DetailsPage extends BasePage {
return this.pageHeading;
}

async clickPageAction(actionName: string): Promise<void> {
await this.robustClick(this.page.getByTestId('actions-menu-button'));
await this.robustClick(this.page.getByTestId(actionName));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

getBreadcrumb(index: number): Locator {
return this.page.getByTestId(`breadcrumb-link-${index}`);
}

/**
* Select a specific tab by name
*/
Expand All @@ -25,8 +34,7 @@ export class DetailsPage extends BasePage {
* Click a kebab menu action (assumes menu is already open)
*/
async clickKebabAction(actionId: string): Promise<void> {
const action = this.page.locator(`[data-test-action="${actionId}"]`);
await this.robustClick(action);
await this.robustClick(this.page.getByTestId(actionId));
}

/**
Expand Down
Loading