Skip to content
Open
97 changes: 95 additions & 2 deletions frontend/e2e/clients/kubernetes-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,12 @@ export default class KubernetesClient {

async createNamespace(name: string, labels?: Record<string, string>): Promise<void> {
try {
await this.k8sApi.readNamespace({ name });
return; // already exists
const { status } = await this.k8sApi.readNamespace({ name });
if (status?.phase === 'Terminating') {
await this.waitForNamespaceDeleted(name);
} else {
return; // already exists and is active
}
} catch (err) {
if (!isNotFound(err)) {
throw err;
Expand Down Expand Up @@ -636,6 +640,95 @@ export default class KubernetesClient {
});
}

async waitForDeploymentReady(
name: string,
namespace: string,
timeoutMs = 120_000,
): Promise<void> {
const ready = await pollUntil(
async () => {
try {
const deployment = await this.appsApi.readNamespacedDeployment({ name, namespace });
const status = deployment.status;
const desired = deployment.spec?.replicas ?? 1;
return (
status?.availableReplicas === desired &&
status?.updatedReplicas === desired &&
(status?.conditions ?? []).some(
(c) => c.type === 'Available' && c.status === 'True',
)
);
} catch {
return false;
}
},
timeoutMs,
2_000,
);
if (!ready) {
const diag = await this.getDeploymentDiagnostics(name, namespace);
throw new Error(
`Deployment ${namespace}/${name} not ready after ${timeoutMs / 1000}s.\n${diag}`,
);
}
}

private async getDeploymentDiagnostics(name: string, namespace: string): Promise<string> {
const lines: string[] = [];
try {
const deployment = await this.appsApi.readNamespacedDeployment({ name, namespace });
const conditions = deployment.status?.conditions ?? [];
lines.push(
`Deployment status: replicas=${deployment.status?.replicas ?? 0}, ` +
`ready=${deployment.status?.readyReplicas ?? 0}, ` +
`available=${deployment.status?.availableReplicas ?? 0}, ` +
`updated=${deployment.status?.updatedReplicas ?? 0}`,
);
for (const c of conditions) {
lines.push(` condition ${c.type}=${c.status}: ${c.message ?? ''}`);
}
} catch (err) {
lines.push(`Could not read deployment: ${err}`);
}
try {
const pods = await this.k8sApi.listNamespacedPod({ namespace, labelSelector: `app=${name}` });
for (const pod of pods.items) {
const podName = pod.metadata?.name ?? 'unknown';
const phase = pod.status?.phase ?? 'Unknown';
lines.push(`Pod ${podName}: phase=${phase}`);
for (const cs of pod.status?.containerStatuses ?? []) {
const state = cs.state?.waiting
? `Waiting: ${cs.state.waiting.reason} - ${cs.state.waiting.message ?? ''}`
: cs.state?.terminated
? `Terminated: ${cs.state.terminated.reason}`
: 'Running';
lines.push(` container ${cs.name}: ready=${cs.ready}, restarts=${cs.restartCount}, ${state}`);
}
try {
const events = await this.k8sApi.listNamespacedEvent({
namespace,
fieldSelector: `involvedObject.name=${podName}`,
});
const recent = events.items
.sort(
(a, b) =>
new Date(b.lastTimestamp ?? 0).getTime() -
new Date(a.lastTimestamp ?? 0).getTime(),
)
.slice(0, 10);
for (const ev of recent) {
lines.push(` event: ${ev.reason} - ${ev.message} (count=${ev.count ?? 1})`);
}
} catch {
lines.push(` Could not fetch events for pod ${podName}`);
}
}
} catch (err) {
lines.push(`Could not list pods: ${err}`);
}
return lines.join('\n');
}

async deletePod(name: string, namespace: string): Promise<void> {
try {
await this.k8sApi.deleteNamespacedPod({ name, namespace });
Expand Down
7 changes: 7 additions & 0 deletions frontend/e2e/pages/base-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ export default abstract class BasePage {
await this.robustClick(button);
}

async waitForEditorReady(): Promise<void> {
await this.page.waitForFunction(
() => !!(window as any).monaco?.editor?.getModels()?.[0],
{ timeout: 30_000 },
);
}

async getEditorContent(): Promise<string> {
return getEditorContent(this.page);
}
Expand Down
14 changes: 14 additions & 0 deletions frontend/e2e/pages/cluster-dashboard-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,18 @@ export class ClusterDashboardPage extends BasePage {
await this.robustClick(this.insightsButton);
await expect(this.popover).toBeVisible({ timeout: 10_000 });
}

async isInsightsDataAvailable(): Promise<boolean> {
const popover = this.popover;
const timeout = 30_000;
/* eslint-disable no-restricted-syntax */
const result = await Promise.race([
popover.getByText('Temporarily unavailable.').waitFor({ state: 'visible', timeout }).then(() => 'no-data' as const),
popover.getByText('Waiting for results.').waitFor({ state: 'visible', timeout }).then(() => 'no-data' as const),
popover.getByText('Disabled.').waitFor({ state: 'visible', timeout }).then(() => 'no-data' as const),
popover.locator('a[href*="console.redhat.com/openshift/insights/advisor"]').first().waitFor({ state: 'visible', timeout }).then(() => 'data' as const),
]).catch(() => 'no-data' as const);
/* eslint-enable no-restricted-syntax */
return result === 'data';
}
}
80 changes: 80 additions & 0 deletions frontend/e2e/pages/console-plugin-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import type { Locator } from '@playwright/test';

import BasePage from './base-page';

export class ConsolePluginPage extends BasePage {
private readonly codeEditor = this.page.locator('.co-code-editor');
private readonly pfCodeEditor = this.page.locator('.pf-v6-c-code-editor');

async navigateToConsolePlugins(): Promise<void> {
await this.goTo(
'/k8s/cluster/operator.openshift.io~v1~Console/cluster/console-plugins',
);
}

async navigateToPluginDetails(pluginName: string): Promise<void> {
await this.goTo(
`/k8s/cluster/console.openshift.io~v1~ConsolePlugin/${pluginName}`,
);
}

async navigateToPluginManifest(pluginName: string): Promise<void> {
await this.goTo(
`/k8s/cluster/console.openshift.io~v1~ConsolePlugin/${pluginName}/plugin-manifest`,
);
}

getPluginNameCell(pluginName: string): Locator {
return this.page.getByTestId(`${pluginName}-name`);
}

getPluginStatusCell(pluginName: string): Locator {
return this.page.getByTestId(`${pluginName}-status`);
}

getCodeEditor(): Locator {
return this.codeEditor;
}

getReadOnlyCodeEditor(): Locator {
return this.pfCodeEditor;
}

getEmptyBox(): Locator {
return this.page.getByTestId('empty-box');
}

async clickEditPluginButton(pluginName: string): Promise<void> {
const row = this.getPluginNameCell(pluginName).locator('xpath=ancestor::tr');
const editButton = row.getByTestId('edit-console-plugin');
await this.robustClick(editButton);
}

async navigateToOverview(): Promise<void> {
await this.goTo('/');
}

async navigateToDynamicRoute(id: string): Promise<void> {
await this.goTo(`/dynamic-route-${id}`);
}

async navigateToTestUtilities(): Promise<void> {
await this.goTo('/test-utility-consumer');
}

async navigateToDemoListPage(): Promise<void> {
await this.goTo('/demo-list-page');
}

async navigateToK8sApi(): Promise<void> {
await this.goTo('/test-k8sapi');
}

async navigateToProjects(): Promise<void> {
await this.goTo('/k8s/cluster/projects');
}

async navigateWithQueryParam(queryString: string): Promise<void> {
await this.goTo(`/?${queryString}`);
}
}
5 changes: 5 additions & 0 deletions frontend/e2e/pages/modal-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ import { expect } from '@playwright/test';
import BasePage from './base-page';

export class ModalPage extends BasePage {
private readonly modalTitle = this.page.getByTestId('modal-title');
private readonly cancelButton = this.page.getByTestId('modal-cancel-action');
private readonly submitButton = this.page.getByTestId('confirm-action');

getModalTitle(): Locator {
return this.modalTitle;
}

getCancelButton(): Locator {
return this.cancelButton;
}
Expand Down
82 changes: 82 additions & 0 deletions frontend/e2e/pages/yaml-editor-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@ import { expect } from '@playwright/test';

import BasePage from './base-page';

const SETTINGS_MODAL_ID = 'edit-yaml-settings-modal';

export class YamlEditorPage extends BasePage {
private readonly codeEditor = this.page.getByTestId('code-editor');
private readonly saveButton = this.page.getByTestId('save-changes');
private readonly reloadButton = this.page.getByTestId('reload-object');
private readonly yamlError = this.page.getByTestId('yaml-error');
private readonly resourceSidebar = this.page.getByTestId('resource-sidebar');

async navigateToImportYaml(): Promise<void> {
await this.goTo('/k8s/ns/default/import');
}

async waitForEditorReady(): Promise<void> {
await expect(this.codeEditor).toBeVisible({ timeout: 30_000 });
}
Expand All @@ -30,11 +36,87 @@ export class YamlEditorPage extends BasePage {
return this.yamlError;
}

getMonacoEditor(): Locator {
return this.page.locator('.monaco-editor').first();
}

getMonacoViewLines(): Locator {
return this.page.locator('.monaco-editor .view-lines').first();
}

getSettingsModal(): Locator {
return this.page.locator(`[data-ouia-component-id="${SETTINGS_MODAL_ID}"]`);
}

getSettingsModalTitle(): Locator {
return this.page.locator(`#${SETTINGS_MODAL_ID}-title`);
}

getSettingsModalBody(): Locator {
return this.page.locator(`#${SETTINGS_MODAL_ID}-body`);
}

getFontSizeInput(): Locator {
return this.page
.locator('#ConfigModalItem-font-size')
.locator('input[aria-label="Enter a font size"]');
}

getFontSizeIncreaseButton(): Locator {
return this.page
.locator('#ConfigModalItem-font-size')
.locator('button[aria-label="Increase font size"]');
}

getFontSizeDecreaseButton(): Locator {
return this.page
.locator('#ConfigModalItem-font-size')
.locator('button[aria-label="Decrease font size"]');
}

async clickSave(): Promise<void> {
await this.robustClick(this.saveButton);
}

async clickReload(): Promise<void> {
await this.robustClick(this.reloadButton);
}

async openSettingsModal(): Promise<void> {
await this.robustClick(this.page.locator('[aria-label="Editor settings"]'));
// eslint-disable-next-line no-restricted-syntax
await this.getSettingsModal().waitFor({ state: 'visible' });
}

async closeSettingsModal(): Promise<void> {
await this.robustClick(
this.getSettingsModal().locator('button[aria-label="Close"]'),
);
}

async selectTheme(themeName: 'Dark' | 'Light' | 'Use theme setting'): Promise<void> {
const themeSection = this.page.locator('#ConfigModalItem-color-theme');
await this.robustClick(
themeSection.locator('button[aria-labelledby="ConfigModalItem-color-theme-title"]'),
);
await this.page.getByText(themeName, { exact: true }).click();
}

async setFontSize(size: number): Promise<void> {
const input = this.getFontSizeInput();
await input.fill(String(size));
}

async showSidebar(): Promise<void> {
await this.robustClick(this.page.locator('[aria-label="Show sidebar"]'));
}

async clickFieldDetailsButton(fieldName: string): Promise<void> {
const fieldHeading = this.page.locator('h5', { hasText: fieldName });
const listItem = fieldHeading.locator('xpath=ancestor::li');
const viewDetailsButton = listItem.locator('button.pf-v6-c-button', {
hasText: 'View details',
});
await this.robustClick(viewDetailsButton);
}
}
Loading