Skip to content
Draft
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
99 changes: 99 additions & 0 deletions frontend/e2e/pages/web-terminal-config-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { Locator } from '@playwright/test';

import BasePage from './base-page';

export class WebTerminalConfigPage extends BasePage {
private readonly configSection = this.page.getByTestId('web-terminal form-section');
private readonly incrementButton = this.page.locator(
'[data-test="Increment"], [data-test-id="Increment"]',
);
private readonly selectToggle = this.page.getByTestId('console-select-menu-toggle');
Comment thread
fsgreco marked this conversation as resolved.
private readonly imageInput = this.page.getByTestId('web-terminal-image');
private readonly timeoutCheckbox = this.page.getByTestId('timeout-value-checkbox');
private readonly imageCheckbox = this.page.getByTestId('image-value-checkbox');
private readonly saveButton = this.page.getByTestId('save-button');
private readonly successAlert = this.page.getByTestId('success-alert');

async navigateToWebTerminalConfig(): Promise<void> {
await this.goTo('/k8s/cluster/operator.openshift.io~v1~Console/cluster');
Comment thread
fsgreco marked this conversation as resolved.
await this.waitForLoadingComplete(10_000);
const customizeButton = this.page.locator('button', { hasText: 'Customize' });
await customizeButton
.first()
.waitFor({ state: 'visible', timeout: 30_000 })
.catch(() => {});
if (await customizeButton.first().isVisible()) {
await this.robustClick(customizeButton.first());
} else {
Comment thread
fsgreco marked this conversation as resolved.
const actionsMenu = this.page.locator(
'[data-test="actions-menu-button"], [data-test-id="actions-menu-button"]',
);
await this.robustClick(actionsMenu);
const customizeAction = this.page.locator('[data-test-action="Customize"]:not([disabled])');
await this.robustClick(customizeAction);
}
await this.waitForLoadingComplete(10_000);
await this.clickWebTerminalTab();
}

async clickWebTerminalTab(): Promise<void> {
const tab = this.page
.locator('[role="presentation"]')
.filter({ hasText: 'Web Terminal' })
.first();
await this.robustClick(tab);
await this.waitForLoadingComplete(5_000);
}

async incrementTimeout(): Promise<void> {
await this.robustClick(this.incrementButton);
}

async selectTimeoutUnit(unit: string): Promise<void> {
await this.robustClick(this.selectToggle);
const option = this.page.getByTestId('console-select-item').filter({ hasText: unit });
await this.robustClick(option);
}

async setImageValue(image: string): Promise<void> {
await this.imageInput.fill(image);
}

async checkPersistCheckboxes(): Promise<void> {
await this.timeoutCheckbox.check();
await this.imageCheckbox.check();
}

async uncheckPersistCheckboxes(): Promise<void> {
await this.timeoutCheckbox.uncheck();
await this.imageCheckbox.uncheck();
}

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

getConfigSection(): Locator {
return this.configSection;
}

getSuccessAlert(): Locator {
return this.successAlert;
}

getImageInput(): Locator {
return this.imageInput;
}

getSelectToggle(): Locator {
return this.selectToggle;
}

getTimeoutCheckbox(): Locator {
return this.timeoutCheckbox;
}

getImageCheckbox(): Locator {
return this.imageCheckbox;
}
}
200 changes: 200 additions & 0 deletions frontend/e2e/pages/web-terminal-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import type { Locator } from '@playwright/test';

import BasePage from './base-page';

export class WebTerminalPage extends BasePage {
private readonly terminalIcon = this.page.locator(
'button[data-tour-id="tour-cloud-shell-button"]',
);
private readonly terminalContainer = this.page.locator('.co-cloudshell-terminal__container');
private readonly terminalWindow = this.page.locator('div.xterm-screen>div.xterm-rows');
private readonly addTabButton = this.page.locator(
'[data-test="multi-tab-terminal"] [aria-label="Add new tab"]',
);
private readonly closeTabButtons = this.page.locator('[aria-label="Close terminal tab"]');
private readonly tabsList = this.page.locator('[data-test="multi-tab-terminal"] ul');
private readonly drawerCloseButton = this.page.getByTestId('cloudshell-drawer-close-button');
private readonly loadingBox = this.page.getByTestId('loading-box');
private readonly timeoutLink = this.page.getByText('Timeout', { exact: true });
private readonly incrementButton = this.page.locator(
'[data-test="Increment"], [data-test-id="Increment"]',
);
private readonly timeoutInput = this.page.locator('input[aria-label="Input"]');
private readonly startButton = this.page.locator('[data-test-id="submit-button"]');
private readonly resourceTitle = this.page.locator(
'[data-test="resource-title"], [data-test-id="resource-title"]',
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
private readonly monacoEditor = this.page.locator('div.lines-content.monaco-editor-background');
private readonly openInNewTabLink = this.page.locator("a[href='/terminal']");
private readonly closeTerminalButton = this.page.locator(
Comment thread
fsgreco marked this conversation as resolved.
"button[aria-label='Close terminal'], [aria-label='Close terminal tab']",
);
private readonly inactivityMessageArea = this.page.locator('div.co-cloudshell-exec__error-msg');
private readonly perspectiveSwitcherToggle = this.page.locator(
'[data-test-id="perspective-switcher-toggle"]',
);

async waitForTerminalIconVisible(maxRetries = 10): Promise<void> {
await this.goTo('/');
try {
await this.terminalIcon.waitFor({ state: 'visible', timeout: 30_000 });
return;
} catch {
// Icon not visible on first load — retry with reloads
}
for (let attempt = 0; attempt < maxRetries; attempt++) {
await this.page.reload();
try {
await this.terminalIcon.waitFor({ state: 'visible', timeout: 15_000 });
return;
} catch {
// Retry
}
}
throw new Error(`Terminal icon not visible after ${maxRetries} retries`);
}

async clickTerminalIcon(): Promise<void> {
await this.robustClick(this.terminalIcon);
await this.loadingBox.waitFor({ state: 'detached', timeout: 60_000 }).catch(() => {});
Comment thread
fsgreco marked this conversation as resolved.
}

async waitForTerminalWindow(timeoutMs = 60_000): Promise<void> {
await this.terminalContainer.waitFor({ state: 'visible', timeout: timeoutMs });
await this.terminalWindow.waitFor({ state: 'visible', timeout: timeoutMs });
}

async closeTerminalDrawer(): Promise<void> {
await this.robustClick(this.drawerCloseButton);
await this.terminalContainer.waitFor({ state: 'hidden', timeout: 10_000 }).catch(() => {});
}

async closeTerminalSession(): Promise<void> {
await this.robustClick(this.closeTerminalButton.first());
const confirmButton = this.page.getByRole('button', { name: 'Close' });
await this.robustClick(confirmButton);
}

async clickAdvancedTimeout(): Promise<void> {
await this.timeoutLink.waitFor({ state: 'visible', timeout: 30_000 });
await this.robustClick(this.timeoutLink);
}

async setTimeoutValue(value: string): Promise<void> {
await this.incrementButton.waitFor({ state: 'visible', timeout: 10_000 });
await this.robustClick(this.incrementButton);
await this.timeoutInput.fill(value);
await this.timeoutInput.press('Tab');
}

async clickStartButton(): Promise<void> {
await this.robustClick(this.startButton);
await this.waitForLoadingComplete(10_000);
}

async addTerminalTabs(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await this.robustClick(this.addTabButton);
}
}

async closeTerminalTab(tabIndex: number): Promise<void> {
await this.robustClick(this.closeTabButtons.nth(tabIndex));
}

async getOpenTabCount(): Promise<number> {
const children = this.tabsList.locator('> *');
return children.count();
}

async getResourceTitle(): Promise<string> {
await this.resourceTitle.waitFor({ state: 'visible' });
return (await this.resourceTitle.textContent()) || '';
}

getMonacoEditor(): Locator {
return this.monacoEditor;
}

getTerminalIcon(): Locator {
return this.terminalIcon;
}

getTerminalWindow(): Locator {
return this.terminalWindow;
}

getOpenInNewTabLink(): Locator {
return this.openInNewTabLink;
}

getInactivityMessageArea(): Locator {
return this.inactivityMessageArea;
}

async clickProjectDropdown(): Promise<void> {
const dropdown = this.page.locator('[data-test-id="namespace-bar-dropdown"] [type="button"]');
await this.robustClick(dropdown);
}

async selectCreateProject(): Promise<void> {
const createBtn = this.page.locator('[data-test-dropdown-menu="#CREATE_RESOURCE_ACTION#"]');
await this.robustClick(createBtn);
}

async typeProjectName(name: string): Promise<void> {
await this.page.getByTestId('input-name').fill(name);
}

async confirmProjectCreation(): Promise<void> {
await this.robustClick(this.page.getByTestId('confirm-action'));
}

async selectProjectFromDropdown(name: string): Promise<void> {
const filterInput = this.page.getByTestId('dropdown-text-filter');
await filterInput.fill(name);
const item = this.page.getByTestId('console-select-item').filter({ hasText: name });
await this.robustClick(item);
}

async navigateTo(url: string): Promise<void> {
await this.goTo(url);
}

async waitForPageReady(timeoutMs = 30_000): Promise<void> {
await this.waitForLoadingComplete(timeoutMs);
}

async navigateToDevWorkspaceSearch(namespace: string): Promise<void> {
await this.goTo(`/search/ns/${namespace}?kind=workspace.devfile.io~v1alpha2~DevWorkspace`);
await this.waitForLoadingComplete(30_000);
}

async navigateToDevWorkspaceYaml(namespace: string, name: string): Promise<void> {
await this.goTo(`/k8s/ns/${namespace}/workspace.devfile.io~v1alpha2~DevWorkspace/${name}/yaml`);
await this.waitForLoadingComplete(30_000);
}

async switchPerspective(target: 'Developer' | 'Administrator'): Promise<void> {
const labelMap: Record<string, string[]> = {
Administrator: ['Administrator', 'Core platform'],
Developer: ['Developer'],
};
const labels = labelMap[target] || [target];
const currentText = (await this.perspectiveSwitcherToggle.textContent()) || '';
if (labels.some((label) => currentText.includes(label))) {
return;
}
await this.robustClick(this.perspectiveSwitcherToggle);
const menuOption = this.page.locator('[data-test-id="perspective-switcher-menu-option"]');
for (const label of labels) {
const option = menuOption.filter({ hasText: label });
if ((await option.count()) > 0) {
await this.robustClick(option.first());
await this.waitForLoadingComplete();
return;
}
}
throw new Error(`Perspective "${target}" not found in switcher menu`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { test, expect } from '../../../fixtures';
import { WebTerminalPage } from '../../../pages/web-terminal-page';
import { ensureWebTerminalOperatorInstalled } from '../../../utils/web-terminal-operator';

const INACTIVITY_MESSAGE = 'The terminal connection has closed due to inactivity.';
const TERMINAL_IDLING_TIMEOUT = Number(process.env.TERMINAL_IDLING_TIMEOUT) || 200_000;
const TEST_NAMESPACE = 'aut-terminal-basic';

test.describe('Web Terminal basic user', { tag: ['@web-terminal', '@regression'] }, () => {
test.beforeAll(async ({ k8sClient }) => {
await ensureWebTerminalOperatorInstalled(k8sClient);
});

test.beforeEach(async ({ k8sClient, cleanup }) => {
await k8sClient.createNamespace(TEST_NAMESPACE);
cleanup.trackNamespace(TEST_NAMESPACE);
});
Comment on lines +7 to +17
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use unique namespace names per test run.

Lines [6-12] reuse a fixed namespace across all tests. That tends to flake on retries or overlapping runs when deletion lags. Generate a per-test suffix (e.g., with test.info().parallelIndex/timestamp/random) before createNamespace.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/e2e/tests/webterminal/developer/web-terminal-basic.spec.ts` around
lines 6 - 12, TEST_NAMESPACE is a fixed value causing flakiness; modify the
test.beforeEach block to generate a unique namespace per run (e.g., append
test.info().parallelIndex, timestamp or random suffix) and use that generated
name when calling k8sClient.createNamespace and cleanup.trackNamespace instead
of the constant TEST_NAMESPACE so each test invocation creates and tracks its
own unique namespace.


test('WT-01-TC01: open terminal with advanced timeout', async ({ page }) => {
const webTerminal = new WebTerminalPage(page);

await test.step('Open terminal with 1-minute timeout', async () => {
await webTerminal.waitForTerminalIconVisible();
await webTerminal.clickTerminalIcon();
await webTerminal.clickAdvancedTimeout();
await webTerminal.setTimeoutValue('1');
await webTerminal.clickStartButton();
});

await test.step('Verify terminal window is visible', async () => {
await webTerminal.waitForTerminalWindow();
await expect(webTerminal.getTerminalWindow()).toBeVisible();
});

await test.step('Close terminal session', async () => {
await webTerminal.closeTerminalSession();
});
});

test('WT-01-TC02: verify Open in new tab button', async ({ page }) => {
const webTerminal = new WebTerminalPage(page);

await test.step('Wait for terminal icon and open terminal', async () => {
await webTerminal.waitForTerminalIconVisible();
await webTerminal.clickTerminalIcon();
});

await test.step('Verify Open in new tab link has target _blank', async () => {
const newTabLink = webTerminal.getOpenInNewTabLink();
await expect(newTabLink).toBeVisible();
await expect(newTabLink).toHaveAttribute('target', '_blank');
});
});

test('WT-01-TC03: inactivity timeout closes terminal', async ({ page }) => {
test.slow();
const webTerminal = new WebTerminalPage(page);

await test.step('Open terminal and wait for terminal window', async () => {
await webTerminal.waitForTerminalIconVisible();
await webTerminal.clickTerminalIcon();
await webTerminal.clickStartButton();
await webTerminal.waitForTerminalWindow();
await expect(webTerminal.getTerminalWindow()).toBeVisible();
});

await test.step('Wait for inactivity message', async () => {
await expect(webTerminal.getInactivityMessageArea()).toContainText(INACTIVITY_MESSAGE, {
timeout: TERMINAL_IDLING_TIMEOUT,
});
});

await test.step('Verify restart button is shown', async () => {
const restartButton = page.locator('button', { hasText: 'Restart terminal' });
await expect(restartButton).toBeVisible();
});
});
});
Loading