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
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 @@ -608,6 +612,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 @@ -131,6 +131,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 @@ -101,4 +101,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 @@ -28,11 +34,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