Skip to content
Closed
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
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,10 @@ These files are the single source of truth for architecture, coding standards, a
- [CONTRIBUTING.md](CONTRIBUTING.md) - contribution workflow and commit message conventions.
- [README.md](README.md) - project setup, build instructions, and architecture overview.

## Playwright migration

We are migrating Cypress e2e tests to Playwright. Use `/migrate-cypress` to convert test files and `/debug-test` to fix failing tests. Shared migration context (translation tables, structural rules, checklist) is in `.claude/migration-context.md`.

### Dynamic plugin SDK

- [Dynamic Plugin SDK documentation](frontend/packages/console-dynamic-plugin-sdk/README.md) - architecture, design principles, and development guidelines. Consult before modifying SDK code.
Expand Down
1 change: 1 addition & 0 deletions frontend/.eslintignore

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

.eslintignore no longer exists

Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ Godeps
dynamic-demo-plugin
.eslintrc.js
tsconfig.json
e2e/.eslintrc.json
e2e/tsconfig.json
e2e/package.json
43 changes: 43 additions & 0 deletions frontend/e2e/pages/login-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { expect } from '@playwright/test';

import BasePage from './base-page';

export class LoginPage extends BasePage {
private readonly loginButton = this.page.getByTestId('login');
private readonly usernameInput = this.page.locator('#inputUsername');
private readonly passwordInput = this.page.locator('#inputPassword');
private readonly submitButton = this.page.locator('button[type="submit"]');
private readonly userDropdownToggle = this.page.getByTestId('user-dropdown-toggle');

providerButton(provider: string) {
return this.page.getByText(provider, { exact: true });
}

async loginAs(provider: string, username: string, password: string): Promise<boolean> {
await this.page.goto('./', { timeout: 90_000, waitUntil: 'domcontentloaded' });

const authDisabled = await this.page
.evaluate(() => (window as any).SERVER_FLAGS?.authDisabled)
.catch(() => false);

if (authDisabled) {
return false;
}

const providerBtn = this.providerButton(provider);
await expect(
this.loginButton.or(this.usernameInput).or(providerBtn).first(),
).toBeVisible({ timeout: 30_000 });

if (await providerBtn.isVisible()) {
await providerBtn.click();
await expect(this.usernameInput).toBeVisible({ timeout: 30_000 });
}

await this.usernameInput.fill(username);
await this.passwordInput.fill(password);
await this.submitButton.click();
await expect(this.userDropdownToggle).toBeVisible({ timeout: 60_000 });
return true;
}
}
26 changes: 26 additions & 0 deletions frontend/e2e/pages/machine-config-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { expect } from '@playwright/test';
import type { Locator } from '@playwright/test';

import { DetailsPage } from './details-page';

export class MachineConfigPage extends DetailsPage {
readonly configFilePath = this.page.getByTestId('config-file-path-0');
readonly copyToClipboard = this.page.locator('.co-copy-to-clipboard__text');

sectionHeading(heading: string): Locator {
return this.page.locator(`[data-test-section-heading="${heading}"]`);
}

async checkConfigFileDetails(mode: number, overwrite: boolean, content: string): Promise<void> {
await this.configFilePath.scrollIntoViewIfNeeded();
await this.page.locator('button[aria-label="Info"]').first().click();
const descriptionList = this.page.locator('[class*="description-list"]');
await expect(descriptionList.getByText(String(mode), { exact: true })).toBeVisible();
await expect(descriptionList.getByText(String(overwrite), { exact: true })).toBeVisible();
const decoded = decodeURIComponent(content)
.replace(/^(data:,)/, '')
.slice(0, 30);
const codeBlock = this.page.locator('code').first();
await expect(codeBlock).toContainText(decoded);
}
Comment thread
Cragsmann marked this conversation as resolved.
}
83 changes: 83 additions & 0 deletions frontend/e2e/pages/nav-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { expect } from '@playwright/test';

import BasePage from './base-page';

export class NavPage extends BasePage {
readonly clusterSettingsHeading = this.page.locator(
'[data-test-id="cluster-settings-page-heading"]',
);

private get sidebar() {
return this.page.locator('#page-sidebar');
}

private get perspectiveSwitcherToggle() {
return this.page.locator('[data-test-id="perspective-switcher-toggle"]');
}

async perspectiveSwitcherShouldHaveText(text: string): Promise<void> {
const toggle = this.perspectiveSwitcherToggle;
await toggle.scrollIntoViewIfNeeded();

const isSinglePerspective = (await toggle.getAttribute('id')) === 'core-platform-perspective';
if (isSinglePerspective) {
await expect(toggle).toContainText(text, { timeout: 30_000 });
} else {
await expect(toggle.locator('.pf-v6-c-menu-toggle__text')).toContainText(text, {
timeout: 30_000,
});
}
}

async changePerspectiveTo(perspective: string): Promise<void> {
await this.page.waitForLoadState('domcontentloaded');
const toggle = this.perspectiveSwitcherToggle;
await toggle.scrollIntoViewIfNeeded();
await expect(toggle).toBeVisible();

const isSinglePerspective = (await toggle.getAttribute('id')) === 'core-platform-perspective';
if (isSinglePerspective) {
return;
}

const currentText = await toggle.locator('.pf-v6-c-menu-toggle__text').textContent();

if (currentText?.trim() === perspective) {
return;
}

await this.robustClick(toggle);
await expect(toggle).toHaveAttribute('aria-expanded', 'true', { timeout: 5_000 });
const option = this.page
.locator('[data-test-id="perspective-switcher-menu-option"]')
.filter({ hasText: perspective });
await this.robustClick(option);
}

async shouldHaveNavSection(path: string[]): Promise<void> {
for (const item of path) {
await expect(this.sidebar).toContainText(item);
}
}

async shouldNotHaveNavSection(path: string[]): Promise<void> {
const target = path[path.length - 1];
await expect(this.sidebar.getByText(target, { exact: true })).toBeHidden();
}

async clickNavLink(path: string[]): Promise<void> {
if (!path.length) {
throw new Error('clickNavLink requires at least one path element');
}
const navItem = this.sidebar.getByText(path[0]);
if (path.length === 1) {
await this.robustClick(navItem);
return;
}
const expanded = await navItem.getAttribute('aria-expanded');
if (expanded !== 'true') {
await this.robustClick(navItem);
}
await this.robustClick(this.sidebar.getByText(path[1]));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { test, expect } from '../../../fixtures';
import { YamlEditorPage } from '../../../pages/yaml-editor-page';

const POD_NAME = 'pod1';
const DEPLOY_NAME = 'deploy1';
const CONTAINER_NAME = 'container1';
const WARNING_FOO = '299 - "[pod-must-have-label-foo] you must provide labels: {"foo"}"';
const WARNING_BAR = '299 - "[deployment-must-have-label-bar] you must provide labels: {"bar"}"';
const LEARN_MORE_ID = 'admission-webhook-warning-learn-more';
const WARNING_ID = 'admission-webhook-warning';

test.describe('Admission Webhook warning notification', { tag: ['@admin'] }, () => {
const testNs = `e2e-admission-${Date.now()}`;

const pod1ReqObj = `apiVersion: v1
kind: Pod
metadata:
name: ${POD_NAME}-a
labels:
app: httpd
namespace: ${testNs}
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: ${CONTAINER_NAME}
image: 'image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest'
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL`;

const bulkResourcesReqObj = `apiVersion: v1
kind: Pod
metadata:
name: ${POD_NAME}-b
labels:
app: httpd
namespace: ${testNs}
spec:
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: ${CONTAINER_NAME}
image: 'image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest'
ports:
- containerPort: 8080
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${DEPLOY_NAME}
annotations: {}
namespace: ${testNs}
spec:
selector:
matchLabels:
app: deploy1
replicas: 3
template:
metadata:
labels:
app: deploy1
spec:
containers:
- name: ${CONTAINER_NAME}
image: >-
image-registry.openshift-image-registry.svc:5000/openshift/httpd:latest
ports:
- containerPort: 8080
protocol: TCP
env:
- name: app
value: frontennd
imagePullSecrets: []
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 25%
maxUnavailable: 25%
paused: false
`;

test.beforeAll(async ({ k8sClient }) => {
await k8sClient.createNamespace(testNs);
await k8sClient.waitForNamespaceReady(testNs);
});

test.afterAll(async ({ k8sClient }) => {
await k8sClient.deleteNamespace(testNs);
});

test('Create a pod and display Admission Webhook warning notification', async ({ page }) => {
const yamlEditor = new YamlEditorPage(page);

await page.goto(`/k8s/ns/${testNs}/import`);
await yamlEditor.waitForEditorReady();
await yamlEditor.setEditorContent(pod1ReqObj);

await page.route(`**/api/kubernetes/api/v1/namespaces/${testNs}/pods`, async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
const response = await route.fetch();
await route.fulfill({
response,
headers: {
...response.headers(),
Warning: WARNING_FOO,
},
});
});

await yamlEditor.clickSave();
await expect(page.locator('[data-test-section-heading="Pod details"]')).toBeVisible();

const warning = page.getByTestId(WARNING_ID);
await expect(warning).toContainText('Admission Webhook Warning');
await expect(warning).toContainText(`Pod ${POD_NAME}-a violates policy ${WARNING_FOO}`);

const learnMore = page.getByTestId(LEARN_MORE_ID);
await expect(learnMore).toContainText('Learn more');
await learnMore.click();
});
Comment thread
Cragsmann marked this conversation as resolved.

test('Create bulk resources and display Admission Webhook warning notifications', async ({
page,
}) => {
const yamlEditor = new YamlEditorPage(page);

await page.goto(`/k8s/ns/${testNs}/import`);
await yamlEditor.waitForEditorReady();
await yamlEditor.setEditorContent(bulkResourcesReqObj);

await page.route(`**/api/kubernetes/api/v1/namespaces/${testNs}/pods`, async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
const response = await route.fetch();
await route.fulfill({
response,
headers: {
...response.headers(),
Warning: WARNING_FOO,
},
});
});

await page.route(
`**/api/kubernetes/apis/apps/v1/namespaces/${testNs}/deployments`,
async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
const response = await route.fetch();
await route.fulfill({
response,
headers: {
...response.headers(),
Warning: WARNING_BAR,
},
});
},
);

await yamlEditor.clickSave();

await expect(page.getByTestId('resources-successfully-created')).toContainText(
'Resources successfully created',
);

const warning = page.getByTestId(WARNING_ID);
await expect(warning).toHaveCount(2);
await expect(warning.first()).toContainText('Admission Webhook Warning');
await expect(
warning.filter({ hasText: `Pod ${POD_NAME}-b violates policy ${WARNING_FOO}` }),
).toBeVisible();
await expect(
warning.filter({ hasText: `Deployment ${DEPLOY_NAME} violates policy ${WARNING_BAR}` }),
).toBeVisible();

const learnMore = page.getByTestId(LEARN_MORE_ID);
await expect(learnMore.first()).toContainText('Learn more');
await learnMore.first().click();
});
});
Loading