diff --git a/catalog-entities/extensions/plugins/home-page.yaml b/catalog-entities/extensions/plugins/home-page.yaml index 7960526a22..a0ba5037ac 100644 --- a/catalog-entities/extensions/plugins/home-page.yaml +++ b/catalog-entities/extensions/plugins/home-page.yaml @@ -73,7 +73,31 @@ spec: Add the following configuration to your dynamic plugin yaml. Use the OCI image tags from the Homepage and Homepage Backend package entries in the Extensions catalog for your RHDH version if they differ from the examples below. + For the **new frontend system (app-next / NFS)**, enable the upstream Home plugin OCI and set `app.extensions` so Home is registered — legacy `dynamicRoutes` / `mountPoints` alone are not enough. The homepage alpha module extends `pluginId: home` (`page:home`, visits, layout blueprints) and requires `@backstage/plugin-home` to be loaded: + ```yaml + plugins: + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-home:bs_1.52.0__0.9.7 + disabled: false + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-homepage:bs_1.52.0__1.17.1 + disabled: false + # …homepage-backend as needed + ``` + + ```yaml + app: + extensions: + - page:home: + config: + path: / + - api:home/visits: true + - app-root-element:home/visit-listener: true + - home-page-layout:home/dynamic-homepage-layout: + config: + customizable: true + ``` + + Legacy Scalprum example: includes: - dynamic-plugins.default.yaml plugins: diff --git a/workspaces/homepage/e2e-tests/playwright.config.ts b/workspaces/homepage/e2e-tests/playwright.config.ts index d823241d94..46fae6a266 100644 --- a/workspaces/homepage/e2e-tests/playwright.config.ts +++ b/workspaces/homepage/e2e-tests/playwright.config.ts @@ -1,9 +1,20 @@ import { defineConfig } from "@red-hat-developer-hub/e2e-test-utils/playwright-config"; +/** + * Homepage plugin e2e test configuration. + * + * Projects: + * - homepage — legacy app shell (default RHIDP merge layers). + * - homepage-app-next — namespace ends with -app-next, so e2e-test-utils merges + * NFS (app-next) secrets and default app-auth / app-integrations automatically. + */ export default defineConfig({ projects: [ { name: "homepage", }, + { + name: "homepage-app-next", + }, ], }); diff --git a/workspaces/homepage/e2e-tests/tests/config/app-config-rhdh.yaml b/workspaces/homepage/e2e-tests/tests/config/app-config-rhdh.yaml index 22d4d40638..b1797a330e 100644 --- a/workspaces/homepage/e2e-tests/tests/config/app-config-rhdh.yaml +++ b/workspaces/homepage/e2e-tests/tests/config/app-config-rhdh.yaml @@ -1,3 +1,17 @@ +# Shared by legacy and app-next projects. +# NFS (app-next) requires app.extensions so Home is registered, plus the upstream +# backstage-plugin-home OCI (see dynamic-plugins-app-next.yaml). Legacy ignores these. +app: + extensions: + - page:home: + config: + path: / + - api:home/visits: true + - app-root-element:home/visit-listener: true + - home-page-layout:home/dynamic-homepage-layout: + config: + customizable: true + permission: enabled: true rbac: diff --git a/workspaces/homepage/e2e-tests/tests/config/dynamic-plugins-app-next.yaml b/workspaces/homepage/e2e-tests/tests/config/dynamic-plugins-app-next.yaml new file mode 100644 index 0000000000..e3be3efba3 --- /dev/null +++ b/workspaces/homepage/e2e-tests/tests/config/dynamic-plugins-app-next.yaml @@ -0,0 +1,17 @@ +# NFS (app-next) dynamic plugins for homepage e2e. +# Upstream @backstage/plugin-home provides page:home / visits / layout blueprints; +# the RHDH homepage alpha module extends pluginId "home" and requires this OCI. +# +# homepage.defaultWidgets (homepage-backend) is not supported on NFS yet — keep the +# commented block below and enable it when support lands. NFS tests seed widgets via +# the UI until then. +plugins: + # Version from workspaces/backstage export: backstage-plugin-home + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-home:bs_1.52.0__0.9.7 + disabled: false + # Version from workspaces/homepage/metadata/red-hat-developer-hub-backstage-plugin-homepage.yaml + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-homepage:bs_1.52.0__1.17.1 + disabled: false + # Version from workspaces/homepage/metadata/red-hat-developer-hub-backstage-plugin-homepage-backend.yaml + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-homepage-backend:bs_1.52.0__0.4.0 + disabled: false diff --git a/workspaces/homepage/e2e-tests/tests/config/dynamic-plugins.yaml b/workspaces/homepage/e2e-tests/tests/config/dynamic-plugins.yaml index bef0fd0fca..baba7c7d98 100644 --- a/workspaces/homepage/e2e-tests/tests/config/dynamic-plugins.yaml +++ b/workspaces/homepage/e2e-tests/tests/config/dynamic-plugins.yaml @@ -1,5 +1,8 @@ plugins: - - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-homepage:bs_1.52.0__1.17.0 + # Version from workspaces/backstage export: backstage-plugin-home + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/backstage-plugin-home:bs_1.52.0__0.9.7 + enabled: true + - package: oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/red-hat-developer-hub-backstage-plugin-homepage:bs_1.52.0__1.17.1 enabled: true pluginConfig: dynamicPlugins: diff --git a/workspaces/homepage/e2e-tests/tests/specs/homepage.spec.ts b/workspaces/homepage/e2e-tests/tests/specs/homepage.spec.ts index be17d7eee8..bc2cfdb05a 100644 --- a/workspaces/homepage/e2e-tests/tests/specs/homepage.spec.ts +++ b/workspaces/homepage/e2e-tests/tests/specs/homepage.spec.ts @@ -1,15 +1,13 @@ import { test, expect } from "@red-hat-developer-hub/e2e-test-utils/test"; -import { - LoginHelper, - UIhelper, -} from "@red-hat-developer-hub/e2e-test-utils/helpers"; +import { UIhelper } from "@red-hat-developer-hub/e2e-test-utils/helpers"; import { $, WorkspacePaths } from "@red-hat-developer-hub/e2e-test-utils/utils"; import type { BrowserContext, Page } from "@playwright/test"; import { DynamicHomePagePo, - AVAILABLE_WIDGETS, DEFAULT_WIDGETS, HOMEPAGE_ADMIN, + isHomepageAppNext, + loginAsKeycloakUser, setupKeycloakGroups, } from "../utils/dynamic-homepage"; @@ -17,6 +15,14 @@ const HOMEPAGE_WRAPPER_DIST_NAMES: string[] = [ "red-hat-developer-hub-backstage-plugin-homepage", ]; +function isNightlyMode(): boolean { + return ( + process.env.E2E_NIGHTLY_MODE === "true" || + process.env.E2E_NIGHTLY_MODE === "1" || + (process.env.JOB_NAME?.includes("periodic-") ?? false) + ); +} + /* eslint-disable playwright/expect-expect -- assertions in DynamicHomePagePo */ test.describe.serial("Dynamic home page customization", () => { let context: BrowserContext | undefined; @@ -25,45 +31,83 @@ test.describe.serial("Dynamic home page customization", () => { let home: DynamicHomePagePo; let baseURL: string; let test1Count: number; + let isAppNext: boolean; test.beforeAll(async ({ browser, rhdh }) => { test.setTimeout(10 * 60 * 1000); - await test.runOnce("homepage-setup", async () => { + const namespace = rhdh.deploymentConfig.namespace; + isAppNext = isHomepageAppNext(namespace); + + test.skip( + isAppNext && isNightlyMode(), + "homepage-app-next not ready for nightly", + ); + + // Keycloak users are cluster-scoped — create once so parallel legacy/app-next + // projects do not race on delete/create of the same users. + await test.runOnce("homepage-keycloak-groups", async () => { await setupKeycloakGroups(); + }); + // Deploy key must be unique per Playwright project/namespace. + await test.runOnce(`homepage-deploy-${namespace}`, async () => { + if (process.env.SKIP_RHDH_DEPLOY === "true") { + return; + } const rbacConfigmapPath = WorkspacePaths.resolve( "tests/config/rbac-configmap.yaml", ); - const namespace = rhdh.deploymentConfig.namespace; await $`oc apply -f ${rbacConfigmapPath} -n ${namespace}`; await rhdh.configure({ auth: "keycloak", disablePlugins: HOMEPAGE_WRAPPER_DIST_NAMES, + dynamicPlugins: isAppNext + ? WorkspacePaths.resolve("tests/config/dynamic-plugins-app-next.yaml") + : WorkspacePaths.resolve("tests/config/dynamic-plugins.yaml"), }); await rhdh.deploy(); }); + baseURL = rhdh.rhdhUrl; context = await browser.newContext({ baseURL }); page = await context.newPage(); uiHelper = new UIhelper(page); - home = new DynamicHomePagePo(page, uiHelper); + home = new DynamicHomePagePo(page, uiHelper, isAppNext); home.setBaseURL(baseURL); }); + test.beforeEach(() => { + // NFS login + widget seeding can exceed the default 90s per test. + if (isAppNext) { + test.setTimeout(10 * 60 * 1000); + } + }); + test.afterAll(async () => { await context?.close(); }); test("Verify default widgets from server config on first load", async () => { - await new LoginHelper(page).loginAsKeycloakUser(); + test.skip( + isAppNext, + "homepage-backend defaultWidgets are not supported on NFS yet", + ); + + await loginAsKeycloakUser(page); await home.resetToDefaults(); await home.verifyHomePageLoaded(); await home.verifyDefaultWidgetsFromConfig(DEFAULT_WIDGETS.developer); }); test("Verify cards display after seeding widgets", async () => { + // When the server-defaults test is skipped (NFS), log in here first. + // eslint-disable-next-line playwright/no-conditional-in-test -- NFS skips the prior login test + if (isAppNext) { + await loginAsKeycloakUser(page); + await home.verifyHomePageLoaded({ requireWidgets: false }); + } await home.seedHomePageWidgets(); await home.verifyHomePageLoaded(); await home.verifyAllCardsDisplayed(); @@ -99,7 +143,7 @@ test.describe.serial("Dynamic home page customization", () => { }); test("Each widget type can be added individually", async () => { - for (const widget of AVAILABLE_WIDGETS) { + for (const widget of home.availableWidgets) { await home.addWidget(widget); } await home.verifyAllCardsDisplayed(); @@ -198,17 +242,25 @@ test.describe.serial("Dynamic home page customization", () => { }); test("Per-user isolation: test2 sees defaults", async () => { + test.skip( + isAppNext, + "homepage-backend defaultWidgets are not supported on NFS yet", + ); await home.reloginAsKeycloakUser(); await home.verifyHomePageLoaded(); await home.seedHomePageWidgets(); test1Count = await home.getVisibleCardCount(); - expect(test1Count).toBe(AVAILABLE_WIDGETS.length); + expect(test1Count).toBe(home.availableWidgets.length); await home.reloginAsKeycloakUser("test2", "test2@123"); await home.verifyHomePageLoaded(); await home.verifyDefaultWidgetsFromConfig(DEFAULT_WIDGETS.developer); }); test("test2 customization does not affect test1 layout", async () => { + test.skip( + isAppNext, + "homepage-backend defaultWidgets / persona defaults are not supported on NFS yet", + ); await home.reloginAsKeycloakUser("test2", "test2@123"); await home.verifyHomePageLoaded(); await home.enterEditMode(); @@ -219,20 +271,63 @@ test.describe.serial("Dynamic home page customization", () => { const test1CountAfter = await home.getVisibleCardCount(); expect(test1CountAfter).toBe(test1Count); }); + + test("NFS: layout persists for same user; clears on account switch", async () => { + test.skip( + !isAppNext, + "legacy relies on backend defaultWidgets for cross-user defaults", + ); + await home.reloginAsKeycloakUser("test1", "test1@123", { + clearHomeStorage: true, + }); + await home.verifyHomePageLoaded({ requireWidgets: false }); + await home.seedHomePageWidgets(); + const seededCount = await home.getVisibleCardCount(); + expect(seededCount).toBe(home.availableWidgets.length); + + await home.reloginAsKeycloakUser("test2", "test2@123", { + clearHomeStorage: true, + }); + // test2 has no server defaultWidgets on NFS — home can load empty. + await home.verifyHomePageLoaded({ requireWidgets: false }); + await home.enterEditMode(); + await home.clearAllCardsIfPresent(); + await home.exitEditMode(); + + await home.reloginAsKeycloakUser("test1", "test1@123", { + clearHomeStorage: true, + }); + await home.verifyHomePageLoaded({ requireWidgets: false }); + await home.seedHomePageWidgets(); + expect(await home.getVisibleCardCount()).toBe( + home.availableWidgets.length, + ); + }); }); test.describe("Persona-based homepages", () => { + test.beforeEach(() => { + // Enable persona-based tests for NFS, once the backend defaultWidgets config is supported. + test.skip( + isAppNext, + "homepage-backend defaultWidgets are not supported on NFS yet", + ); + }); + test("Admin sees all group widgets", async () => { await home.reloginAsKeycloakUser( HOMEPAGE_ADMIN.username, HOMEPAGE_ADMIN.password, + { clearHomeStorage: true }, ); await home.verifyHomePageLoaded(); await home.verifyDefaultWidgetsFromConfig(DEFAULT_WIDGETS.admin); }); test("Developer sees developer widgets only", async () => { - await home.reloginAsKeycloakUser("test2", "test2@123"); + await home.reloginAsKeycloakUser("test2", "test2@123", { + clearHomeStorage: true, + }); await home.verifyHomePageLoaded(); await home.verifyDefaultWidgetsFromConfig(DEFAULT_WIDGETS.developer); for (const widget of DEFAULT_WIDGETS.adminOnly) { diff --git a/workspaces/homepage/e2e-tests/tests/utils/dynamic-homepage.ts b/workspaces/homepage/e2e-tests/tests/utils/dynamic-homepage.ts index 673c1cc989..0e8bfcf19f 100644 --- a/workspaces/homepage/e2e-tests/tests/utils/dynamic-homepage.ts +++ b/workspaces/homepage/e2e-tests/tests/utils/dynamic-homepage.ts @@ -16,7 +16,7 @@ const EXPECTED_CARD_TEXTS = [ "Top Visited", ] as const; -/** All widgets available in the "Add widget" dialog. */ +/** Legacy Scalprum "Add widget" dialog labels. */ export const AVAILABLE_WIDGETS = [ "Onboarding Section", "Entity Section", @@ -24,6 +24,26 @@ export const AVAILABLE_WIDGETS = [ "Top Visited", ] as const; +/** + * NFS AddWidgetDialog labels use HomePageWidgetBlueprint title/name + * (title || name). Onboarding/Entity have no title — dialog shows name. + */ +export const AVAILABLE_WIDGETS_NFS = [ + "Red Hat Developer Hub - Onboarding", + "Red Hat Developer Hub - Software Catalog", + "Recently Visited", + "Top Visited", +] as const; + +/** Map legacy add-widget labels to NFS dialog labels. */ +const NFS_WIDGET_LABELS = new Map([ + ["Onboarding Section", "Red Hat Developer Hub - Onboarding"], + ["Entity Section", "Red Hat Developer Hub - Software Catalog"], + ["Entity section", "Red Hat Developer Hub - Software Catalog"], + ["Recently Visited", "Recently Visited"], + ["Top Visited", "Top Visited"], +]); + const COMMON = ["Explore Your Software Catalog"]; const ADMIN_ONLY = ["Explore Templates", "Quick Access"]; const DEVELOPER_ONLY = ["Recently Visited", "Top Visited"]; @@ -37,6 +57,10 @@ export const DEFAULT_WIDGETS = { developerOnly: DEVELOPER_ONLY, }; +export function isHomepageAppNext(projectOrNamespace: string): boolean { + return projectOrNamespace.endsWith("-app-next"); +} + export const HOMEPAGE_ADMIN = { username: "homepage-admin", password: "homepage-admin@123", // gitleaks:allow @@ -47,12 +71,59 @@ const HOMEPAGE_TEST3 = { password: "test3@123", // gitleaks:allow }; +/** + * Keycloak login that tolerates slow NFS cold loads. + * + * Stock LoginHelper.loginAsKeycloakUser uses waitForLoad (progressbar state:hidden + * resolves immediately if the bar is not mounted yet) then clickButton("Sign In") + * under actionTimeout=10s. On homepage-app-next the Sign In card often appears only + * after ~30–60s of remote plugin loading, so login races and looks like a blank page. + */ +export async function loginAsKeycloakUser( + page: Page, + username = "test1", + password = "test1@123", +): Promise { + const helper = new LoginHelper(page); + await page.goto("/"); + await page.getByRole("button", { name: "Sign In", exact: true }).waitFor({ + state: "visible", + timeout: 240_000, + }); + const popupPromise = page.waitForEvent("popup"); + await page.getByRole("button", { name: "Sign In", exact: true }).click(); + const popup = await popupPromise; + await helper.logintoKeycloak(popup, username, password); + await page + .locator("nav a") + .first() + .waitFor({ state: "visible", timeout: 60_000 }); +} + export async function setupKeycloakGroups(): Promise { + const baseUrl = process.env.KEYCLOAK_BASE_URL; + if (!baseUrl) { + throw new Error( + "KEYCLOAK_BASE_URL is not set. Global setup should deploy Keycloak and set it; " + + "do not set SKIP_KEYCLOAK_DEPLOYMENT=true for homepage tests.", + ); + } + + // Local Keycloak from e2e-test-utils uses admin/admin123. Vault secrets are for CI. + const username = + process.env.VAULT_KEYCLOAK_ADMIN_USERNAME || + process.env.KEYCLOAK_ADMIN_USERNAME || + "admin"; + const password = + process.env.VAULT_KEYCLOAK_ADMIN_PASSWORD || + process.env.KEYCLOAK_ADMIN_PASSWORD || + "admin123"; + const keycloak = new KeycloakHelper(); await keycloak.connect({ - baseUrl: process.env.KEYCLOAK_BASE_URL!, - username: process.env.VAULT_KEYCLOAK_ADMIN_USERNAME!, - password: process.env.VAULT_KEYCLOAK_ADMIN_PASSWORD!, + baseUrl, + username, + password, }); await keycloak.deleteUser("rhdh", HOMEPAGE_ADMIN.username).catch(() => {}); @@ -86,12 +157,51 @@ export class DynamicHomePagePo { constructor( private readonly page: Page, private readonly ui: UIhelper, + private readonly isAppNext = false, ) {} setBaseURL(url: string): void { this.baseURL = url; } + /** Widget labels shown in the Add widget dialog for the active frontend. */ + get availableWidgets(): readonly string[] { + return this.isAppNext ? AVAILABLE_WIDGETS_NFS : AVAILABLE_WIDGETS; + } + + private widgetDialogLabel(widgetType: string): string { + if (!this.isAppNext) { + return widgetType; + } + return NFS_WIDGET_LABELS.get(widgetType) ?? widgetType; + } + + /** + * Clears the CustomHomepageGrid storage bucket used by NFS/legacy home. + * Layout is stored under storageApi bucket `home.customHomepage` (often + * localStorage). Clear between distinct users so shared browser storage + * cannot leak layouts when user-settings isolation is unavailable. + */ + async clearHomeLayoutStorage(): Promise { + await this.page.evaluate(() => { + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if ( + key && + (key.includes("home.customHomepage") || + key.includes("customHomepage") || + /[/:]home$/.test(key)) + ) { + keysToRemove.push(key); + } + } + for (const key of keysToRemove) { + localStorage.removeItem(key); + } + }); + } + private async signOut(): Promise { await this.page.goto(`${this.baseURL}/settings`); await this.page.getByTestId("user-settings-menu").click(); @@ -103,46 +213,56 @@ export class DynamicHomePagePo { async reloginAsKeycloakUser( username = "test1", password = "test1@123", + options?: { clearHomeStorage?: boolean }, ): Promise { await this.signOut(); await this.page.context().clearCookies(); - await this.page.goto(this.baseURL); - await new LoginHelper(this.page).loginAsKeycloakUser(username, password); + if (options?.clearHomeStorage) { + await this.clearHomeLayoutStorage(); + } + await loginAsKeycloakUser(this.page, username, password); } async reloginAsNonGroupUser(): Promise { await this.signOut(); await this.page.context().clearCookies(); - await this.page.goto(this.baseURL); - await new LoginHelper(this.page).loginAsKeycloakUser( + // Guest/non-group user must not inherit the previous user's layout from + // shared browser storage when switching accounts in the same context. + await this.clearHomeLayoutStorage(); + await loginAsKeycloakUser( + this.page, HOMEPAGE_TEST3.username, HOMEPAGE_TEST3.password, ); } - private readonly editButton = () => this.page.getByText("Edit"); + private readonly editButton = () => + this.page.getByRole("button", { name: "Edit", exact: true }); private readonly saveButton = () => - this.page.getByText("Save", { - exact: true, - }); + this.page.getByRole("button", { name: "Save", exact: true }); + private readonly cancelButton = () => + this.page.getByRole("button", { name: "Cancel", exact: true }); private readonly clearAllButton = () => this.page.getByRole("button", { name: "Clear all" }); private readonly restoreDefaultsButton = () => this.page.getByText("Restore defaults"); private readonly addWidgetButton = () => this.page.getByRole("button", { name: "Add widget" }); - private readonly resizeHandles = () => - this.page.locator(".react-resizable-handle"); private readonly deleteButtons = () => this.page.getByRole("button", { name: "Delete widget" }); private readonly greetingText = () => this.page.getByText(/Good (morning|afternoon|evening)/); - async verifyHomePageLoaded(): Promise { + async verifyHomePageLoaded(options?: { + requireWidgets?: boolean; + }): Promise { await this.ui.verifyHeading("Welcome back"); - await expect( - this.page.locator('[class*="react-grid-item"]').first(), - ).toBeVisible({ timeout: 15_000 }); + const requireWidgets = options?.requireWidgets ?? true; + if (requireWidgets) { + await expect( + this.page.locator('[class*="react-grid-item"]').first(), + ).toBeVisible({ timeout: 15_000 }); + } await this.dismissQuickstart(); } @@ -183,54 +303,114 @@ export class DynamicHomePagePo { async enterEditMode(): Promise { await this.ui.clickButton("Edit"); - await expect(this.saveButton()).toBeVisible(); + // NFS shows both Cancel and Save in edit mode; .or() + toBeVisible() hits strict mode + // when both match — wait for either via .first(). + await this.saveButton() + .or(this.cancelButton()) + .first() + .waitFor({ state: "visible", timeout: 10_000 }); } async exitEditMode(): Promise { - await this.ui.clickButton("Save"); - await expect(this.editButton()).toBeVisible(); + await this.dismissAddWidgetDialog(); + + // NFS only surfaces Save after a layout dimension change; add/remove alone leaves + // Save hidden and Cancel reverts to the last persisted layout. + if (this.isAppNext && !(await this.saveButton().isVisible())) { + await this.nudgeLayoutToEnableSave(); + } + + if (await this.saveButton().isVisible()) { + await this.saveButton().click(); + } else if (await this.cancelButton().isVisible()) { + await this.cancelButton().click(); + } + await expect(this.editButton()).toBeVisible({ timeout: 10_000 }); + } + + private async dismissAddWidgetDialog(): Promise { + const dialog = this.page.getByRole("dialog"); + if (await dialog.isVisible()) { + await this.page.keyboard.press("Escape"); + await expect(dialog).toBeHidden({ timeout: 5_000 }); + } + } + + /** Small resize so NFS edit toolbar exposes Save after widget add/remove. */ + private async nudgeLayoutToEnableSave(): Promise { + const gridItem = this.editableGridItem(); + if ((await gridItem.count()) === 0) { + return; + } + const handle = gridItem + .locator(".react-resizable-handle-se, .react-resizable-handle") + .last(); + if (!(await handle.isVisible())) { + return; + } + await this.dragResizeHandle(handle, { widthDelta: 0, heightDelta: 40 }); + await this.saveButton().waitFor({ state: "visible", timeout: 10_000 }); + } + + /** Grid item that contains a real widget (not an empty NFS placeholder row). */ + private editableGridItem(): Locator { + return this.page + .locator('[class*="react-grid-item"]') + .filter({ has: this.deleteButtons() }) + .last(); } /** - * Resizes one card via the first visible resize handle (while still in edit - * mode, before Save). Call after `enterEditMode` and adding a widget. + * Resizes one card via the SE resize handle (while still in edit mode, before Save). + * Call after `enterEditMode` and adding a widget. */ async resizeFirstCard(): Promise { - const handle = this.resizeHandles().first(); + const gridItem = this.editableGridItem(); + await expect(gridItem).toBeVisible({ timeout: 10_000 }); + + const handle = gridItem + .locator(".react-resizable-handle-se, .react-resizable-handle") + .last(); await expect(handle).toBeVisible(); - const panel = this.resizablePanelForHandle(handle); - const initialBox = await panel.boundingBox(); + + const initialBox = await gridItem.boundingBox(); expect(initialBox).not.toBeNull(); await this.dragResizeHandle(handle); - const finalBox = await panel.boundingBox(); - expect(finalBox).not.toBeNull(); - const widthChanged = finalBox!.width !== initialBox!.width; - const heightChanged = finalBox!.height !== initialBox!.height; - expect(widthChanged || heightChanged).toBe(true); - } - - /** Nearest `react-resizable` root for a handle (`.react-resizable-handle`). */ - private resizablePanelForHandle(handle: Locator): Locator { - return handle.locator( - 'xpath=ancestor::*[contains(@class,"react-resizable")][1]', - ); + // Measure the grid item — NFS widgets are full width so only height changes. + await expect + .poll(async () => { + const box = await gridItem.boundingBox(); + if (!box || !initialBox) { + return false; + } + return ( + Math.abs(box.height - initialBox.height) > 5 || + Math.abs(box.width - initialBox.width) > 5 + ); + }) + .toBe(true); } - private async dragResizeHandle(handle: Locator): Promise { + private async dragResizeHandle( + handle: Locator, + deltas?: { widthDelta?: number; heightDelta?: number }, + ): Promise { await handle.scrollIntoViewIfNeeded(); const box = await handle.boundingBox(); expect(box).not.toBeNull(); const startX = box!.x + box!.width / 2; const startY = box!.y + box!.height / 2; - const delta = 160; + // NFS home widgets default to full grid width — drag vertically to resize height. + const widthDelta = deltas?.widthDelta ?? (this.isAppNext ? 0 : 160); + const heightDelta = deltas?.heightDelta ?? (this.isAppNext ? 220 : 160); await this.page.mouse.move(startX, startY); await this.page.mouse.down(); - await this.page.mouse.move(startX + delta, startY + delta, { steps: 12 }); + await this.page.mouse.move(startX + widthDelta, startY + heightDelta, { + steps: 24, + }); await this.page.mouse.up(); - // eslint-disable-next-line playwright/no-wait-for-timeout -- layout after resize - await this.page.waitForTimeout(500); } async deleteAllCards(): Promise { @@ -252,6 +432,14 @@ export class DynamicHomePagePo { await this.clearAllButton().click(); } + /** Clear all only when the grid has cards (NFS users may start with an empty home). */ + async clearAllCardsIfPresent(): Promise { + if (await this.clearAllButton().isVisible()) { + await this.clearAllCardsWithButton(); + await this.verifyCardsDeleted(); + } + } + async verifyCardsDeleted(): Promise { const gridItems = this.page.locator('[class*="react-grid-item"]'); await expect(gridItems).toHaveCount(0, { timeout: 10_000 }); @@ -280,12 +468,18 @@ export class DynamicHomePagePo { } async addWidget(widgetType: string): Promise { + const label = this.widgetDialogLabel(widgetType); + const gridItems = this.page.locator('[class*="react-grid-item"]'); + const countBefore = await gridItems.count(); + await this.ui.clickButton("Add widget"); - // eslint-disable-next-line playwright/no-wait-for-timeout -- dialog open - await this.page.waitForTimeout(1000); - await this.page.getByRole("button", { name: widgetType }).click(); - // eslint-disable-next-line playwright/no-wait-for-timeout -- widget mount - await this.page.waitForTimeout(1000); + const widgetOption = this.page.getByRole("button", { name: label }); + await widgetOption.waitFor({ state: "visible", timeout: 30_000 }); + await widgetOption.click(); + + await expect(gridItems).toHaveCount(countBefore + 1, { timeout: 30_000 }); + await expect(gridItems.last()).toBeVisible({ timeout: 30_000 }); + await this.dismissAddWidgetDialog(); } /** Returns count of visible widget cards on the homepage grid. */ @@ -314,7 +508,7 @@ export class DynamicHomePagePo { await this.ui.clickButton("Add widget"); // eslint-disable-next-line playwright/no-wait-for-timeout -- dialog open await this.page.waitForTimeout(1000); - for (const widget of AVAILABLE_WIDGETS) { + for (const widget of this.availableWidgets) { await expect( this.page.getByRole("button", { name: widget }), ).toBeVisible(); diff --git a/workspaces/homepage/metadata/red-hat-developer-hub-backstage-plugin-homepage.yaml b/workspaces/homepage/metadata/red-hat-developer-hub-backstage-plugin-homepage.yaml index 7a898fb237..2dbf50fb4a 100644 --- a/workspaces/homepage/metadata/red-hat-developer-hub-backstage-plugin-homepage.yaml +++ b/workspaces/homepage/metadata/red-hat-developer-hub-backstage-plugin-homepage.yaml @@ -92,3 +92,41 @@ spec: sm: { w: 12, h: 3 } xs: { w: 12, h: 3 } xxs: { w: 12, h: 3 } + - title: New frontend system (app-next / NFS) + content: + app: + extensions: + - page:home: + config: + path: / + - api:home/visits: true + - app-root-element:home/visit-listener: true + - home-page-layout:home/dynamic-homepage-layout: + config: + customizable: true + widgetLayout: + RhdhTemplateSection: + priority: 300 + breakpoints: + xl: { w: 12, h: 5 } + lg: { w: 12, h: 5 } + RhdhEntitySection: + priority: 200 + breakpoints: + xl: { w: 12, h: 7 } + lg: { w: 12, h: 7 } + RhdhOnboardingSection: + priority: 100 + breakpoints: + xl: { w: 12, h: 6 } + lg: { w: 12, h: 6 } + # homepage.defaultWidgets is not supported on NFS yet — uncomment when it is: + # homepage: + # defaultWidgets: + # - id: onboarding + # ref: rhdh-onboarding-section + # - id: entity-list + # ref: rhdh-entity-section + # - id: template-list + # ref: rhdh-template-section +