Skip to content

Commit 4c4aa0f

Browse files
HusneShabbirHusneShabbir
andauthored
test(homepage): add Playwright e2e for dynamic home page (#2048)
* feat(homepage): add Playwright e2e for dynamic home page Add homepage workspace e2e-tests with Keycloak auth, dynamic-plugins config, and DynamicHomePagePo flows (seed, delete, single-widget resize before save). Include app-config and value_file stubs, e2e-test-utils patch, and rhdh fixture. Made-with: Cursor * test(homepage): drop redundant enterEditMode before add in resize test Made-with: Cursor * chore(homepage-e2e): remove Yarn patch for e2e-test-utils Made-with: Cursor * chore(homepage-e2e): mark DynamicHomePagePo locators as readonly Made-with: Cursor * chore(homepage): align e2e with e2e-test-utils 1.1.33 - Update package.json (Yarn 4.12, pinned devDeps, test:vault, check script) - Drop manual dotenv from playwright.config.ts - Remove redundant tests/config overrides; trim dynamic-plugins.yaml - Format dynamic-plugins.yaml with Prettier Made-with: Cursor --------- Co-authored-by: HusneShabbir <husneshabbir447@gmail.com>
1 parent 58c8a18 commit 4c4aa0f

10 files changed

Lines changed: 2709 additions & 0 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
playwright-report/
2+
test-results/
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
compressionLevel: mixed
2+
3+
enableGlobalCache: false
4+
5+
nodeLinker: node-modules
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { createEslintConfig } from "@red-hat-developer-hub/e2e-test-utils/eslint";
2+
3+
export default createEslintConfig(import.meta.dirname);
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "homepage-e2e-tests",
3+
"version": "1.0.0",
4+
"private": true,
5+
"type": "module",
6+
"engines": {
7+
"node": ">=22",
8+
"yarn": ">=3"
9+
},
10+
"packageManager": "yarn@4.12.0",
11+
"description": "E2E tests for Dynamic Home Page plugin",
12+
"scripts": {
13+
"test": "playwright test",
14+
"test:vault": "VAULT=1 playwright test",
15+
"report": "playwright show-report",
16+
"test:ui": "playwright test --ui",
17+
"test:headed": "playwright test --headed",
18+
"lint:check": "eslint .",
19+
"lint:fix": "eslint . --fix",
20+
"prettier:check": "prettier --check .",
21+
"prettier:fix": "prettier --write .",
22+
"tsc:check": "tsc --noEmit",
23+
"check": "yarn tsc:check && yarn lint:check && yarn prettier:check"
24+
},
25+
"devDependencies": {
26+
"@eslint/js": "10.0.1",
27+
"@playwright/test": "1.59.1",
28+
"@red-hat-developer-hub/e2e-test-utils": "1.1.33",
29+
"@types/node": "25.5.2",
30+
"eslint": "10.2.0",
31+
"eslint-plugin-check-file": "3.3.1",
32+
"eslint-plugin-playwright": "2.10.1",
33+
"prettier": "3.8.1",
34+
"typescript": "6.0.2",
35+
"typescript-eslint": "8.58.1"
36+
}
37+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { defineConfig } from "@red-hat-developer-hub/e2e-test-utils/playwright-config";
2+
3+
export default defineConfig({
4+
projects: [
5+
{
6+
name: "homepage",
7+
},
8+
],
9+
});
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
plugins:
2+
- package: ./dynamic-plugins/dist/red-hat-developer-hub-backstage-plugin-dynamic-home-page
3+
disabled: false
4+
pluginConfig:
5+
dynamicPlugins:
6+
frontend:
7+
red-hat-developer-hub.backstage-plugin-dynamic-home-page:
8+
dynamicRoutes:
9+
- path: /
10+
importName: DynamicCustomizableHomePage
11+
mountPoints:
12+
- mountPoint: home.page/cards
13+
importName: OnboardingSection
14+
config:
15+
id: onboarding-section
16+
title: Onboarding section
17+
- mountPoint: home.page/cards
18+
importName: EntitySection
19+
config:
20+
id: entity-section
21+
title: Entity section
22+
- mountPoint: home.page/cards
23+
importName: RecentlyVisitedCard
24+
config:
25+
id: recently-visited-card
26+
title: Recently visited
27+
- mountPoint: home.page/cards
28+
importName: TopVisitedCard
29+
config:
30+
id: top-visited-card
31+
title: Top visited
32+
- mountPoint: home.page/cards
33+
importName: JokeCard
34+
config:
35+
id: joke-card
36+
title: Random joke
37+
translationResources:
38+
- importName: homepageTranslations
39+
ref: homepageTranslationRef
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { test } from "@red-hat-developer-hub/e2e-test-utils/test";
2+
import {
3+
LoginHelper,
4+
UIhelper,
5+
} from "@red-hat-developer-hub/e2e-test-utils/helpers";
6+
import type { BrowserContext, Page } from "@playwright/test";
7+
import { DynamicHomePagePo } from "../utils/dynamic-homepage";
8+
9+
/** Chart dist wrapper names (see ../metadata `spec.dynamicArtifact` basenames). */
10+
const DYNAMIC_HOME_PAGE_WRAPPER_DIST_NAMES: string[] = [
11+
"red-hat-developer-hub-backstage-plugin-dynamic-home-page",
12+
];
13+
14+
/* Assertions live in DynamicHomePagePo (expect/verify*), matching RHDH core structure. */
15+
/* eslint-disable playwright/expect-expect -- see DynamicHomePagePo */
16+
test.describe.serial("Dynamic home page customization", () => {
17+
let context: BrowserContext | undefined;
18+
let page: Page;
19+
let uiHelper: UIhelper;
20+
let home: DynamicHomePagePo;
21+
22+
test.beforeAll(async ({ browser, rhdh }) => {
23+
test.setTimeout(10 * 60 * 1000);
24+
25+
await rhdh.configure({
26+
auth: "keycloak",
27+
// Default chart tag in registry (avoid "next", which is not always published).
28+
version: process.env.RHDH_VERSION ?? "1.10",
29+
disableWrappers: DYNAMIC_HOME_PAGE_WRAPPER_DIST_NAMES,
30+
});
31+
await rhdh.deploy();
32+
33+
context = await browser.newContext({
34+
baseURL: rhdh.rhdhUrl,
35+
});
36+
page = await context.newPage();
37+
uiHelper = new UIhelper(page);
38+
const loginHelper = new LoginHelper(page);
39+
await loginHelper.loginAsKeycloakUser();
40+
home = new DynamicHomePagePo(page, uiHelper);
41+
});
42+
43+
test.afterAll(async () => {
44+
await context?.close();
45+
});
46+
47+
test("Verify cards display after login", async () => {
48+
await home.seedHomePageWidgets();
49+
await home.verifyHomePageLoaded();
50+
await home.verifyAllCardsDisplayed();
51+
await home.verifyEditButtonVisible();
52+
});
53+
54+
test("Verify cards can be individually deleted in edit mode", async () => {
55+
await home.enterEditMode();
56+
await home.deleteAllCards();
57+
await home.verifyCardsDeleted();
58+
});
59+
60+
test("Verify cards can be resized in edit mode", async () => {
61+
await home.addWidget("Entity Section");
62+
await home.resizeFirstCard();
63+
await home.exitEditMode();
64+
});
65+
66+
// restore defaults button is not working as expected
67+
// eslint-disable-next-line playwright/no-skipped-test -- re-enable when https://issues.redhat.com/browse/RHDHBUGS-2906 is fixed
68+
test.skip("Verify restore default cards and deleted with Clear all button", async () => {
69+
await home.restoreDefaultCards();
70+
await home.verifyCardsRestored();
71+
await home.enterEditMode();
72+
await home.clearAllCardsWithButton();
73+
await home.verifyCardsDeleted();
74+
});
75+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import {
2+
expect,
3+
type Locator,
4+
type Page,
5+
} from "@red-hat-developer-hub/e2e-test-utils/test";
6+
import type { UIhelper } from "@red-hat-developer-hub/e2e-test-utils/helpers";
7+
8+
const EXPECTED_CARD_TEXTS = [
9+
"Good (morning|afternoon|evening)",
10+
"Explore Your Software Catalog",
11+
"Recently Visited",
12+
"Top Visited",
13+
] as const;
14+
15+
/**
16+
* Flows ported from rhdh e2e-tests/playwright/support/pages/home-page-customization.ts
17+
* (same locators/behavior, uses overlay UIhelper).
18+
*/
19+
export class DynamicHomePagePo {
20+
constructor(
21+
private readonly page: Page,
22+
private readonly ui: UIhelper,
23+
) {}
24+
25+
private readonly editButton = () => this.page.getByText("Edit");
26+
private readonly saveButton = () =>
27+
this.page.getByText("Save", {
28+
exact: true,
29+
});
30+
private readonly clearAllButton = () => this.page.getByText("Clear all");
31+
private readonly restoreDefaultsButton = () =>
32+
this.page.getByText("Restore defaults");
33+
private readonly addWidgetButton = () =>
34+
this.page.getByRole("button", { name: "Add widget" });
35+
private readonly resizeHandles = () =>
36+
this.page.locator(".react-resizable-handle");
37+
private readonly deleteButtons = () =>
38+
this.page.getByRole("button", { name: "Delete widget" });
39+
private readonly greetingText = () =>
40+
this.page.getByText(/Good (morning|afternoon|evening)/);
41+
42+
async verifyHomePageLoaded(): Promise<void> {
43+
await this.ui.verifyHeading("Welcome back");
44+
await expect(this.greetingText()).toBeVisible();
45+
}
46+
47+
async verifyAllCardsDisplayed(): Promise<void> {
48+
for (const card of EXPECTED_CARD_TEXTS) {
49+
if (card.startsWith("Good")) {
50+
await expect(this.greetingText()).toBeVisible();
51+
} else {
52+
await this.ui.verifyText(card);
53+
}
54+
}
55+
}
56+
57+
async verifyEditButtonVisible(): Promise<void> {
58+
await this.ui.verifyText("Edit");
59+
}
60+
61+
/**
62+
* Adds the default home cards through Add widget (dialog labels must match the UI).
63+
* Used when tests need a full grid without relying on restore-defaults (skipped / broken).
64+
*/
65+
async seedHomePageWidgets(): Promise<void> {
66+
await this.addWidget("Entity Section");
67+
await this.enterEditMode();
68+
await this.addWidget("Onboarding Section");
69+
await this.addWidget("Recently visited");
70+
await this.addWidget("Top visited");
71+
await this.addWidget("Random joke");
72+
await this.exitEditMode();
73+
}
74+
75+
async enterEditMode(): Promise<void> {
76+
await this.ui.clickButton("Edit");
77+
await expect(this.saveButton()).toBeVisible();
78+
}
79+
80+
async exitEditMode(): Promise<void> {
81+
await this.ui.clickButton("Save");
82+
await expect(this.editButton()).toBeVisible();
83+
}
84+
85+
/**
86+
* Resizes one card via the first visible resize handle (while still in edit
87+
* mode, before Save). Call after `enterEditMode` and adding a widget.
88+
*/
89+
async resizeFirstCard(): Promise<void> {
90+
const handle = this.resizeHandles().first();
91+
await expect(handle).toBeVisible();
92+
const panel = this.resizablePanelForHandle(handle);
93+
const initialBox = await panel.boundingBox();
94+
expect(initialBox).not.toBeNull();
95+
96+
await this.dragResizeHandle(handle);
97+
98+
const finalBox = await panel.boundingBox();
99+
expect(finalBox).not.toBeNull();
100+
const widthChanged = finalBox!.width !== initialBox!.width;
101+
const heightChanged = finalBox!.height !== initialBox!.height;
102+
expect(widthChanged || heightChanged).toBe(true);
103+
}
104+
105+
/** Nearest `react-resizable` root for a handle (`.react-resizable-handle`). */
106+
private resizablePanelForHandle(handle: Locator): Locator {
107+
return handle.locator(
108+
'xpath=ancestor::*[contains(@class,"react-resizable")][1]',
109+
);
110+
}
111+
112+
private async dragResizeHandle(handle: Locator): Promise<void> {
113+
await handle.scrollIntoViewIfNeeded();
114+
const box = await handle.boundingBox();
115+
expect(box).not.toBeNull();
116+
const startX = box!.x + box!.width / 2;
117+
const startY = box!.y + box!.height / 2;
118+
const delta = 160;
119+
await this.page.mouse.move(startX, startY);
120+
await this.page.mouse.down();
121+
await this.page.mouse.move(startX + delta, startY + delta, { steps: 12 });
122+
await this.page.mouse.up();
123+
// eslint-disable-next-line playwright/no-wait-for-timeout -- layout after resize
124+
await this.page.waitForTimeout(600);
125+
}
126+
127+
async deleteAllCards(): Promise<void> {
128+
for (let n = 0; n < 50; n++) {
129+
const currentButtons = this.deleteButtons();
130+
const currentCount = await currentButtons.count();
131+
if (currentCount === 0) {
132+
break;
133+
}
134+
await currentButtons.first().click();
135+
// eslint-disable-next-line playwright/no-wait-for-timeout -- upstream timing between deletes
136+
await this.page.waitForTimeout(1000);
137+
}
138+
}
139+
140+
async clearAllCardsWithButton(): Promise<void> {
141+
await this.ui.clickButton("Clear all");
142+
}
143+
144+
async verifyCardsDeleted(): Promise<void> {
145+
await expect(this.clearAllButton()).toBeHidden();
146+
await expect(this.saveButton()).toBeHidden();
147+
await expect(this.restoreDefaultsButton()).toBeVisible();
148+
await expect(this.addWidgetButton()).toBeVisible();
149+
150+
for (const card of EXPECTED_CARD_TEXTS) {
151+
if (card.startsWith("Good")) {
152+
await expect(this.greetingText()).toBeHidden();
153+
} else {
154+
await expect(this.page.getByText(card)).toBeHidden();
155+
}
156+
}
157+
}
158+
159+
async restoreDefaultCards(): Promise<void> {
160+
await this.ui.clickButton("Restore defaults");
161+
// eslint-disable-next-line playwright/no-wait-for-timeout -- upstream wait for layout
162+
await this.page.waitForTimeout(2000);
163+
}
164+
165+
async verifyCardsRestored(): Promise<void> {
166+
await this.verifyAllCardsDisplayed();
167+
await expect(this.editButton()).toBeVisible();
168+
}
169+
170+
async addWidget(widgetType: string): Promise<void> {
171+
await this.ui.clickButton("Add widget");
172+
// eslint-disable-next-line playwright/no-wait-for-timeout -- dialog open
173+
await this.page.waitForTimeout(1000);
174+
await this.page.getByRole("button", { name: widgetType }).click();
175+
// eslint-disable-next-line playwright/no-wait-for-timeout -- widget mount
176+
await this.page.waitForTimeout(1000);
177+
}
178+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"extends": "@red-hat-developer-hub/e2e-test-utils/tsconfig",
3+
"compilerOptions": {
4+
"lib": ["ES2022", "DOM"]
5+
},
6+
"include": ["**/*.ts"]
7+
}

0 commit comments

Comments
 (0)