Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
cac8b24
Remove redundant waitFor calls and unused waitForPageLoad
rhamilto Jun 9, 2026
b2c5152
CONSOLE-5278: Migrate console CRUD Cypress tests to Playwright
rhamilto Jun 4, 2026
57ee5d0
CONSOLE-5278: Fix CI failures and address CodeRabbit feedback
rhamilto Jun 5, 2026
64f7a96
CONSOLE-5278: Fix quota tests - serial mode and unique names
rhamilto Jun 8, 2026
c4fab17
CONSOLE-5278: Fix search page accordion toggle missing accessible name
rhamilto Jun 8, 2026
8448074
CONSOLE-5278: Address migration review findings
rhamilto Jun 8, 2026
5f86d2a
CONSOLE-5278: Reconcile shared e2e helpers with PR 16556
rhamilto Jun 8, 2026
6a2f7ba
CONSOLE-5278: Address PR review feedback for e2e test migration
rhamilto Jun 9, 2026
9146698
CONSOLE-5278: Remove redundant waitFor calls before Playwright actions
rhamilto Jun 9, 2026
6d636b2
CONSOLE-5278: Remove redundant waitFor calls per review feedback
rhamilto Jun 9, 2026
6435824
CONSOLE-5278: Remove DetailsPage.waitForPageLoad()
rhamilto Jun 9, 2026
231fadc
CONSOLE-5278: Add deleteClusterCustomResource to migration docs
rhamilto Jun 9, 2026
0825491
CONSOLE-5278: Clean up unused method and extra blank lines
rhamilto Jun 9, 2026
cc6ca35
CONSOLE-5277: Migrate resource-crud Cypress test to Playwright
rhamilto Jun 4, 2026
b90047c
CONSOLE-5277: Migrate namespace-crud Cypress test to Playwright
rhamilto Jun 4, 2026
135cb0c
CONSOLE-5277: Migrate customresourcedefinition Cypress test to Playwr…
rhamilto Jun 4, 2026
d480aaf
CONSOLE-5277: Add generateTestName utility for DNS-safe test names
rhamilto Jun 4, 2026
036b855
CONSOLE-5277: Migrate annotations Cypress test to Playwright
rhamilto Jun 5, 2026
0830728
CONSOLE-5277: Migrate labels Cypress test to Playwright
rhamilto Jun 5, 2026
74d14b1
CONSOLE-5277: Migrate environment Cypress test to Playwright
rhamilto Jun 5, 2026
ba9e827
CONSOLE-5277: Migrate bulk-create-resources Cypress test to Playwright
rhamilto Jun 5, 2026
0d2787b
CONSOLE-5277: Address pre-push review findings
rhamilto Jun 5, 2026
ddf9ac9
CONSOLE-5277: Address CodeRabbit PR feedback
rhamilto Jun 5, 2026
a10a5c0
CONSOLE-5277: Add data-test to PVC delete modal cancel button
rhamilto Jun 5, 2026
643f29c
CONSOLE-5277: Fix e2e test failures after rebase onto CONSOLE-5278
rhamilto Jun 8, 2026
48d9265
yarn.lock fix after rebase
rhamilto Jun 9, 2026
8fb157d
CONSOLE-5277: Align e2e tests with migrate-cypress skill guidance
rhamilto Jun 9, 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
89 changes: 87 additions & 2 deletions .claude/migration-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,10 @@ When migrating a Cypress test that uses `cy.get('[data-test-id="x"]')` or `cy.by
| Cypress | Playwright |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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: 60000 })` | `await this.page.locator(s).waitFor({ state: 'visible', timeout: 60000 })` |
| `cy.contains(text, { timeout })` | `await this.page.getByText(text).waitFor({ state: 'visible', timeout })` |
| `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 |
| `cy.contains(text, { timeout })` | `await expect(page.getByText(text)).toBeVisible({ timeout })` or `await page.getByText(text).click({ timeout })` depending on what follows |
| `cy.intercept('GET', url).as('req')` + `cy.wait('@req')` | `await this.page.waitForResponse(url)` or `page.waitForResponse(resp => resp.url().includes(url))` |

### Resource Lifecycle
Expand Down Expand Up @@ -439,11 +441,94 @@ The MCP's tracked page stays on `about:blank` — use the `p` reference from the

---

## Playwright Auto-Awaiting

Playwright action methods (`fill()`, `click()`, `check()`, `uncheck()`, `selectOption()`, `type()`, `press()`) **auto-wait for the element to be actionable** (visible, enabled, stable). You do NOT need an explicit `waitFor()` before calling these actions. This includes `robustClick()` in page objects — it also auto-waits.

```typescript
// WRONG — redundant waitFor before an action
await input.waitFor({ state: 'visible' });
await input.fill('text');

// WRONG — redundant waitFor before robustClick
await action.waitFor({ state: 'visible', timeout: 10_000 });
await this.robustClick(action);

// RIGHT — actions auto-wait for actionability
await input.fill('text');
await this.robustClick(action);

// RIGHT — if you need a custom timeout, pass it to the action
await input.fill('text', { timeout: 10_000 });
await action.click({ timeout: 10_000 });
```

Only use explicit `waitFor()` when you need to wait for an element **without acting on it** — e.g., confirming navigation completed, or waiting for loading indicators to disappear:

```typescript
// OK — waiting for a state transition, not an action
await page.getByTestId('loading-indicator').waitFor({ state: 'detached' });

// OK — confirming the editor loaded before reading its content (not an action on the element)
await page.getByTestId('code-editor').waitFor({ state: 'visible' });
```

Similarly, `waitForLoadingComplete()` should not be called at the end of page object methods like `selectProject()`. The caller's next action will auto-wait for whatever element it needs.

---

## Adding `data-test` Attributes

When migrating selectors, **always check the React source** for existing `data-test` attributes before creating locators:

1. **If `data-test` already exists** on the element → use `getByTestId('value')` directly.
2. **If only a legacy attribute exists** (`data-test-id`, `data-test-rows`, `data-test-dropdown-menu`, `data-test-action`, etc.) → add `data-test="value"` to the React component source alongside the existing legacy attribute, then use `getByTestId('value')`.
3. **Never use legacy attribute selectors** like `page.locator('[data-test-rows="..."]')` or `page.locator('[data-test-dropdown-menu="..."]')` when `data-test` exists or can be added.

```typescript
// WRONG — using legacy selector directly
private readonly resourceRows = this.page.locator('[data-test-rows="resource-row"]');

// RIGHT — data-test="resource-row" already exists on the same element
private readonly resourceRows = this.page.getByTestId('resource-row');
```

---

## k8sClient Cleanup

`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
test.afterAll(async ({ k8sClient }) => {
try { await k8sClient.deleteNamespace(namespace); } catch { /* may already be deleted */ }
});

// RIGHT — k8sClient handles 404 silently
test.afterAll(async ({ k8sClient }) => {
await k8sClient.deleteNamespace(namespace);
});
```

---

## Page Object Naming

- Do NOT prefix methods or locators with `legacy`. If a locator targets an older DOM structure that will be replaced, name it for what it does, not its age (e.g., `filterByNameInput` not `legacyFilterByName`).
- Common actions (navigate to form, click create dropdown item, filter + select) should be page object methods, not inline locator chains in spec files.

---

## Things to NEVER Do

- **Never import `test` or `expect` from `@playwright/test`** — import from `e2e/fixtures`
- **Never transliterate** — `cy.get(x).click()` → `page.locator(x).click()` is not a migration. Understand intent, use idiomatic Playwright APIs
- **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`, `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.
- **Never skip cleanup** — every created resource must be tracked with `cleanup.track*()`
Expand Down
10 changes: 8 additions & 2 deletions .claude/skills/migrate-cypress/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,11 @@ If MCP is unavailable or no cluster is reachable, log a warning: "Playwright MCP
1. Create/extend page objects with locators and interaction methods. Follow the established pattern:
- Import `Locator` type from `@playwright/test` and default-import `BasePage`
- Use `getByTestId()` for `data-test` attributes, `locator()` for other selectors
- 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()`.
- 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:
```typescript
Expand Down Expand Up @@ -144,9 +147,12 @@ 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()`, `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
- **Correct layer** — locators in page objects, test scenarios in specs; common multi-step interactions belong in page object methods, not inline in specs

## Troubleshooting

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();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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 @@ -77,6 +84,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,
});
},

trackCustomResource(
name: string,
namespace: string,
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 @@ -61,20 +61,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
26 changes: 26 additions & 0 deletions frontend/e2e/pages/base-page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import type { Locator, Page } from '@playwright/test';

export async function getEditorContent(page: Page): Promise<string> {
await page.waitForFunction(() => (window as any).monaco?.editor?.getModels()?.[0], {
timeout: 10_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 default abstract class BasePage {
constructor(public readonly page: Page) {}

Expand Down Expand Up @@ -97,6 +115,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
Loading