Skip to content

Commit b335546

Browse files
feat: add WecsPage class and integrate into e2e tests (kubestellar#2247)
* feat: add WecsPage class and integrate into e2e tests Signed-off-by: arpit529srivastava <arpitsrivastava529@gmail.com> * fix: update heading regex in WecsPage to improve matching for remote cluster treeview Signed-off-by: arpit529srivastava <arpitsrivastava529@gmail.com> * a small fix for feedback Signed-off-by: arpit529srivastava <arpitsrivastava529@gmail.com> --------- Signed-off-by: arpit529srivastava <arpitsrivastava529@gmail.com>
1 parent 2567708 commit b335546

5 files changed

Lines changed: 704 additions & 2 deletions

File tree

.github/workflows/feedback.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515

1616
steps:
1717
- name: Comment feedback request
18-
uses: peter-evans/create-or-update-comment@v5
18+
uses: peter-evans/create-or-update-comment@v4
1919
with:
2020
token: ${{ secrets.GITHUB_TOKEN }}
2121
issue-number: ${{ github.event.pull_request.number }}
@@ -25,4 +25,4 @@ jobs:
2525
We’d love to hear your thoughts to help improve KubeStellar.
2626
Please take a moment to fill out our short feedback survey:
2727
28-
https://kubestellar.io/survey
28+
https://kubestellar.io/survey

frontend/e2e/pages/WecsPage.ts

Lines changed: 361 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,361 @@
1+
import { expect, Locator, Page } from '@playwright/test';
2+
import { BasePage } from './base/BasePage';
3+
4+
type DetailsTab = 'summary' | 'edit' | 'logs' | 'exec';
5+
6+
export class WecsPage extends BasePage {
7+
readonly pageTitle: Locator;
8+
readonly noteBanner: Locator;
9+
readonly createWorkloadButton: Locator;
10+
readonly createOptionsDialog: Locator;
11+
readonly createOptionsTabs: Locator;
12+
readonly createOptionsCancelButton: Locator;
13+
readonly tilesViewButton: Locator;
14+
readonly listViewButton: Locator;
15+
readonly viewSkeleton: Locator;
16+
readonly listViewSkeleton: Locator;
17+
readonly reactFlowCanvas: Locator;
18+
readonly reactFlowNodes: Locator;
19+
readonly listViewContainer: Locator;
20+
readonly listViewItems: Locator;
21+
readonly listViewTableRows: Locator;
22+
readonly listViewSearchInput: Locator;
23+
readonly filterKindButton: Locator;
24+
readonly filterNamespaceButton: Locator;
25+
readonly filterLabelButton: Locator;
26+
readonly filterClearButton: Locator;
27+
readonly zoomControlsContainer: Locator;
28+
readonly zoomHideControlsButton: Locator;
29+
readonly zoomExpandAllButton: Locator;
30+
readonly zoomCollapseAllButton: Locator;
31+
readonly zoomFullscreenButton: Locator;
32+
readonly zoomEdgeSquareButton: Locator;
33+
readonly zoomEdgeCurvyButton: Locator;
34+
readonly contextMenu: Locator;
35+
readonly detailsPanel: Locator;
36+
readonly detailsPanelCloseButton: Locator;
37+
readonly summaryTab: Locator;
38+
readonly editTab: Locator;
39+
readonly logsTab: Locator;
40+
readonly execTab: Locator;
41+
readonly manifestEditor: Locator;
42+
readonly manifestFormatYamlButton: Locator;
43+
readonly manifestFormatJsonButton: Locator;
44+
readonly logsContainerDropdown: Locator;
45+
readonly logsPreviousButton: Locator;
46+
readonly logsDownloadButton: Locator;
47+
readonly logsTerminal: Locator;
48+
readonly execContainerDropdown: Locator;
49+
readonly execClearButton: Locator;
50+
readonly execMaximizeButton: Locator;
51+
readonly execTerminal: Locator;
52+
readonly snackbar: Locator;
53+
54+
constructor(page: Page) {
55+
super(page);
56+
57+
this.pageTitle = page.getByRole('heading', { name: /remote-?cluster treeview/i }).first();
58+
this.noteBanner = page
59+
.locator(
60+
'text=Note: Default, Kubernetes system, and OpenShift namespaces are filtered out from this view.'
61+
)
62+
.first();
63+
this.createWorkloadButton = page.getByRole('button', { name: /create workload/i }).first();
64+
this.createOptionsDialog = page.getByRole('dialog', { name: /create/i }).first();
65+
this.createOptionsTabs = this.createOptionsDialog.getByRole('tablist');
66+
this.createOptionsCancelButton = page.getByRole('button', { name: /cancel|close/i }).first();
67+
68+
this.tilesViewButton = page
69+
.locator('button')
70+
.filter({ has: page.locator('i.fa-th, i.fa-solid.fa-th') })
71+
.first();
72+
this.listViewButton = page
73+
.locator('button')
74+
.filter({ has: page.locator('i.fa-th-list, i.fa-solid.fa-th-list') })
75+
.first();
76+
77+
this.viewSkeleton = page
78+
.locator('[class*="UnifiedSkeleton"], [class*="unified-skeleton"]')
79+
.first();
80+
this.listViewSkeleton = page.locator('[class*="ListViewSkeleton"]').first();
81+
82+
this.reactFlowCanvas = page.locator('.react-flow, [class*="react-flow"]').first();
83+
this.reactFlowNodes = this.reactFlowCanvas.locator('.react-flow__node, [data-id]');
84+
85+
this.listViewContainer = page
86+
.locator('[class*="ListViewComponent"], [data-testid="list-view"]')
87+
.first();
88+
this.listViewItems = this.listViewContainer.locator('[class*="list-item"], [role="row"]');
89+
this.listViewTableRows = page.locator('table tbody tr');
90+
this.listViewSearchInput = page.getByRole('textbox', { name: /quick search objects/i }).first();
91+
this.filterKindButton = page.getByRole('button', { name: /kind/i }).first();
92+
this.filterNamespaceButton = page.getByRole('button', { name: /namespace/i }).first();
93+
this.filterLabelButton = page.getByRole('button', { name: /label/i }).first();
94+
this.filterClearButton = page.getByRole('button', { name: /clear filters|reset/i }).first();
95+
96+
this.zoomControlsContainer = page.locator('text=/hide controls|show controls/i').first();
97+
this.zoomHideControlsButton = page
98+
.getByRole('button', { name: /hide controls|show controls/i })
99+
.first();
100+
this.zoomExpandAllButton = page.getByRole('button', { name: /expand all/i }).first();
101+
this.zoomCollapseAllButton = page.getByRole('button', { name: /collapse all/i }).first();
102+
this.zoomFullscreenButton = page
103+
.getByRole('button', { name: /fullscreen|exit fullscreen/i })
104+
.first();
105+
this.zoomEdgeSquareButton = page.getByRole('button', { name: /square/i }).first();
106+
this.zoomEdgeCurvyButton = page.getByRole('button', { name: /curvy/i }).first();
107+
108+
this.contextMenu = page.locator('[role="menu"]').first();
109+
110+
this.detailsPanel = page.locator('[data-testid="wecs-details-panel"]').first();
111+
this.detailsPanelCloseButton = this.detailsPanel
112+
.getByRole('button', { name: /close/i })
113+
.first();
114+
this.summaryTab = this.detailsPanel.getByRole('tab', { name: /summary/i }).first();
115+
this.editTab = this.detailsPanel.getByRole('tab', { name: /edit/i }).first();
116+
this.logsTab = this.detailsPanel.getByRole('tab', { name: /logs/i }).first();
117+
this.execTab = this.detailsPanel.getByRole('tab', { name: /exec/i }).first();
118+
this.manifestEditor = this.detailsPanel.locator('.monaco-editor, textarea, pre').first();
119+
this.manifestFormatYamlButton = this.detailsPanel
120+
.getByRole('button', { name: /yaml/i })
121+
.first();
122+
this.manifestFormatJsonButton = this.detailsPanel
123+
.getByRole('button', { name: /json/i })
124+
.first();
125+
126+
this.logsContainerDropdown = this.detailsPanel.locator('.logs-container-dropdown').first();
127+
this.logsPreviousButton = this.detailsPanel
128+
.getByRole('button', { name: /previous logs/i })
129+
.first();
130+
this.logsDownloadButton = this.detailsPanel
131+
.locator('button[title*="Download logs"], button[title*="download"]')
132+
.first();
133+
this.logsTerminal = this.detailsPanel
134+
.locator('.xterm, .terminal, [data-testid="logs-terminal"]')
135+
.first();
136+
137+
this.execContainerDropdown = this.detailsPanel.locator('.container-dropdown').first();
138+
this.execClearButton = this.detailsPanel
139+
.getByRole('button', { name: /clear terminal/i })
140+
.first();
141+
this.execMaximizeButton = this.detailsPanel
142+
.getByRole('button', { name: /maximize|minimize/i })
143+
.first();
144+
this.execTerminal = this.detailsPanel.locator('[data-terminal="exec"], .xterm').first();
145+
146+
this.snackbar = page.locator('.MuiSnackbar-root, .toast, [role="alert"]').first();
147+
}
148+
149+
async goto() {
150+
await super.goto('/wecs/treeview');
151+
await this.waitForInitialLoad();
152+
}
153+
154+
async ensureOnWecsPage() {
155+
const url = this.page.url();
156+
if (!/\/wecs\/treeview/.test(url)) {
157+
await this.page.goto(`${this.BASE_URL}/wecs/treeview`, { waitUntil: 'domcontentloaded' });
158+
}
159+
await this.waitForInitialLoad();
160+
}
161+
162+
async waitForInitialLoad() {
163+
await this.page.waitForFunction(
164+
() => {
165+
const heading = Array.from(document.querySelectorAll('h1,h2,h3,h4')).some(el =>
166+
/remote-?cluster treeview/i.test(el.textContent || '')
167+
);
168+
if (!heading) return false;
169+
const hasSkeleton = document.querySelector('[class*="Skeleton"]');
170+
const hasFlow = document.querySelector('.react-flow, [class*="react-flow"]');
171+
const hasList = document.querySelector('[class*="ListViewComponent"]');
172+
const bodyText = document.body?.innerText || '';
173+
const hasEmpty = /No Workloads Found/i.test(bodyText);
174+
return Boolean(hasSkeleton || hasFlow || hasList || hasEmpty);
175+
},
176+
{ timeout: 30000 }
177+
);
178+
}
179+
180+
async waitForTilesView() {
181+
await this.page.waitForFunction(
182+
() => {
183+
const canvas = document.querySelector('.react-flow, [class*="react-flow"], canvas');
184+
const emptyState = /No Workloads Found/i.test(document.body?.innerText || '');
185+
const listView = document.querySelector('[class*="ListViewComponent"]');
186+
return Boolean(canvas || emptyState) && !listView;
187+
},
188+
{ timeout: 20000 }
189+
);
190+
}
191+
192+
async waitForListView() {
193+
await this.page.waitForFunction(
194+
() => {
195+
const listView = document.querySelector('[class*="ListViewComponent"]');
196+
const table = document.querySelector('table');
197+
const empty = /No Workloads Found/i.test(document.body?.innerText || '');
198+
return Boolean(listView || table || empty);
199+
},
200+
{ timeout: 20000 }
201+
);
202+
}
203+
204+
async switchToTilesView() {
205+
await this.tilesViewButton.waitFor({ state: 'visible', timeout: 10000 });
206+
await this.tilesViewButton.click();
207+
await this.waitForTilesView();
208+
}
209+
210+
async switchToListView() {
211+
await this.listViewButton.waitFor({ state: 'visible', timeout: 10000 });
212+
await this.listViewButton.click();
213+
await this.waitForListView();
214+
}
215+
216+
async openCreateOptions(option: string = 'yaml') {
217+
await this.createWorkloadButton.click();
218+
await expect(this.createOptionsDialog).toBeVisible({ timeout: 5000 });
219+
const tabLabelMap: Record<string, RegExp> = {
220+
yaml: /yaml/i,
221+
file: /file/i,
222+
github: /github/i,
223+
helm: /helm/i,
224+
artifactHub: /artifact hub/i,
225+
};
226+
const tab = this.createOptionsDialog
227+
.getByRole('tab', { name: tabLabelMap[option] || /yaml/i })
228+
.first();
229+
await tab.click();
230+
}
231+
232+
async closeCreateOptions() {
233+
if (await this.createOptionsDialog.isVisible().catch(() => false)) {
234+
await this.page.keyboard.press('Escape').catch(() => {});
235+
if (await this.createOptionsDialog.isVisible().catch(() => false)) {
236+
await this.createOptionsCancelButton.click().catch(() => {});
237+
}
238+
await this.createOptionsDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
239+
}
240+
}
241+
242+
async searchListView(query: string) {
243+
await this.listViewSearchInput.fill('');
244+
await this.listViewSearchInput.fill(query);
245+
await this.page.waitForTimeout(400);
246+
}
247+
248+
async selectFilter(filter: 'kind' | 'namespace' | 'label', value: string) {
249+
const target =
250+
filter === 'kind'
251+
? this.filterKindButton
252+
: filter === 'namespace'
253+
? this.filterNamespaceButton
254+
: this.filterLabelButton;
255+
await target.click();
256+
const option = this.page.getByRole('menuitem', { name: new RegExp(value, 'i') }).first();
257+
await option.click();
258+
await this.page.waitForTimeout(300);
259+
}
260+
261+
async clearFilters() {
262+
await this.filterClearButton.click().catch(() => {});
263+
await this.page.waitForTimeout(300);
264+
}
265+
266+
getNodeLocator(nodeName: string) {
267+
return this.reactFlowNodes.filter({ hasText: new RegExp(nodeName, 'i') }).first();
268+
}
269+
270+
async openNodeMenu(nodeName: string) {
271+
const node = this.getNodeLocator(nodeName);
272+
await node.waitFor({ state: 'visible', timeout: 10000 });
273+
const menuButton = node.getByRole('button', { name: /more options/i }).first();
274+
await menuButton.click();
275+
await this.contextMenu.waitFor({ state: 'visible', timeout: 5000 });
276+
}
277+
278+
async selectContextMenuAction(action: 'details' | 'edit' | 'logs' | 'exec') {
279+
const labels: Record<typeof action, RegExp> = {
280+
details: /details/i,
281+
edit: /edit/i,
282+
logs: /logs/i,
283+
exec: /exec/i,
284+
};
285+
const item = this.page.getByRole('menuitem', { name: labels[action] }).first();
286+
await item.click();
287+
}
288+
289+
async selectNode(nodeName: string) {
290+
const node = this.getNodeLocator(nodeName);
291+
await node.click();
292+
await this.page.waitForTimeout(300);
293+
}
294+
295+
async waitForDetailsPanel() {
296+
await this.detailsPanel.waitFor({ state: 'visible', timeout: 10000 });
297+
}
298+
299+
async closeDetailsPanel() {
300+
if (await this.detailsPanel.isVisible().catch(() => false)) {
301+
await this.detailsPanelCloseButton.click().catch(() => {});
302+
await this.detailsPanel.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
303+
}
304+
}
305+
306+
async openDetailsTab(tab: DetailsTab) {
307+
const tabMap: Record<DetailsTab, Locator> = {
308+
summary: this.summaryTab,
309+
edit: this.editTab,
310+
logs: this.logsTab,
311+
exec: this.execTab,
312+
};
313+
await tabMap[tab].click();
314+
await this.page.waitForTimeout(300);
315+
}
316+
317+
async setManifestFormat(format: 'yaml' | 'json') {
318+
if (format === 'yaml') {
319+
await this.manifestFormatYamlButton.click();
320+
} else {
321+
await this.manifestFormatJsonButton.click();
322+
}
323+
await this.page.waitForTimeout(200);
324+
}
325+
326+
async selectLogsContainer(containerName: string) {
327+
await this.logsContainerDropdown.click();
328+
const option = this.page.getByRole('option', { name: new RegExp(containerName, 'i') }).first();
329+
await option.click();
330+
await this.page.waitForTimeout(200);
331+
}
332+
333+
async togglePreviousLogs() {
334+
await this.logsPreviousButton.click();
335+
await this.page.waitForTimeout(300);
336+
}
337+
338+
async selectExecContainer(containerName: string) {
339+
await this.execContainerDropdown.click();
340+
const option = this.page.getByRole('option', { name: new RegExp(containerName, 'i') }).first();
341+
await option.click();
342+
await this.page.waitForTimeout(200);
343+
}
344+
345+
async clearExecTerminal() {
346+
await this.execClearButton.click();
347+
await this.page.waitForTimeout(200);
348+
}
349+
350+
async toggleExecMaximize() {
351+
await this.execMaximizeButton.click();
352+
await this.page.waitForTimeout(300);
353+
}
354+
355+
async waitForSnackbar(message?: RegExp) {
356+
await this.snackbar.waitFor({ state: 'visible', timeout: 10000 });
357+
if (message) {
358+
await expect(this.snackbar).toHaveText(message);
359+
}
360+
}
361+
}

frontend/e2e/pages/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { UserManagementPage } from './UserManagementPage';
55
export { ITSPage } from './ITSPage';
66
export { ObjectExplorerPage } from './ObjectExplorerPage';
77
export { WDSPage } from './WDSPage';
8+
export { WecsPage } from './WecsPage';
89
export { BindingPolicyPage } from './BindingPolicyPage';
910
// Export utilities
1011
export { MSWHelper } from './utils/MSWHelper';

0 commit comments

Comments
 (0)