diff --git a/docs/e2e-tests/README.md b/docs/e2e-tests/README.md index 813ce2fcc7..b7b0e81264 100644 --- a/docs/e2e-tests/README.md +++ b/docs/e2e-tests/README.md @@ -11,6 +11,8 @@ | `e2e-tests/playwright/e2e` | Contains all the end-to-end (E2E) test suites and test cases | | `e2e-tests/playwright/e2e/plugins` | Contains all the dynamic plugins E2E test suites and test cases | | `e2e-tests/playwright/utils` | Utilities for easier test development, from UI interaction tasks to network requests | +| `e2e-tests/unit/**/*.test.ts` | Vitest unit tests for shared helpers (run via `yarn test:unit`) | +| `e2e-tests/vitest.config.ts` | Vitest configuration for unit tests | | `e2e-tests/playwright/support` | Contains helper files for Playwright, like custom commands and page objects | | `e2e-tests/playwright-report/index.html` | HTML report of the test execution | | `e2e-tests/test-results` | Contains video recordings of the executed test cases | @@ -35,6 +37,8 @@ yarn playwright install chromium ## Adding a Test To incorporate a new test case, create a file with a `.spec.ts` extension in the `e2e-tests/playwright/e2e` directory. + +Unit tests for shared helpers (for example `poll-until.ts`) live in `e2e-tests/unit/` as `*.test.ts` and run with `yarn test:unit` (Vitest). E2E specs use `*.spec.ts` under `playwright/e2e/`. The tests within a spec file can run in parallel (by default) or sequentially if using the `.serial` modifier like in [these examples](../../e2e-tests/playwright/e2e/). Note that sequential execution is considered a bad practice and is strongly discouraged. To add or edit a test, you should adhere to the [contribution guidelines](./CONTRIBUTING.MD). @@ -83,7 +87,14 @@ The currently supported environment variables are: ### Running the Tests -The Playwright command line supports many options; see them [here](https://playwright.dev/docs/test-cli). Flags like `--ui` or `--headed` are very useful when debugging. You can also specify a specific test to run: +Unit tests (Vitest) do not need a deployed RHDH instance: + +```bash +cd e2e-tests +yarn test:unit +``` + +E2E tests (Playwright) require `BASE_URL` and a running application. The Playwright command line supports many options; see them [here](https://playwright.dev/docs/test-cli). Flags like `--ui` or `--headed` are very useful when debugging. You can also specify a specific test to run: ```bash yarn playwright test e2e-tests/playwright/e2e/your-test-file.spec.ts diff --git a/e2e-tests/README.md b/e2e-tests/README.md index c4b47cb173..624c2effcb 100644 --- a/e2e-tests/README.md +++ b/e2e-tests/README.md @@ -201,6 +201,7 @@ letting CI or a local run target a subset. | `@smoke` | Fast, high-signal check suitable to run on every PR. | | `@ga-plugin` | Exercises a generally-available (GA) plugin. | | `@non-ga-plugin` | Exercises a tech-preview / dev-preview (non-GA) plugin. | +| `@blocked` | Blocked by a known issue; tests are skipped with a Jira reference. | ```bash # Run only smoke-tagged tests @@ -404,18 +405,24 @@ This opens an interactive UI where you can select individual tests, watch them r After running `local-test-setup.sh`, these variables are set: -| Variable | Description | -| --------------------------- | --------------------------------------------- | -| `BASE_URL` | URL of the deployed RHDH instance | -| `SHOWCASE_URL` | Showcase deployment URL | -| `SHOWCASE_RBAC_URL` | Showcase RBAC deployment URL | -| `K8S_CLUSTER_URL` | OpenShift API server URL | -| `K8S_CLUSTER_TOKEN` | Service account token (48-hour duration) | -| `JOB_NAME` | Selected job name | -| `IMAGE_REGISTRY` | Image registry (default: `quay.io`) | -| `IMAGE_REPO` | Image repository (fallback: `QUAY_REPO`) | -| `TAG_NAME` | Image tag | -| Plus all secrets from Vault | (exported with `-`, `.`, `/` replaced by `_`) | +| Variable | Description | +| --------------------------- | ---------------------------------------------------------------------------------------------- | +| `BASE_URL` | URL of the deployed RHDH instance (Playwright uses this as the test base URL) | +| `SHOWCASE_URL` | Legacy name for the standard RHDH deployment URL (same value as `BASE_URL` for showcase tests) | +| `SHOWCASE_RBAC_URL` | Legacy name for the RBAC deployment URL | +| `K8S_CLUSTER_URL` | OpenShift API server URL | +| `K8S_CLUSTER_TOKEN` | Service account token (48-hour duration) | +| `JOB_NAME` | Selected job name | +| `IMAGE_REGISTRY` | Image registry (default: `quay.io`) | +| `IMAGE_REPO` | Image repository (fallback: `QUAY_REPO`) | +| `TAG_NAME` | Image tag | +| Plus all secrets from Vault | (exported with `-`, `.`, `/` replaced by `_`) | + +> **Note:** `BASE_URL` is the canonical Playwright base URL for the RHDH instance under test. +> `SHOWCASE_URL` and `SHOWCASE_RBAC_URL` are legacy names retained by `local-test-setup.sh` and +> deployment scripts; `local-test-setup.sh` sets `BASE_URL` from the appropriate legacy URL. +> Yarn scripts such as `yarn showcase`, `yarn showcase-rbac`, and `yarn test:stability` still use +> the `showcase` Playwright project name for historical reasons. ### Artifacts and Logs diff --git a/e2e-tests/oxlint.config.ts b/e2e-tests/oxlint.config.ts index 648437e905..4f854c1b21 100644 --- a/e2e-tests/oxlint.config.ts +++ b/e2e-tests/oxlint.config.ts @@ -1,5 +1,87 @@ import { defineConfig } from "oxlint"; +/** POM and helper methods that perform assertions on behalf of E2E specs. */ +const playwrightAssertFunctions = [ + "expect", + "toPass", + "verifyHeading", + "verifyWelcomeHeading", + "verifyGuestProfile", + "verifySignInPageTitle", + "verifyProfileHeading", + "verifySignInError", + "verifyQuickAccess", + "verifyLink", + "verifyRowsInTable", + "verifyRowInTableByUniqueText", + "verifyDivHasText", + "verifyComponentInCatalog", + "verifyComponentsInCatalog", + "verifyParagraph", + "verifyText", + "verifyTextinCard", + "verifyTextInCard", + "verifyVisitedCardContent", + "verifyAboutCardIsDisplayed", + "verifyPRStatisticsRendered", + "verifyPRRows", + "verifyPRRowsPerPage", + "waitForEntityPath", + "clickPullRequestFilter", + "verifyGithubUserProfile", + "verifySignInButtonVisible", + "verifyTemplateHeading", + "verifyTableCell", + "verifyDependencyResource", + "verifySharedCardCount", + "incrementFirstCardCounter", + "waitForOpenInCatalogLink", + "verifyComponentNameVisible", + "verifyLinkHidden", + "clearSearchIfVisible", + "sortCreatedAtDescending", + "verifyFirstRowCreatedAtNotEmpty", + "openLicensedUsersCatalog", + "verifyTestPageContent", + "verifyContextOneCard", + "verifyContextTwoCard", + "verifyTemplatesHeading", + "verifyDocumentationHeading", + "verifyDocHeading", + "verifyCreateReactAppReviewTableWithGroupOwner", + "verifyDependencyGraphLabels", + "launchTemplateAndVerifyIntro", + "runHttpRequestTemplateFlow", + "inspectEntityAndVerifyYaml", + "registerExistingComponent", + "runAccessibilityTests", + "validateLog", + "validateLogEvent", + "validateRbacLogEvent", + "checkRbacResponse", + "verifyTextInSelector", + "verifyPartialTextInSelector", + "verifyTextVisible", + "verifyLanguageToggleList", + "verifyLanguageSelectShowsOptions", + "verifyLanguageOptionsList", + "verifySelectedLanguage", + "verifySignOutMenuLabel", + "verifySidebarMenuItemHidden", + "verifyLocalizedUserSettingsLabelsWithOwnership", + "verifyBuildInfoCardVisible", + "verifyBuildInfoText", + "verifyGuestSignInMethodNotListed", + "verifyInactivityLogoutMessageHidden", + "verifyRhdhMetadata", + "verifyMenuItemInSection", + "verifyLearningPathLinksOpenInNewTab", + "verifyMainHeadingVisible", + "loginAsGuest", + "restartDeployment", + "waitForTitle", +]; + export default defineConfig({ plugins: ["eslint", "typescript", "unicorn", "oxc", "import", "node", "promise"], categories: { @@ -11,7 +93,7 @@ export default defineConfig({ typeAware: true, typeCheck: true, }, - jsPlugins: ["eslint-plugin-playwright", "eslint-plugin-check-file"], + jsPlugins: ["eslint-plugin-check-file"], ignorePatterns: [ "node_modules/**", "playwright-report/**", @@ -44,29 +126,9 @@ export default defineConfig({ "**": "KEBAB_CASE", }, ], - "playwright/no-wait-for-timeout": "error", - "playwright/no-force-option": "error", - "playwright/expect-expect": "error", - "playwright/valid-expect": "error", - "playwright/prefer-native-locators": "error", - "playwright/no-raw-locators": [ - "error", - { - allowed: [], - }, - ], - "playwright/no-skipped-test": [ - "error", - { - allowConditional: true, - }, - ], }, overrides: [ { - // Auth-provider specs deploy RHDH in beforeAll and use async Playwright hooks. - // strict-void-return and no-misused-promises produce false positives on those - // describe/beforeAll callbacks without improving test safety. files: ["playwright/e2e/auth-providers/**/*.spec.ts"], rules: { "typescript/strict-void-return": "off", @@ -74,8 +136,6 @@ export default defineConfig({ }, }, { - // Spec files orchestrate multi-step E2E flows; length limits target production - // code readability, not test scenarios that must stay in one file for clarity. files: ["**/*.spec.ts", "**/*.test.ts"], rules: { "eslint/max-lines": "off", @@ -83,9 +143,6 @@ export default defineConfig({ }, }, { - // Shared infrastructure (utils, support, data, e2e helpers) is split into - // modules but still contains cohesive orchestration (kube waits, deployment - // setup, log parsing). Complexity limits would force artificial fragmentation. files: [ "playwright/utils/**/*.ts", "playwright/support/**/*.ts", @@ -99,27 +156,32 @@ export default defineConfig({ }, }, { - // Facade modules aggregate many submodules by design (e.g. KubeClient re-exports, - // rhdh-deployment orchestration, locale translation maps). A flat import count - // does not reflect coupling when each import is a focused submodule. files: ["playwright/utils/**/*.ts", "playwright/e2e/localization/**/*.ts"], rules: { "import/max-dependencies": "off", }, }, { - // valid-title / valid-describe-callback: existing suite uses legacy naming - // patterns that do not match the plugin's strict conventions. - // no-wait-for-selector: replaced with expect() and locator.waitFor() per - // hardening guidelines; rule would flag intentional migration patterns. - // expect-expect + assertFunctionNames: POM verify* helpers and loginAsGuest - // perform assertions on behalf of the spec; register them so specs are not - // forced to duplicate expect() calls after every helper invocation. - files: ["**/*.spec.ts", "**/*.test.ts", "playwright/**/*.ts"], + // E2E: *.spec.ts only. Playwright jsPlugin is not loaded for other files. + files: ["**/*.spec.ts"], + jsPlugins: ["eslint-plugin-playwright"], rules: { - // Playwright requires object destructuring for hook/test callbacks that take - // testInfo as a second argument (e.g. async ({}, testInfo) =>). Oxlint's - // no-empty-pattern rejects {}; disable it here so lint and runtime agree. + "playwright/no-wait-for-timeout": "error", + "playwright/no-force-option": "error", + "playwright/valid-expect": "error", + "playwright/prefer-native-locators": "error", + "playwright/no-raw-locators": [ + "error", + { + allowed: [], + }, + ], + "playwright/no-skipped-test": [ + "error", + { + allowConditional: true, + }, + ], "eslint/no-empty-pattern": "off", "playwright/valid-title": "off", "playwright/valid-describe-callback": "off", @@ -127,40 +189,22 @@ export default defineConfig({ "playwright/expect-expect": [ "error", { - assertFunctionNames: [ - "expect", - "toPass", - "verifyHeading", - "verifyQuickAccess", - "verifyLink", - "verifyRowsInTable", - "verifyRowInTableByUniqueText", - "verifyDivHasText", - "verifyComponentInCatalog", - "verifyParagraph", - "verifyText", - "verifyTextinCard", - "verifyVisitedCardContent", - "verifyAboutCardIsDisplayed", - "verifyPRStatisticsRendered", - "verifyPRRows", - "verifyPRRowsPerPage", - "registerExistingComponent", - "inspectEntityAndVerifyYaml", - "runAccessibilityTests", - "validateLog", - "validateLogEvent", - "validateRbacLogEvent", - "checkRbacResponse", - "verifyTextInSelector", - "verifyPartialTextInSelector", - "loginAsGuest", - "restartDeployment", - "waitForTitle", - ], + assertFunctionNames: playwrightAssertFunctions, }, ], }, }, + { + // Unit: *.test.ts only. Vitest plugin is not loaded for other files. + files: ["**/*.test.ts"], + plugins: ["vitest"], + rules: { + "vitest/expect-expect": "error", + "vitest/valid-expect": "off", + "vitest/no-conditional-in-test": "off", + "vitest/no-conditional-tests": "off", + "eslint/no-empty-pattern": "off", + }, + }, ], }); diff --git a/e2e-tests/package.json b/e2e-tests/package.json index c79fdaa48e..54bc75bdf0 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "test:stability": "playwright test --project=showcase --retries=0", "showcase": "playwright test --project=showcase", "showcase-rbac": "playwright test --project=showcase-rbac", "showcase-k8s": "playwright test --project=showcase-k8s", @@ -22,6 +23,7 @@ "lint": "oxlint .", "lint:fix": "oxlint --fix .", "test:list": "playwright test --list", + "test:unit": "vitest run", "fmt": "oxfmt .", "fmt:check": "oxfmt --check .", "postinstall": "playwright install chromium", @@ -47,7 +49,6 @@ "@playwright/test": "1.61.0", "@types/js-yaml": "4.0.9", "@types/node": "24.13.2", - "@types/node-fetch": "2.6.13", "@types/pg": "8.20.0", "eslint-plugin-check-file": "3.3.1", "eslint-plugin-playwright": "2.10.4", @@ -58,7 +59,8 @@ "oxlint": "1.71.0", "oxlint-tsgolint": "0.23.0", "shellcheck": "4.1.0", - "typescript": "6.0.3" + "typescript": "6.0.3", + "vitest": "^4.1.9" }, "engines": { "node": "24" diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 6c87fb3271..95e7e3406c 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -34,8 +34,9 @@ const k8sSpecificConfig = { }; export default defineConfig({ + globalSetup: "./playwright/global-setup.ts", timeout: 90 * 1000, - testDir: "./playwright", + testDir: "./playwright/e2e", /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: process.env.CI !== undefined && process.env.CI !== "", /* Retry on CI only */ @@ -63,9 +64,8 @@ export default defineConfig({ ...devices["Desktop Chrome"], viewport: { width: 1920, height: 1080 }, // Note: this video config only applies to tests using the built-in { page } fixture. - // Tests that create their own context via setupBrowser() in playwright/utils/common.ts - // must configure recordVideo explicitly because manually created contexts don't - // inherit these recording options. + // Tests that share one browser context across a describe block should use + // the worker-scoped rhdhPage / rhdhContext fixtures from @support/coverage/test. video: { mode: "retain-on-failure", size: { width: 1280, height: 720 }, @@ -83,10 +83,10 @@ export default defineConfig({ { name: PW_PROJECT.SMOKE_TEST, testMatch: "**/playwright/e2e/smoke-test.spec.ts", - retries: 10, }, { name: PW_PROJECT.SHOWCASE, + timeout: 180 * 1000, dependencies: [PW_PROJECT.SMOKE_TEST], testIgnore: [ "**/playwright/seed.spec.ts", @@ -110,6 +110,7 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_AUTH_PROVIDERS, + timeout: 600 * 1000, testMatch: ["**/playwright/e2e/auth-providers/*.spec.ts"], testIgnore: [ // temporarily disable github-happy-path @@ -174,10 +175,6 @@ export default defineConfig({ { name: PW_PROJECT.SHOWCASE_RUNTIME, workers: 1, - // Runtime tests restart the RHDH deployment (ConfigMap changes, - // external DB reconfiguration, schema-mode setup). Each restart - // takes ~60-90 s on a typical cluster, so the default 90 s global - // timeout is insufficient. 10 minutes gives comfortable headroom. timeout: 10 * 60 * 1000, testMatch: [ "**/playwright/e2e/configuration-test/config-map.spec.ts", diff --git a/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts b/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts index 97405af452..ccf265f84d 100644 --- a/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts +++ b/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts @@ -1,10 +1,11 @@ import { test } from "@support/coverage/test"; import { CatalogImport } from "../../support/pages/catalog-import"; +import { SelfServicePage } from "../../support/pages/self-service-page"; import { APIHelper } from "../../utils/api-helper"; import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; import { LogUtils } from "./log-utils"; + const template = "https://github.com/janus-qe/sample-service/blob/main/demo_template.yaml"; const entityName = "hello-world-2"; const namespace = "default"; @@ -26,7 +27,7 @@ async function ensureEntityDoesNotExist() { } test.describe.serial("Audit Log check for Catalog Plugin", () => { - let uiHelper: UIhelper; + let selfServicePage: SelfServicePage; let common: Common; let catalogImport: CatalogImport; @@ -38,16 +39,16 @@ test.describe.serial("Audit Log check for Catalog Plugin", () => { }); test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); + selfServicePage = new SelfServicePage(page); common = new Common(page); catalogImport = new CatalogImport(page); await common.loginAsGuest(); - await uiHelper.goToSelfServicePage(); + await selfServicePage.open(); }); test("Should fetch logs for entity-mutate event and validate log structure and values", async () => { await ensureEntityExists(); - await uiHelper.clickButton("Import an existing Git repository"); + await selfServicePage.clickImportGitRepository(); await catalogImport.registerExistingComponent(template, false); await LogUtils.validateLogEvent( "entity-mutate", @@ -64,7 +65,7 @@ test.describe.serial("Audit Log check for Catalog Plugin", () => { test("Should fetch logs for location-mutate event and validate log structure and values", async () => { await ensureEntityDoesNotExist(); - await uiHelper.clickButton("Import an existing Git repository"); + await selfServicePage.clickImportGitRepository(); await catalogImport.registerExistingComponent(template, false); await LogUtils.validateLogEvent( "location-mutate", diff --git a/e2e-tests/playwright/e2e/audit-log/auditor-rbac.spec.ts b/e2e-tests/playwright/e2e/audit-log/auditor-rbac.spec.ts index 89ba3f8482..ec21da75cb 100644 --- a/e2e-tests/playwright/e2e/audit-log/auditor-rbac.spec.ts +++ b/e2e-tests/playwright/e2e/audit-log/auditor-rbac.spec.ts @@ -1,7 +1,7 @@ -import { test, expect, Page } from "@support/coverage/test"; +import { test, expect } from "@support/coverage/test"; import RhdhRbacApi from "../../support/api/rbac-api"; -import { Common, setupBrowser, teardownBrowser } from "../../utils/common"; +import { Common } from "../../utils/common"; import { RBAC_API, ROLE_NAME, @@ -25,19 +25,16 @@ let rbacApi: RhdhRbacApi; /* ======================================================================== */ test.describe("Auditor check for RBAC Plugin", () => { - let page: Page; - - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(async ({ rhdhPage }) => { test.info().annotations.push({ type: "component", description: "audit-log", }); await (await import("./log-utils")).LogUtils.loginToOpenShift(); - page = (await setupBrowser(browser, testInfo)).page; - common = new Common(page); + common = new Common(rhdhPage); await common.loginAsKeycloakUser(); - rbacApi = await RhdhRbacApi.buildRbacApi(page); + rbacApi = await RhdhRbacApi.buildRbacApi(rhdhPage); }); /* --------------------------------------------------------------------- */ @@ -279,8 +276,4 @@ test.describe("Auditor check for RBAC Plugin", () => { ["policy.entity.read", USER_ENTITY_REF], ); }); - - test.afterAll(async ({}, testInfo) => { - await teardownBrowser(page, testInfo); - }); }); diff --git a/e2e-tests/playwright/e2e/audit-log/log-utils.ts b/e2e-tests/playwright/e2e/audit-log/log-utils.ts index 0a87edfb6c..44d9327bd4 100644 --- a/e2e-tests/playwright/e2e/audit-log/log-utils.ts +++ b/e2e-tests/playwright/e2e/audit-log/log-utils.ts @@ -5,6 +5,7 @@ import { expect } from "@playwright/test"; import { getBackstageDeploySelector } from "../../utils/helper"; import { BACKSTAGE_BACKEND_CONTAINER } from "../../utils/kube-client"; +import { sleep } from "../../utils/poll-until"; import { Log, type LogRequest, type EventStatus, type EventSeverityLevel } from "./logs"; function formatError(error: unknown): string { @@ -47,7 +48,7 @@ function compareValues(actual: unknown, expected: unknown): void { } } -function validateLog(actual: Log, expected: Partial): void { +function validateLog(actual: Record, expected: Partial): void { for (const [key, expectedValue] of Object.entries(expected)) { if (expectedValue === undefined) { continue; @@ -56,47 +57,17 @@ function validateLog(actual: Log, expected: Partial): void { } } -function getLogProperty(log: Log, key: string): unknown { - switch (key) { - case "actor": - return log.actor; - case "eventId": - return log.eventId; - case "isAuditEvent": - return log.isAuditEvent; - case "severityLevel": - return log.severityLevel; - case "plugin": - return log.plugin; - case "request": - return log.request; - case "response": - return log.response; - case "service": - return log.service; - case "status": - return log.status; - case "timestamp": - return log.timestamp; - case "meta": - return log.meta; - case "message": - return log.message; - case "name": - return log.name; - case "stack": - return log.stack; - default: - return undefined; - } +function getLogProperty(log: Record, key: string): unknown { + return log[key]; } -function parseLogFromJson(text: string): Log { +/** Parse audit log JSON without applying Log constructor defaults. */ +function parseLogFromJson(text: string): Record { const parsed: unknown = JSON.parse(text); if (!isRecord(parsed)) { throw new TypeError("Audit log JSON must be an object"); } - return new Log(parsed as Partial); + return parsed; } export const LogUtils = { @@ -239,9 +210,7 @@ export const LogUtils = { attempt++; if (attempt <= maxRetries) { console.log(`Waiting ${retryDelay / 1000} seconds before retrying...`); - await new Promise((resolve) => { - setTimeout(resolve, retryDelay); - }); + await sleep(retryDelay); } } @@ -303,7 +272,7 @@ export const LogUtils = { try { const actualLog = await LogUtils.getPodLogsWithGrep(filterWordsAll, namespace); - let parsedLog: Log; + let parsedLog: Record; try { parsedLog = parseLogFromJson(actualLog); } catch (parseError) { diff --git a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts index f917bb5024..69a30a1c83 100644 --- a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts @@ -1,11 +1,9 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; -import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; -import { Common, setupBrowser } from "../../utils/common"; +import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { SettingsPage } from "../../support/pages/settings-page"; +import { Common } from "../../utils/common"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; -import { UIhelper } from "../../utils/ui-helper"; -let page: Page; -let context: BrowserContext; /* SUPORTED RESOLVERS GITHUB: @@ -15,125 +13,60 @@ GITHUB: [x] emailLocalPartMatchingUserEntityName */ -// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL -test.describe("Configure Github Provider", async () => { +const harness = await AuthProviderHarness.create("albarbaro-test-namespace-github"); + +test.describe("Configure Github Provider", () => { + test.use({ baseURL: harness.backstageUrl }); + let common: Common; - let uiHelper: UIhelper; - - const namespace = "albarbaro-test-namespace-github"; - const appConfigMap = "app-config-rhdh"; - const rbacConfigMap = "rbac-policy"; - const dynamicPluginsConfigMap = "dynamic-plugins"; - const secretName = "rhdh-secrets"; - - // set deployment instance - const deployment: RHDHDeployment = new RHDHDeployment( - namespace, - appConfigMap, - rbacConfigMap, - dynamicPluginsConfigMap, - secretName, - ); - deployment.instanceName = "rhdh"; - - // compute backstage baseurl - const backstageUrl = await deployment.computeBackstageUrl(); - const backstageBackendUrl = await deployment.computeBackstageBackendUrl(); - console.log(`Backstage BaseURL is: ${backstageUrl}`); - - test.use({ baseURL: backstageUrl }); - - test.beforeAll(async ({ browser }, testInfo) => { + let settingsPage: SettingsPage; + let page: Page; + let context: BrowserContext; + + test.beforeAll(async ({ rhdhPage, rhdhContext }) => { test.info().annotations.push({ type: "component", description: "authentication", }); - test.info().setTimeout(600 * 1000); - // load default configs from yaml files - await deployment.loadAllConfigs(); - - // setup playwright helpers - ({ context, page } = await setupBrowser(browser, testInfo)); - common = new Common(page); - uiHelper = new UIhelper(page); - - // expect some expected variables - - expect(process.env.AUTH_PROVIDERS_GH_ORG_NAME!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_USER_2FA!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ADMIN_2FA!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG_APP_ID!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET!).toBeDefined(); - - // clean old namespaces - await deployment.deleteNamespaceIfExists(); - - // create namespace and wait for it to be active - await (await deployment.createNamespace()).waitForNamespaceActive(); - - // create all base configmaps - await deployment.createAllConfigs(); - - // generate static token - await deployment.generateStaticToken(); - - // set enviroment variables and create secret - if ( - process.env.ISRUNNINGLOCAL === undefined || - process.env.ISRUNNINGLOCAL === "" || - process.env.ISRUNNINGLOCAL === "false" - ) { - await deployment.addSecretData("BASE_URL", backstageUrl); - await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); - } - await deployment.addSecretData( + page = rhdhPage; + context = rhdhContext; + common = new Common(rhdhPage); + settingsPage = new SettingsPage(rhdhPage); + + harness.expectEnvVars([ "AUTH_PROVIDERS_GH_ORG_NAME", - process.env.AUTH_PROVIDERS_GH_ORG_NAME!, - ); - await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET", - process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!, - ); - await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", - process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID!, - ); - await deployment.addSecretData( + "AUTH_PROVIDERS_GH_USER_PASSWORD", + "AUTH_PROVIDERS_GH_USER_2FA", + "AUTH_PROVIDERS_GH_ADMIN_2FA", "AUTH_PROVIDERS_GH_ORG_APP_ID", - process.env.AUTH_PROVIDERS_GH_ORG_APP_ID!, - ); - await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY", - process.env.AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY!, - ); - await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET", - process.env.AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET!, - ); - - await deployment.createSecret(); + ]); + + await harness.loadConfigsAndProvisionNamespace(); + await harness.addBaseUrlSecretsIfRemote(); + await harness.addSecretsFromEnv({ + AUTH_PROVIDERS_GH_ORG_NAME: "AUTH_PROVIDERS_GH_ORG_NAME", + AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET: "AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET", + AUTH_PROVIDERS_GH_ORG_CLIENT_ID: "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", + AUTH_PROVIDERS_GH_ORG_APP_ID: "AUTH_PROVIDERS_GH_ORG_APP_ID", + AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY: "AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY", + AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET: "AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET", + }); + await harness.createSecret(); - // enable github login with ingestion console.log("[TEST] Enabling GitHub login with ingestion..."); - await deployment.enableGithubLoginWithIngestion(); - await deployment.updateAllConfigs(); + await harness.deployment.enableGithubLoginWithIngestion(); + await harness.deployment.updateAllConfigs(); console.log("[TEST] GitHub login with ingestion enabled successfully"); - // create backstage deployment and wait for it to be ready - await deployment.createBackstageDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployAndWait(); }); test.beforeEach(() => { - test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); @@ -145,22 +78,16 @@ test.describe("Configure Github Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("RHDH QE Admin"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("RHDH QE Admin"); await common.signOut(); await context.clearCookies(); }); test("Login with Github usernameMatchingUserEntityName resolver", async () => { //A github sign-in resolver that looks up the user using their github username as the entity name. - await deployment.setGithubResolver("usernameMatchingUserEntityName", false); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setGithubResolver("usernameMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); const login = await common.githubLogin( "rhdhqeauthadmin", @@ -169,22 +96,16 @@ test.describe("Configure Github Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("RHDH QE Admin"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("RHDH QE Admin"); await common.signOut(); await context.clearCookies(); }); test("Login with Github emailMatchingUserEntityProfileEmail resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setGithubResolver("emailMatchingUserEntityProfileEmail", false); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setGithubResolver("emailMatchingUserEntityProfileEmail", false); + await harness.reconcileAfterConfigChange(); const login = await common.githubLogin( "rhdhqeauth1", @@ -193,20 +114,14 @@ test.describe("Configure Github Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await context.clearCookies(); }); test("Login with Github emailLocalPartMatchingUserEntityName resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setGithubResolver("emailLocalPartMatchingUserEntityName", false); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setGithubResolver("emailLocalPartMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); const login = await common.githubLogin( "rhdhqeauth1", @@ -218,19 +133,16 @@ test.describe("Configure Github Provider", async () => { expect(login).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await context.clearCookies(); }); test(`Set Github sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty("auth.providers.github.production.sessionDuration", "3days"); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + harness.deployment.setAppConfigProperty( + "auth.providers.github.production.sessionDuration", + "3days", + ); + await harness.reconcileAfterConfigChange(); const login = await common.githubLogin( "rhdhqeauthadmin", @@ -255,53 +167,56 @@ test.describe("Configure Github Provider", async () => { expect(actualDuration).toBeGreaterThan(threeDays - tolerance); expect(actualDuration).toBeLessThan(threeDays + tolerance); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("RHDH QE Admin"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("RHDH QE Admin"); await common.signOut(); await context.clearCookies(); }); test(`Ingestion of Github users and groups: verify the user entities and groups are created with the correct relationships`, async () => { - test.setTimeout(300 * 1000); - await expect - .poll(() => deployment.checkUserIsIngestedInCatalog(["RHDH QE User 1", "RHDH QE Admin"]), { - timeout: 120_000, - }) + .poll( + () => harness.deployment.checkUserIsIngestedInCatalog(["RHDH QE User 1", "RHDH QE Admin"]), + { timeout: 120_000 }, + ) .toBe(true); expect( - await deployment.checkGroupIsIngestedInCatalog(["test_admins", "test_all", "test_users"]), + await harness.deployment.checkGroupIsIngestedInCatalog([ + "test_admins", + "test_all", + "test_users", + ]), ).toBe(true); - expect(await deployment.checkUserIsInGroup("rhdhqeauthadmin", "test_admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("rhdhqeauth1", "test_users")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("rhdhqeauthadmin", "test_admins")).toBe( + true, + ); + expect(await harness.deployment.checkUserIsInGroup("rhdhqeauth1", "test_users")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("test_users", "test_all")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("test_admins", "test_all")).toBe(true); + expect(await harness.deployment.checkGroupIsChildOfGroup("test_users", "test_all")).toBe(true); + expect(await harness.deployment.checkGroupIsChildOfGroup("test_admins", "test_all")).toBe(true); expect( - await deployment.checkUserHasAnnotation( + await harness.deployment.checkUserHasAnnotation( "rhdhqeauthadmin", "MY_CUSTOM_ANNOTATION", "rhdhqeauthadmin", ), ).toBe(true); expect( - await deployment.checkUserHasAnnotation("rhdhqeauth1", "MY_CUSTOM_ANNOTATION", "rhdhqeauth1"), + await harness.deployment.checkUserHasAnnotation( + "rhdhqeauth1", + "MY_CUSTOM_ANNOTATION", + "rhdhqeauth1", + ), ).toBe(true); }); test("Login with Github as only auth provider with disableIdentityResolution should fail", async () => { - deployment.setAppConfigProperty( + harness.deployment.setAppConfigProperty( "auth.providers.github.production.disableIdentityResolution", "true", ); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.reconcileAfterConfigChange(); const login = await common.githubLogin( "rhdhqeauth1", @@ -311,15 +226,13 @@ test.describe("Configure Github Provider", async () => { expect(login).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage( + await settingsPage.verifySignInError( /Login failed; caused by Error: The GitHub provider is not configured to support sign-in/u, ); await context.clearCookies(); }); test.afterAll(async () => { - console.log("[TEST] Starting cleanup..."); - await deployment.killRunningProcess(); - console.log("[TEST] Cleanup completed"); + await harness.cleanup(); }); }); diff --git a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts index 3925d504eb..4fe174da5b 100644 --- a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts @@ -1,11 +1,9 @@ -import { test, expect, Page, BrowserContext } from "@support/coverage/test"; +import { test, expect, BrowserContext } from "@support/coverage/test"; +import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { SettingsPage } from "../../support/pages/settings-page"; import { GitLabHelper } from "../../utils/authentication-providers/gitlab-helper"; -import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; -import { Common, setupBrowser } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; -let page: Page; -let context: BrowserContext; +import { Common } from "../../utils/common"; /* SUPORTED RESOLVERS GITLAB: @@ -15,64 +13,42 @@ GITLAB: [x] emailLocalPartMatchingUserEntityName */ -// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL -test.describe("Configure GitLab Provider", async () => { +const harness = await AuthProviderHarness.create("albarbaro-test-namespace-gitlab"); + +test.describe("Configure GitLab Provider", () => { + test.use({ baseURL: harness.backstageUrl }); + let common: Common; - let uiHelper: UIhelper; + let settingsPage: SettingsPage; + let context: BrowserContext; let gitlabHelper: GitLabHelper; let oauthAppId: number | null = null; - const namespace = "albarbaro-test-namespace-gitlab"; - const appConfigMap = "app-config-rhdh"; - const rbacConfigMap = "rbac-policy"; - const dynamicPluginsConfigMap = "dynamic-plugins"; - const secretName = "rhdh-secrets"; - - // set deployment instance - const deployment: RHDHDeployment = new RHDHDeployment( - namespace, - appConfigMap, - rbacConfigMap, - dynamicPluginsConfigMap, - secretName, - ); - deployment.instanceName = "rhdh"; - - // compute backstage baseurl - const backstageUrl = await deployment.computeBackstageUrl(); - const backstageBackendUrl = await deployment.computeBackstageBackendUrl(); - console.log(`Backstage BaseURL is: ${backstageUrl}`); - - test.use({ baseURL: backstageUrl }); - - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(async ({ rhdhPage, rhdhContext }) => { test.info().annotations.push({ type: "component", description: "authentication", }); - test.info().setTimeout(600 * 1000); - // load default configs from yaml files - await deployment.loadAllConfigs(); + context = rhdhContext; + common = new Common(rhdhPage); + settingsPage = new SettingsPage(rhdhPage); - // setup playwright helpers - ({ context, page } = await setupBrowser(browser, testInfo)); - common = new Common(page); - uiHelper = new UIhelper(page); + harness.expectEnvVars([ + "AUTH_PROVIDERS_GITLAB_HOST", + "AUTH_PROVIDERS_GITLAB_TOKEN", + "AUTH_PROVIDERS_GITLAB_PARENT_ORG", + "DEFAULT_USER_PASSWORD", + ]); - // expect some expected variables - expect(process.env.AUTH_PROVIDERS_GITLAB_HOST!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GITLAB_TOKEN!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_GITLAB_PARENT_ORG!).toBeDefined(); - expect(process.env.DEFAULT_USER_PASSWORD!).toBeDefined(); + await harness.loadConfigsAndProvisionNamespace(); - // Initialize GitLab helper and create OAuth application dynamically gitlabHelper = new GitLabHelper({ host: process.env.AUTH_PROVIDERS_GITLAB_HOST!, personalAccessToken: process.env.AUTH_PROVIDERS_GITLAB_TOKEN!, }); - const callbackUrl = `${backstageBackendUrl}/api/auth/gitlab/handler/frame`; + const callbackUrl = `${harness.backstageBackendUrl}/api/auth/gitlab/handler/frame`; const oauthAppName = `rhdh-test-${Date.now()}`; console.log(`[TEST] Creating GitLab OAuth application: ${oauthAppName}`); const oauthApp = await gitlabHelper.createOAuthApplication( @@ -85,60 +61,28 @@ test.describe("Configure GitLab Provider", async () => { oauthAppId = oauthApp.id; console.log(`[TEST] GitLab OAuth application created - ID: ${oauthApp.application_id}`); - // clean old namespaces - await deployment.deleteNamespaceIfExists(); - - // create namespace and wait for it to be active - await (await deployment.createNamespace()).waitForNamespaceActive(); - - // create all base configmaps - await deployment.createAllConfigs(); - - // generate static token - await deployment.generateStaticToken(); - - // set enviroment variables and create secret - if ( - process.env.ISRUNNINGLOCAL === undefined || - process.env.ISRUNNINGLOCAL === "" || - process.env.ISRUNNINGLOCAL === "false" - ) { - await deployment.addSecretData("BASE_URL", backstageUrl); - await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); - } - await deployment.addSecretData( - "AUTH_PROVIDERS_GITLAB_HOST", - process.env.AUTH_PROVIDERS_GITLAB_HOST!, - ); - await deployment.addSecretData( - "AUTH_PROVIDERS_GITLAB_PARENT_ORG", - process.env.AUTH_PROVIDERS_GITLAB_PARENT_ORG!, - ); - await deployment.addSecretData("AUTH_PROVIDERS_GITLAB_CLIENT_ID", oauthApp.application_id); - await deployment.addSecretData("AUTH_PROVIDERS_GITLAB_CLIENT_SECRET", oauthApp.secret); - await deployment.addSecretData( - "AUTH_PROVIDERS_GITLAB_TOKEN", - process.env.AUTH_PROVIDERS_GITLAB_TOKEN!, + await harness.addBaseUrlSecretsIfRemote(); + await harness.addSecretsFromEnv({ + AUTH_PROVIDERS_GITLAB_HOST: "AUTH_PROVIDERS_GITLAB_HOST", + AUTH_PROVIDERS_GITLAB_PARENT_ORG: "AUTH_PROVIDERS_GITLAB_PARENT_ORG", + AUTH_PROVIDERS_GITLAB_TOKEN: "AUTH_PROVIDERS_GITLAB_TOKEN", + }); + await harness.deployment.addSecretData( + "AUTH_PROVIDERS_GITLAB_CLIENT_ID", + oauthApp.application_id, ); + await harness.deployment.addSecretData("AUTH_PROVIDERS_GITLAB_CLIENT_SECRET", oauthApp.secret); + await harness.createSecret(); - await deployment.createSecret(); - - // enable gitlab login with ingestion console.log("[TEST] Enabling GitLab login with ingestion..."); - await deployment.enableGitlabLoginWithIngestion(); - await deployment.updateAllConfigs(); + await harness.deployment.enableGitlabLoginWithIngestion(); + await harness.deployment.updateAllConfigs(); console.log("[TEST] GitLab login with ingestion enabled successfully"); - // create backstage deployment and wait for it to be ready - await deployment.createBackstageDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployAndWait(); }); test.beforeEach(() => { - test.info().setTimeout(60 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); @@ -146,8 +90,8 @@ test.describe("Configure GitLab Provider", async () => { const login = await common.gitlabLogin("user1", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("user1"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("user1"); await common.signOut(); await context.clearCookies(); }); @@ -155,12 +99,18 @@ test.describe("Configure GitLab Provider", async () => { test(`Ingestion of GitLab users and groups: verify the user entities and groups are created with the correct relationships`, async () => { await expect .poll( - () => deployment.checkUserIsIngestedInCatalog(["user1", "user2", "user3", "Administrator"]), + () => + harness.deployment.checkUserIsIngestedInCatalog([ + "user1", + "user2", + "user3", + "Administrator", + ]), { timeout: 120_000 }, ) .toBe(true); expect( - await deployment.checkGroupIsIngestedInCatalog([ + await harness.deployment.checkGroupIsIngestedInCatalog([ "my-org", "group1", "all", @@ -169,41 +119,44 @@ test.describe("Configure GitLab Provider", async () => { ]), ).toBe(true); - expect(await deployment.checkUserIsInGroup("user1", "all")).toBe(true); - expect(await deployment.checkUserIsInGroup("user2", "all")).toBe(true); - expect(await deployment.checkUserIsInGroup("user3", "all")).toBe(true); - expect(await deployment.checkUserIsInGroup("root", "all")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("user1", "all")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("user2", "all")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("user3", "all")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("root", "all")).toBe(true); - expect(await deployment.checkUserIsInGroup("root", "group1")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("root", "group1")).toBe(true); - expect(await deployment.checkUserIsInGroup("user1", "group1-nested")).toBe(true); - expect(await deployment.checkUserIsInGroup("user2", "group1-nested")).toBe(true); - expect(await deployment.checkUserIsInGroup("root", "group1-nested")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("user1", "group1-nested")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("user2", "group1-nested")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("root", "group1-nested")).toBe(true); - expect(await deployment.checkUserIsInGroup("user3", "group1-nested-nested_2")).toBe(true); - expect(await deployment.checkUserIsInGroup("root", "group1-nested-nested_2")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("user3", "group1-nested-nested_2")).toBe( + true, + ); + expect(await harness.deployment.checkUserIsInGroup("root", "group1-nested-nested_2")).toBe( + true, + ); - expect(await deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("my-org", "group1")).toBe(true); + expect(await harness.deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe(true); + expect(await harness.deployment.checkGroupIsParentOfGroup("my-org", "group1")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("all", "my-org")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("my-org", "all")).toBe(true); + expect(await harness.deployment.checkGroupIsChildOfGroup("all", "my-org")).toBe(true); + expect(await harness.deployment.checkGroupIsParentOfGroup("my-org", "all")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("group1-nested", "group1")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("group1", "group1-nested")).toBe(true); + expect(await harness.deployment.checkGroupIsChildOfGroup("group1-nested", "group1")).toBe(true); + expect(await harness.deployment.checkGroupIsParentOfGroup("group1", "group1-nested")).toBe( + true, + ); expect( - await deployment.checkGroupIsChildOfGroup("group1-nested-nested_2", "group1-nested"), + await harness.deployment.checkGroupIsChildOfGroup("group1-nested-nested_2", "group1-nested"), ).toBe(true); expect( - await deployment.checkGroupIsParentOfGroup("group1-nested", "group1-nested-nested_2"), + await harness.deployment.checkGroupIsParentOfGroup("group1-nested", "group1-nested-nested_2"), ).toBe(true); }); test.afterAll(async () => { - console.log("[TEST] Starting cleanup..."); - - // Delete the dynamically created OAuth application if (oauthAppId !== null) { try { await gitlabHelper.deleteOAuthApplication(oauthAppId); @@ -213,7 +166,6 @@ test.describe("Configure GitLab Provider", async () => { } } - await deployment.killRunningProcess(); - console.log("[TEST] Cleanup completed"); + await harness.cleanup(); }); }); diff --git a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts index c5c0a42b61..0bc74921dc 100644 --- a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts @@ -1,140 +1,92 @@ -import { test, expect, Page, BrowserContext } from "@support/coverage/test"; +import { test, expect } from "@support/coverage/test"; +import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { SettingsPage } from "../../support/pages/settings-page"; import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; -import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; -import { Common, setupBrowser } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; - -let page: Page; -let browserContext: BrowserContext; -let nsgCleanup: (() => Promise) | undefined; +import { Common } from "../../utils/common"; /* SUPPORTED RESOLVERS LDAP: [x] oidcLdapUuidMatchingAnnotation -> (Default) */ -const namespace = "albarbaro-test-namespace-ldap"; -const appConfigMap = "app-config-rhdh"; -const rbacConfigMap = "rbac-policy"; -const dynamicPluginsConfigMap = "dynamic-plugins"; -const secretName = "rhdh-secrets"; +const harness = await AuthProviderHarness.create("albarbaro-test-namespace-ldap"); -const deployment = new RHDHDeployment( - namespace, - appConfigMap, - rbacConfigMap, - dynamicPluginsConfigMap, - secretName, -); -deployment.instanceName = "rhdh"; - -const backstageUrl = await deployment.computeBackstageUrl(); -const backstageBackendUrl = await deployment.computeBackstageBackendUrl(); -console.log(`Backstage BaseURL is: ${backstageUrl}`); +let nsgCleanup: (() => Promise) | undefined; test.describe("Configure LDAP Provider", () => { - let common: Common; - let uiHelper: UIhelper; + test.use({ baseURL: harness.backstageUrl }); - test.use({ baseURL: backstageUrl }); + let common: Common; + let settingsPage: SettingsPage; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(async ({ rhdhPage }) => { test.info().annotations.push({ type: "component", description: "authentication", }); - test.info().setTimeout(600 * 1000); - // load default configs from yaml files - await deployment.loadAllConfigs(); - - // setup playwright helpers - ({ context: browserContext, page } = await setupBrowser(browser, testInfo)); - void browserContext; - common = new Common(page); - uiHelper = new UIhelper(page); - - // expect some expected variables - expect(process.env.DEFAULT_USER_PASSWORD!).toBeDefined(); - expect(process.env.DEFAULT_USER_PASSWORD_2!).toBeDefined(); - expect(process.env.RHBK_LDAP_REALM!).toBeDefined(); - expect(process.env.RHBK_LDAP_CLIENT_ID!).toBeDefined(); - expect(process.env.RHBK_LDAP_CLIENT_SECRET!).toBeDefined(); - expect(process.env.RHBK_LDAP_USER_BIND!).toBeDefined(); - expect(process.env.RHBK_LDAP_USER_PASSWORD!).toBeDefined(); - expect(process.env.RHBK_LDAP_TARGET!).toBeDefined(); - expect(process.env.RHBK_BASE_URL!).toBeDefined(); - expect(process.env.RHBK_REALM!).toBeDefined(); - expect(process.env.RHBK_CLIENT_ID!).toBeDefined(); - expect(process.env.RHBK_CLIENT_SECRET!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_ARM_CLIENT_ID!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_ARM_CLIENT_SECRET!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_ARM_SUBSCRIPTION_ID!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_ARM_TENANT_ID!).toBeDefined(); - - // clean old namespaces - await deployment.deleteNamespaceIfExists(); - - // create namespace and wait for it to be active - await (await deployment.createNamespace()).waitForNamespaceActive(); - - // create all base configmaps - await deployment.createAllConfigs(); - - // generate static token - await deployment.generateStaticToken(); - - // set enviroment variables and create secret - if ( - process.env.ISRUNNINGLOCAL === undefined || - process.env.ISRUNNINGLOCAL === "" || - process.env.ISRUNNINGLOCAL === "false" - ) { - await deployment.addSecretData("BASE_URL", backstageUrl); - await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); - } - - await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); - await deployment.addSecretData("RHBK_LDAP_REALM", process.env.RHBK_LDAP_REALM!); - await deployment.addSecretData("RHBK_LDAP_CLIENT_ID", process.env.RHBK_LDAP_CLIENT_ID!); - await deployment.addSecretData("RHBK_LDAP_CLIENT_SECRET", process.env.RHBK_LDAP_CLIENT_SECRET!); - await deployment.addSecretData("LDAP_BIND_DN", process.env.RHBK_LDAP_USER_BIND!); - await deployment.addSecretData("LDAP_BIND_SECRET", process.env.RHBK_LDAP_USER_PASSWORD!); - await deployment.addSecretData("LDAP_TARGET_URL", process.env.RHBK_LDAP_TARGET!); - await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); - await deployment.addSecretData("DEFAULT_USER_PASSWORD_2", process.env.DEFAULT_USER_PASSWORD_2!); - await deployment.addSecretData("LDAP_GROUPS_DN", "OU=Groups,OU=RHDH Local,DC=rhdh,DC=test"); - await deployment.addSecretData("LDAP_USERS_DN", "OU=Users,OU=RHDH Local,DC=rhdh,DC=test"); - await deployment.addSecretData("RHBK_BASE_URL", process.env.RHBK_BASE_URL!); - await deployment.addSecretData("RHBK_REALM", process.env.RHBK_REALM!); - await deployment.addSecretData("RHBK_CLIENT_ID", process.env.RHBK_CLIENT_ID!); - await deployment.addSecretData("RHBK_CLIENT_SECRET", process.env.RHBK_CLIENT_SECRET!); + common = new Common(rhdhPage); + settingsPage = new SettingsPage(rhdhPage); + + harness.expectEnvVars([ + "DEFAULT_USER_PASSWORD", + "DEFAULT_USER_PASSWORD_2", + "RHBK_LDAP_REALM", + "RHBK_LDAP_CLIENT_ID", + "RHBK_LDAP_CLIENT_SECRET", + "RHBK_LDAP_USER_BIND", + "RHBK_LDAP_USER_PASSWORD", + "RHBK_LDAP_TARGET", + "RHBK_BASE_URL", + "RHBK_REALM", + "RHBK_CLIENT_ID", + "RHBK_CLIENT_SECRET", + "AUTH_PROVIDERS_ARM_CLIENT_ID", + "AUTH_PROVIDERS_ARM_CLIENT_SECRET", + "AUTH_PROVIDERS_ARM_SUBSCRIPTION_ID", + "AUTH_PROVIDERS_ARM_TENANT_ID", + ]); - await deployment.addSecretData( - "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", - process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID!, + await harness.loadConfigsAndProvisionNamespace(); + await harness.addBaseUrlSecretsIfRemote(); + await harness.addSecretsFromEnv({ + DEFAULT_USER_PASSWORD: "DEFAULT_USER_PASSWORD", + RHBK_LDAP_REALM: "RHBK_LDAP_REALM", + RHBK_LDAP_CLIENT_ID: "RHBK_LDAP_CLIENT_ID", + RHBK_LDAP_CLIENT_SECRET: "RHBK_LDAP_CLIENT_SECRET", + LDAP_BIND_DN: "RHBK_LDAP_USER_BIND", + LDAP_BIND_SECRET: "RHBK_LDAP_USER_PASSWORD", + LDAP_TARGET_URL: "RHBK_LDAP_TARGET", + DEFAULT_USER_PASSWORD_2: "DEFAULT_USER_PASSWORD_2", + RHBK_BASE_URL: "RHBK_BASE_URL", + RHBK_REALM: "RHBK_REALM", + RHBK_CLIENT_ID: "RHBK_CLIENT_ID", + RHBK_CLIENT_SECRET: "RHBK_CLIENT_SECRET", + AUTH_PROVIDERS_GH_ORG_CLIENT_ID: "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", + AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET: "AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET", + PINGFEDERATE_BASE_URL: "PINGFEDERATE_BASE_URL", + PINGFEDERATE_CLIENT_ID: "PINGFEDERATE_CLIENT_ID", + PINGFEDERATE_CLIENT_SECRET: "PINGFEDERATE_CLIENT_SECRET", + }); + await harness.deployment.addSecretData( + "DEFAULT_USER_PASSWORD", + process.env.DEFAULT_USER_PASSWORD!, ); - await deployment.addSecretData( - "AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET", - process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!, + await harness.deployment.addSecretData( + "LDAP_GROUPS_DN", + "OU=Groups,OU=RHDH Local,DC=rhdh,DC=test", ); - - await deployment.addSecretData("PINGFEDERATE_BASE_URL", process.env.PINGFEDERATE_BASE_URL!); - await deployment.addSecretData("PINGFEDERATE_CLIENT_ID", process.env.PINGFEDERATE_CLIENT_ID!); - await deployment.addSecretData( - "PINGFEDERATE_CLIENT_SECRET", - process.env.PINGFEDERATE_CLIENT_SECRET!, + await harness.deployment.addSecretData( + "LDAP_USERS_DN", + "OU=Users,OU=RHDH Local,DC=rhdh,DC=test", ); + await harness.createSecret(); - await deployment.createSecret(); + await harness.deployment.enableLDAPLoginWithIngestion(); + await harness.deployment.setOIDCResolver("oidcLdapUuidMatchingAnnotation"); + await harness.deployment.updateAllConfigs(); - // enable ldap login with ingestion through RHBK - await deployment.enableLDAPLoginWithIngestion(); - await deployment.setOIDCResolver("oidcLdapUuidMatchingAnnotation"); - await deployment.updateAllConfigs(); - - // update the Azure App Registration to include the current redirectUrl console.log("[TEST] Configuring Microsoft Azure App Registration..."); const graphClient = new MSClient( process.env.AUTH_PROVIDERS_ARM_CLIENT_ID!, @@ -160,16 +112,10 @@ test.describe("Configure LDAP Provider", () => { // Continue with test even if NSG configuration fails } - // create backstage deployment and wait for it to be ready - await deployment.createBackstageDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployAndWait(); }); test.beforeEach(() => { - test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); @@ -180,18 +126,23 @@ test.describe("Configure LDAP Provider", () => { ); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("User 1"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("User 1"); await common.signOut(); }); test(`Ingestion of LDAP users and groups: verify the user entities and groups are created with the correct relationships`, async () => { expect( - await deployment.checkUserIsIngestedInCatalog(["User 1", "User 2", "User 3", "RHDH Admin"]), + await harness.deployment.checkUserIsIngestedInCatalog([ + "User 1", + "User 2", + "User 3", + "RHDH Admin", + ]), ).toBe(true); expect( - await deployment.checkGroupIsIngestedInCatalog([ + await harness.deployment.checkGroupIsIngestedInCatalog([ "Admins", "All_Users", "testGroup", @@ -200,42 +151,41 @@ test.describe("Configure LDAP Provider", () => { "SubAdmins", ]), ).toBe(true); - expect(await deployment.checkUserIsInGroup("rhdh-admin", "Admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("user1", "All_Users")).toBe(true); - expect(await deployment.checkUserIsInGroup("user2", "All_Users")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("rhdh-admin", "Admins")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("user1", "All_Users")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("user2", "All_Users")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("testsubgroup", "testgroup")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("testsubsubgroup", "testsubgroup")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("testgroup", "testsubgroup")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("testsubgroup", "testsubsubgroup")).toBe( + expect(await harness.deployment.checkGroupIsChildOfGroup("testsubgroup", "testgroup")).toBe( + true, + ); + expect( + await harness.deployment.checkGroupIsChildOfGroup("testsubsubgroup", "testsubgroup"), + ).toBe(true); + expect(await harness.deployment.checkGroupIsParentOfGroup("testgroup", "testsubgroup")).toBe( true, ); + expect( + await harness.deployment.checkGroupIsParentOfGroup("testsubgroup", "testsubsubgroup"), + ).toBe(true); }); test("Login with PingFederate OIDC (with LDAP catalog)", async () => { // Switch from RHBK auth to PingFederate auth (LDAP catalog remains) - await deployment.enablePingFederateOIDCLogin(); - - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // Wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.enablePingFederateOIDCLogin(); + await harness.reconcileAfterConfigChange(); const login = await common.pingFederateLogin("user1", process.env.RHBK_LDAP_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("User 1"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("User 1"); await common.signOut(); }); test("Login with PingFederate OIDC (with LDAP catalog) with sub as ldap_uuid", async () => { - await deployment.enablePingFederateOIDCLogin(); + await harness.deployment.enablePingFederateOIDCLogin(); - deployment.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ + harness.deployment.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ { resolver: "oidcLdapUuidMatchingAnnotation", // match sub claim as required by OIDC spec @@ -243,26 +193,17 @@ test.describe("Configure LDAP Provider", () => { }, ]); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // Wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.reconcileAfterConfigChange(); const login = await common.pingFederateLogin("user1", process.env.RHBK_LDAP_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("User 1"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("User 1"); await common.signOut(); }); test.afterAll(async () => { - console.log("[TEST] Starting cleanup..."); - - // Clean up NSG rule try { if (nsgCleanup) { console.log("[TEST] Cleaning up NSG rule..."); @@ -275,5 +216,7 @@ test.describe("Configure LDAP Provider", () => { console.error("[TEST] Failed to cleanup NSG:", error); // Don't fail the test cleanup if NSG cleanup fails } + + await harness.cleanup(); }); }); diff --git a/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts b/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts index 9f4dc9d023..32e0a34174 100644 --- a/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts @@ -1,12 +1,10 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; +import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { SettingsPage } from "../../support/pages/settings-page"; import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; -import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; -import { Common, setupBrowser } from "../../utils/common"; +import { Common } from "../../utils/common"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; -import { UIhelper } from "../../utils/ui-helper"; -let page: Page; -let context: BrowserContext; /* SUPPORTED RESOLVERS MICOROSFT: @@ -16,110 +14,51 @@ MICOROSFT: [-] emailLocalPartMatchingUserEntityName */ -// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL -test.describe("Configure Microsoft Provider", async () => { +const harness = await AuthProviderHarness.create("albarbaro-test-namespace-msgraph"); + +test.describe("Configure Microsoft Provider", () => { + test.use({ baseURL: harness.backstageUrl }); + let common: Common; - let uiHelper: UIhelper; - - const namespace = "albarbaro-test-namespace-msgraph"; - const appConfigMap = "app-config-rhdh"; - const rbacConfigMap = "rbac-policy"; - const dynamicPluginsConfigMap = "dynamic-plugins"; - const secretName = "rhdh-secrets"; - - // set deployment instance - const deployment: RHDHDeployment = new RHDHDeployment( - namespace, - appConfigMap, - rbacConfigMap, - dynamicPluginsConfigMap, - secretName, - ); - deployment.instanceName = "rhdh"; - - // compute backstage baseurl - const backstageUrl = await deployment.computeBackstageUrl(); - const backstageBackendUrl = await deployment.computeBackstageBackendUrl(); - console.log(`Backstage BaseURL is: ${backstageUrl}`); - - test.use({ baseURL: backstageUrl }); - - test.beforeAll(async ({ browser }, testInfo) => { + let settingsPage: SettingsPage; + let page: Page; + let context: BrowserContext; + + test.beforeAll(async ({ rhdhPage, rhdhContext }) => { test.info().annotations.push({ type: "component", description: "authentication", }); - test.info().setTimeout(600 * 1000); - // load default configs from yaml files - await deployment.loadAllConfigs(); - - // setup playwright helpers - ({ context, page } = await setupBrowser(browser, testInfo)); - common = new Common(page); - uiHelper = new UIhelper(page); - - // expect some expected variables - expect(process.env.DEFAULT_USER_PASSWORD_2!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!).toBeDefined(); - expect(process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!).toBeDefined(); - - // clean old namespaces - await deployment.deleteNamespaceIfExists(); - - // create namespace and wait for it to be active - await (await deployment.createNamespace()).waitForNamespaceActive(); - - // create all base configmaps - await deployment.createAllConfigs(); - - // generate static token - await deployment.generateStaticToken(); - - // set enviroment variables and create secret - if ( - process.env.ISRUNNINGLOCAL === undefined || - process.env.ISRUNNINGLOCAL === "" || - process.env.ISRUNNINGLOCAL === "false" - ) { - await deployment.addSecretData("BASE_URL", backstageUrl); - await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); - } - await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); - await deployment.addSecretData("DEFAULT_USER_PASSWORD_2", process.env.DEFAULT_USER_PASSWORD_2!); - await deployment.addSecretData( + page = rhdhPage; + context = rhdhContext; + common = new Common(rhdhPage); + settingsPage = new SettingsPage(rhdhPage); + + harness.expectEnvVars([ + "DEFAULT_USER_PASSWORD_2", "AUTH_PROVIDERS_AZURE_CLIENT_ID", - process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, - ); - await deployment.addSecretData( "AUTH_PROVIDERS_AZURE_CLIENT_SECRET", - process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!, - ); - await deployment.addSecretData( "AUTH_PROVIDERS_AZURE_TENANT_ID", - process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, - ); - await deployment.addSecretData( - "MICROSOFT_CLIENT_ID", - process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, - ); - await deployment.addSecretData( - "MICROSOFT_CLIENT_SECRET", - process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!, - ); - await deployment.addSecretData( - "MICROSOFT_TENANT_ID", - process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, - ); - - await deployment.createSecret(); + ]); + + await harness.loadConfigsAndProvisionNamespace(); + await harness.addBaseUrlSecretsIfRemote(); + await harness.addSecretsFromEnv({ + DEFAULT_USER_PASSWORD: "DEFAULT_USER_PASSWORD", + DEFAULT_USER_PASSWORD_2: "DEFAULT_USER_PASSWORD_2", + AUTH_PROVIDERS_AZURE_CLIENT_ID: "AUTH_PROVIDERS_AZURE_CLIENT_ID", + AUTH_PROVIDERS_AZURE_CLIENT_SECRET: "AUTH_PROVIDERS_AZURE_CLIENT_SECRET", + AUTH_PROVIDERS_AZURE_TENANT_ID: "AUTH_PROVIDERS_AZURE_TENANT_ID", + MICROSOFT_CLIENT_ID: "AUTH_PROVIDERS_AZURE_CLIENT_ID", + MICROSOFT_CLIENT_SECRET: "AUTH_PROVIDERS_AZURE_CLIENT_SECRET", + MICROSOFT_TENANT_ID: "AUTH_PROVIDERS_AZURE_TENANT_ID", + }); + await harness.createSecret(); - // enable keycloak login with ingestion - await deployment.enableMicrosoftLoginWithIngestion(); - await deployment.updateAllConfigs(); + await harness.deployment.enableMicrosoftLoginWithIngestion(); + await harness.deployment.updateAllConfigs(); - // update the Azure App Registration to include the current redirectUrl console.log("[TEST] Configuring Microsoft Azure App Registration..."); const graphClient = new MSClient( process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, @@ -127,21 +66,15 @@ test.describe("Configure Microsoft Provider", async () => { process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, ); - const redirectUrl = `${backstageUrl}/api/auth/microsoft/handler/frame`; + const redirectUrl = `${harness.backstageUrl}/api/auth/microsoft/handler/frame`; console.log(`[TEST] Adding redirect URL: ${redirectUrl}`); await graphClient.addAppRedirectUrlsAsync([redirectUrl]); console.log("[TEST] Microsoft Azure App Registration configured successfully"); - // create backstage deployment and wait for it to be ready - await deployment.createBackstageDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployAndWait(); }); test.beforeEach(() => { - test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); @@ -152,8 +85,8 @@ test.describe("Configure Microsoft Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); await common.signOut(); await context.clearCookies(); }); @@ -161,14 +94,8 @@ test.describe("Configure Microsoft Provider", async () => { test("Login with Microsoft emailMatchingUserEntityAnnotation resolver", async () => { //Looks up the user by matching their Microsoft email to the email entity annotation. //User atena has no email attribute set - await deployment.setMicrosoftResolver("emailMatchingUserEntityAnnotation", false); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setMicrosoftResolver("emailMatchingUserEntityAnnotation", false); + await harness.reconcileAfterConfigChange(); const login = await common.MicrosoftAzureLogin( "zeus@rhdhtesting.onmicrosoft.com", @@ -176,8 +103,8 @@ test.describe("Configure Microsoft Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); await common.signOut(); await context.clearCookies(); @@ -186,20 +113,14 @@ test.describe("Configure Microsoft Provider", async () => { process.env.DEFAULT_USER_PASSWORD_2!, ); expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await context.clearCookies(); }); test("Login with Microsoft emailMatchingUserEntityProfileEmail resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setMicrosoftResolver("emailMatchingUserEntityProfileEmail", false); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setMicrosoftResolver("emailMatchingUserEntityProfileEmail", false); + await harness.reconcileAfterConfigChange(); const login = await common.MicrosoftAzureLogin( "zeus@rhdhtesting.onmicrosoft.com", @@ -207,8 +128,8 @@ test.describe("Configure Microsoft Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); await common.signOut(); await context.clearCookies(); }); @@ -216,14 +137,8 @@ test.describe("Configure Microsoft Provider", async () => { // NOTE: entity name is "name": "zeus_rhdhtesting.onmicrosoft.com", email is "email": "zeus@rhdhtesting.onmicrosoft.com" not resolving? test.fixme("Login with Microsoft emailLocalPartMatchingUserEntityName resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setMicrosoftResolver("emailLocalPartMatchingUserEntityName", false); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setMicrosoftResolver("emailLocalPartMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); const login = await common.MicrosoftAzureLogin( "zeus@rhdhtesting.onmicrosoft.com", @@ -231,8 +146,8 @@ test.describe("Configure Microsoft Provider", async () => { ); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); await common.signOut(); await context.clearCookies(); @@ -242,18 +157,15 @@ test.describe("Configure Microsoft Provider", async () => { ); expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); }); test(`Set Micrisoft sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty("auth.providers.microsoft.production.sessionDuration", "3days"); - await deployment.updateAllConfigs(); - await deployment.restartLocalDeployment(); - await deployment.waitForConfigReconciled(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + harness.deployment.setAppConfigProperty( + "auth.providers.microsoft.production.sessionDuration", + "3days", + ); + await harness.reconcileAfterConfigChange(); const login = await common.MicrosoftAzureLogin( "zeus@rhdhtesting.onmicrosoft.com", @@ -277,18 +189,16 @@ test.describe("Configure Microsoft Provider", async () => { expect(actualDuration).toBeGreaterThan(threeDays - tolerance); expect(actualDuration).toBeLessThan(threeDays + tolerance); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); await common.signOut(); }); test(`Ingestion of Microsoft users and groups: verify the user entities and groups are created with the correct relationships`, async () => { - test.setTimeout(300 * 1000); - await expect .poll( () => - deployment.checkUserIsIngestedInCatalog([ + harness.deployment.checkUserIsIngestedInCatalog([ "TEST Admin", "TEST Atena", "TEST Elio", @@ -299,7 +209,7 @@ test.describe("Configure Microsoft Provider", async () => { ) .toBe(true); expect( - await deployment.checkGroupIsIngestedInCatalog([ + await harness.deployment.checkGroupIsIngestedInCatalog([ "TEST_admins", "TEST_goddesses", "TEST_gods", @@ -307,37 +217,49 @@ test.describe("Configure Microsoft Provider", async () => { ]), ).toBe(true); expect( - await deployment.checkUserIsInGroup("admin_rhdhtesting.onmicrosoft.com", "TEST_admins"), + await harness.deployment.checkUserIsInGroup( + "admin_rhdhtesting.onmicrosoft.com", + "TEST_admins", + ), ).toBe(true); expect( - await deployment.checkUserIsInGroup("zeus_rhdhtesting.onmicrosoft.com", "TEST_admins"), + await harness.deployment.checkUserIsInGroup( + "zeus_rhdhtesting.onmicrosoft.com", + "TEST_admins", + ), ).toBe(true); expect( - await deployment.checkUserIsInGroup("atena_rhdhtesting.onmicrosoft.com", "TEST_goddesses"), + await harness.deployment.checkUserIsInGroup( + "atena_rhdhtesting.onmicrosoft.com", + "TEST_goddesses", + ), ).toBe(true); expect( - await deployment.checkUserIsInGroup("tiche_rhdhtesting.onmicrosoft.com", "TEST_goddesses"), + await harness.deployment.checkUserIsInGroup( + "tiche_rhdhtesting.onmicrosoft.com", + "TEST_goddesses", + ), ).toBe(true); expect( - await deployment.checkUserIsInGroup("elio_rhdhtesting.onmicrosoft.com", "TEST_gods"), + await harness.deployment.checkUserIsInGroup("elio_rhdhtesting.onmicrosoft.com", "TEST_gods"), ).toBe(true); expect( - await deployment.checkUserIsInGroup("zeus_rhdhtesting.onmicrosoft.com", "TEST_gods"), + await harness.deployment.checkUserIsInGroup("zeus_rhdhtesting.onmicrosoft.com", "TEST_gods"), ).toBe(true); - //expect(await deployment.checkUserIsInGroup('zeus', 'all')).toBe(true); - //expect(await deployment.checkUserIsInGroup('tyke', 'all')).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("test_gods", "test_all")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("test_goddesses", "test_all")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("test_all", "test_gods")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("test_all", "test_goddesses")).toBe(true); + //expect(await harness.deployment.checkUserIsInGroup('zeus', 'all')).toBe(true); + //expect(await harness.deployment.checkUserIsInGroup('tyke', 'all')).toBe(true); + expect(await harness.deployment.checkGroupIsChildOfGroup("test_gods", "test_all")).toBe(true); + expect(await harness.deployment.checkGroupIsChildOfGroup("test_goddesses", "test_all")).toBe( + true, + ); + expect(await harness.deployment.checkGroupIsParentOfGroup("test_all", "test_gods")).toBe(true); + expect(await harness.deployment.checkGroupIsParentOfGroup("test_all", "test_goddesses")).toBe( + true, + ); }); test.afterAll(async () => { - console.log("[TEST] Starting cleanup..."); - await deployment.killRunningProcess(); - - // Clean up Azure App Registration try { console.log("[TEST] Cleaning up Microsoft Azure App Registration..."); const graphClient = new MSClient( @@ -346,7 +268,7 @@ test.describe("Configure Microsoft Provider", async () => { process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, ); - const redirectUrl = `${backstageUrl}/api/auth/microsoft/handler/frame`; + const redirectUrl = `${harness.backstageUrl}/api/auth/microsoft/handler/frame`; console.log(`[TEST] Removing redirect URL: ${redirectUrl}`); await graphClient.removeAppRedirectUrlsAsync([redirectUrl]); console.log("[TEST] Microsoft Azure App Registration cleanup completed"); @@ -354,5 +276,7 @@ test.describe("Configure Microsoft Provider", async () => { console.error("[TEST] Failed to cleanup Microsoft Azure App Registration:", error); // Don't fail the test cleanup if Azure cleanup fails } + + await harness.cleanup(); }); }); diff --git a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts index 1b359e4e54..4a36d62090 100644 --- a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts @@ -1,12 +1,10 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; +import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { SettingsPage } from "../../support/pages/settings-page"; import { KeycloakHelper } from "../../utils/authentication-providers/keycloak-helper"; -import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; -import { Common, setupBrowser } from "../../utils/common"; +import { Common } from "../../utils/common"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; -import { UIhelper } from "../../utils/ui-helper"; -let page: Page; -let context: BrowserContext; /* SUPPORTED RESOLVERS OIDC: @@ -19,122 +17,69 @@ OIDC: [-] oidcSubClaimMatchingPingIdentityUserId -> Ping Identity not supported */ -// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL -test.describe("Configure OIDC provider (using RHBK)", async () => { +const harness = await AuthProviderHarness.create("albarbaro-test-namespace-oidc"); + +const keycloakHelper = new KeycloakHelper({ + baseUrl: process.env.RHBK_BASE_URL!, + realmName: process.env.RHBK_REALM!, + clientId: process.env.RHBK_CLIENT_ID!, + clientSecret: process.env.RHBK_CLIENT_SECRET!, +}); + +test.describe("Configure OIDC provider (using RHBK)", () => { + test.use({ baseURL: harness.backstageUrl }); + let common: Common; - let uiHelper: UIhelper; - - const namespace = "albarbaro-test-namespace-oidc"; - const appConfigMap = "app-config-rhdh"; - const rbacConfigMap = "rbac-policy"; - const dynamicPluginsConfigMap = "dynamic-plugins"; - const secretName = "rhdh-secrets"; - - const keycloakHelper = new KeycloakHelper({ - baseUrl: process.env.RHBK_BASE_URL!, - realmName: process.env.RHBK_REALM!, - clientId: process.env.RHBK_CLIENT_ID!, - clientSecret: process.env.RHBK_CLIENT_SECRET!, - }); + let settingsPage: SettingsPage; + let page: Page; + let context: BrowserContext; - // set deployment instance - const deployment: RHDHDeployment = new RHDHDeployment( - namespace, - appConfigMap, - rbacConfigMap, - dynamicPluginsConfigMap, - secretName, - ); - deployment.instanceName = "rhdh"; - // compute backstage baseurl - const backstageUrl = await deployment.computeBackstageUrl(); - const backstageBackendUrl = await deployment.computeBackstageBackendUrl(); - console.log(`Backstage BaseURL is: ${backstageUrl}`); - - test.use({ baseURL: backstageUrl }); - - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(async ({ rhdhPage, rhdhContext }) => { test.info().annotations.push({ type: "component", description: "authentication", }); - test.info().setTimeout(600 * 1000); - // load default configs from yaml files - await deployment.loadAllConfigs(); - // setup playwright helpers - ({ context, page } = await setupBrowser(browser, testInfo)); - common = new Common(page); - uiHelper = new UIhelper(page); + page = rhdhPage; + context = rhdhContext; + common = new Common(rhdhPage); + settingsPage = new SettingsPage(rhdhPage); + + harness.expectEnvVars([ + "DEFAULT_USER_PASSWORD", + "RHBK_BASE_URL", + "RHBK_REALM", + "RHBK_CLIENT_ID", + "RHBK_CLIENT_SECRET", + ]); - // initialize keycloak helper console.log("[TEST] Initializing Keycloak helper..."); await keycloakHelper.initialize(); console.log("[TEST] Keycloak helper initialized successfully"); - // expect some expected variables - expect(process.env.DEFAULT_USER_PASSWORD!).toBeDefined(); - expect(process.env.RHBK_BASE_URL!).toBeDefined(); - expect(process.env.RHBK_REALM!).toBeDefined(); - expect(process.env.RHBK_CLIENT_ID!).toBeDefined(); - expect(process.env.RHBK_CLIENT_SECRET!).toBeDefined(); - - // clean old namespaces - await deployment.deleteNamespaceIfExists(); - - // create namespace and wait for it to be active - await (await deployment.createNamespace()).waitForNamespaceActive(); - - // create all base configmaps - await deployment.createAllConfigs(); - - // generate static token - await deployment.generateStaticToken(); - - // set enviroment variables and create secret - if ( - process.env.ISRUNNINGLOCAL === undefined || - process.env.ISRUNNINGLOCAL === "" || - process.env.ISRUNNINGLOCAL === "false" - ) { - await deployment.addSecretData("BASE_URL", backstageUrl); - await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); - } - await deployment.addSecretData("DEFAULT_USER_PASSWORD", process.env.DEFAULT_USER_PASSWORD!); - await deployment.addSecretData("DEFAULT_USER_PASSWORD_2", process.env.DEFAULT_USER_PASSWORD_2!); - await deployment.addSecretData("RHBK_BASE_URL", process.env.RHBK_BASE_URL!); - await deployment.addSecretData("RHBK_REALM", process.env.RHBK_REALM!); - await deployment.addSecretData("RHBK_CLIENT_ID", process.env.RHBK_CLIENT_ID!); - await deployment.addSecretData("RHBK_CLIENT_SECRET", process.env.RHBK_CLIENT_SECRET!); - - await deployment.addSecretData( - "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", - process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID!, - ); - await deployment.addSecretData( - "AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET", - process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!, - ); - - await deployment.createSecret(); + await harness.loadConfigsAndProvisionNamespace(); + await harness.addBaseUrlSecretsIfRemote(); + await harness.addSecretsFromEnv({ + DEFAULT_USER_PASSWORD: "DEFAULT_USER_PASSWORD", + DEFAULT_USER_PASSWORD_2: "DEFAULT_USER_PASSWORD_2", + RHBK_BASE_URL: "RHBK_BASE_URL", + RHBK_REALM: "RHBK_REALM", + RHBK_CLIENT_ID: "RHBK_CLIENT_ID", + RHBK_CLIENT_SECRET: "RHBK_CLIENT_SECRET", + AUTH_PROVIDERS_GH_ORG_CLIENT_ID: "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", + AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET: "AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET", + }); + await harness.createSecret(); - // create initial deployment - // enable keycloak login with ingestion console.log("[TEST] Enabling OIDC login with ingestion..."); - await deployment.enableOIDCLoginWithIngestion(); - await deployment.updateAllConfigs(); + await harness.deployment.enableOIDCLoginWithIngestion(); + await harness.deployment.updateAllConfigs(); console.log("[TEST] OIDC login with ingestion enabled successfully"); - // create backstage deployment and wait for it to be ready - await deployment.createBackstageDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployAndWait(); }); test.beforeEach(() => { - test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); @@ -142,132 +87,96 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); - await uiHelper.hideQuickstartIfVisible(); + await settingsPage.hideQuickstartIfVisible(); - // Click "Show more" button to display metadata info - await page.getByTitle("Show more").click(); - // Verify Metadata text is present - await uiHelper.verifyText("RHDH Metadata"); + await settingsPage.verifyRhdhMetadata(); await common.signOut(); }); test("Login with OIDC oidcSubClaimMatchingKeycloakUserId resolver", async () => { - await deployment.enableOIDCLoginWithIngestion(); - await deployment.setOIDCResolver("oidcSubClaimMatchingKeycloakUserId", false); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.enableOIDCLoginWithIngestion(); + await harness.deployment.setOIDCResolver("oidcSubClaimMatchingKeycloakUserId", false); + await harness.reconcileAfterConfigChange(); const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); await common.signOut(); }); test("Login with OIDC emailMatchingUserEntityProfileEmail resolver", async () => { - await deployment.setOIDCResolver("emailMatchingUserEntityProfileEmail", false); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setOIDCResolver("emailMatchingUserEntityProfileEmail", false); + await harness.reconcileAfterConfigChange(); const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); await common.signOut(); }); test("Login with OIDC emailLocalPartMatchingUserEntityName resolver", async () => { - await deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", false); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); await common.signOut(); const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await keycloakHelper.initialize(); await keycloakHelper.clearUserSessions("atena"); }); test("Login with OIDC emailLocalPartMatchingUserEntityName with dangerouslyAllowSignInWithoutUserInCatalog resolver", async () => { - await deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", true); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", true); + await harness.reconcileAfterConfigChange(); const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); await common.signOut(); const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login2).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Atena Minerva"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Atena Minerva"); await common.signOut(); }); test("Login with OIDC preferredUsernameMatchingUserEntityName resolver", async () => { - await deployment.setOIDCResolver("preferredUsernameMatchingUserEntityName", false); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + await harness.deployment.setOIDCResolver("preferredUsernameMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); const login = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Atena Minerva"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Atena Minerva"); await common.signOut(); }); test(`Set sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty("auth.providers.oidc.production.sessionDuration", "3days"); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + harness.deployment.setAppConfigProperty( + "auth.providers.oidc.production.sessionDuration", + "3days", + ); + await harness.reconcileAfterConfigChange(); const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); @@ -288,14 +197,14 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { expect(actualDuration).toBeGreaterThan(threeDays - tolerance); expect(actualDuration).toBeLessThan(threeDays + tolerance); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); await common.signOut(); }); test(`Ingestion of users and groups: verify the user entities and groups are created with the correct relationships`, async () => { expect( - await deployment.checkUserIsIngestedInCatalog([ + await harness.deployment.checkUserIsIngestedInCatalog([ "Admin E2e", "Atena Minerva", "Elio Sole", @@ -303,35 +212,32 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { "Zeus Giove", ]), ).toBe(true); - expect(await deployment.checkGroupIsIngestedInCatalog(["admins", "goddesses", "gods"])).toBe( - true, - ); - expect(await deployment.checkUserIsInGroup("admin", "admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("zeus", "admins")).toBe(true); - expect(await deployment.checkUserIsInGroup("atena", "goddesses")).toBe(true); - expect(await deployment.checkUserIsInGroup("tyke", "goddesses")).toBe(true); - expect(await deployment.checkUserIsInGroup("elio", "gods")).toBe(true); - expect(await deployment.checkUserIsInGroup("zeus", "gods")).toBe(true); - - expect(await deployment.checkGroupIsChildOfGroup("gods", "all")).toBe(true); - expect(await deployment.checkGroupIsChildOfGroup("goddesses", "all")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("all", "gods")).toBe(true); - expect(await deployment.checkGroupIsParentOfGroup("all", "goddesses")).toBe(true); + expect( + await harness.deployment.checkGroupIsIngestedInCatalog(["admins", "goddesses", "gods"]), + ).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("admin", "admins")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("zeus", "admins")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("atena", "goddesses")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("tyke", "goddesses")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("elio", "gods")).toBe(true); + expect(await harness.deployment.checkUserIsInGroup("zeus", "gods")).toBe(true); + + expect(await harness.deployment.checkGroupIsChildOfGroup("gods", "all")).toBe(true); + expect(await harness.deployment.checkGroupIsChildOfGroup("goddesses", "all")).toBe(true); + expect(await harness.deployment.checkGroupIsParentOfGroup("all", "gods")).toBe(true); + expect(await harness.deployment.checkGroupIsParentOfGroup("all", "goddesses")).toBe(true); }); test(`Ingestion of users and groups with invalid characters: check sanitize[User/Group]NameTransformer`, async () => { - expect(await deployment.checkUserIsIngestedInCatalog(["Invalid Username"])).toBe(true); - expect(await deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"])).toBe(true); + expect(await harness.deployment.checkUserIsIngestedInCatalog(["Invalid Username"])).toBe(true); + expect(await harness.deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"])).toBe( + true, + ); }); test("Ensure Guest login is disabled when setting environment to production", async () => { - await uiHelper.goToPageUrl("/", "Select a sign-in method"); - // Scope to the main content area to get only sign-in method card headers - const signInMethodsContainer = page.getByRole("main"); - const singInMethods = await signInMethodsContainer - .getByRole("heading", { level: 6 }) - .allInnerTexts(); - expect(singInMethods).not.toContain("Guest"); + await settingsPage.goToPageUrl("/", "Select a sign-in method"); + await settingsPage.verifyGuestSignInMethodNotListed(); }); test("Login with OIDC as primary sign in provider and GitHub auth as secondary", async () => { @@ -339,13 +245,13 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { expect(oidcLogin).toBe("Login successful"); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!).toBeDefined(); expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID!).toBeDefined(); // set up GitHub auth - deployment.setAppConfigProperty("auth.providers.github", { + harness.deployment.setAppConfigProperty("auth.providers.github", { production: { clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", @@ -353,19 +259,13 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }, }); - deployment.setAppConfigProperty( + harness.deployment.setAppConfigProperty( "auth.providers.github.production.disableIdentityResolution", "true", ); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); + await harness.reconcileAfterConfigChange(); - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); - - await uiHelper.hideQuickstartIfVisible(); + await settingsPage.hideQuickstartIfVisible(); const ghLogin = await common.githubLoginFromSettingsPage( "rhdhqeauth1", @@ -377,32 +277,24 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { await page.getByTitle("Sign out from GitHub").click(); // Sign out for OIDC - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); await common.signOut(); await context.clearCookies(); }); test(`Enable autologout and user is logged out after inactivity`, async () => { - deployment.setAppConfigProperty("auth.autologout.enabled", "true"); + harness.deployment.setAppConfigProperty("auth.autologout.enabled", "true"); // minimum allowed value is 0.5 minutes - deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); - deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + harness.deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); + harness.deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); + await harness.reconcileAfterConfigChange(); const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.verifyTextVisible("Logging out due to inactivity", false, 60000); - await expect(page.getByText("Logging out due to inactivity")).toBeHidden({ - timeout: 30000, - }); + await settingsPage.verifyTextVisible("Logging out due to inactivity", false, 60000); + await settingsPage.verifyInactivityLogoutMessageHidden(); await page.reload(); @@ -412,31 +304,25 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test(`Enable autologout and user stays logged in after clicking "Don't log me out"`, async () => { - deployment.setAppConfigProperty("auth.autologout.enabled", "true"); + harness.deployment.setAppConfigProperty("auth.autologout.enabled", "true"); // minimum allowed value is 0.5 minutes - deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); - deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); - await deployment.updateAllConfigs(); - await deployment.waitForConfigReconciled(); - await deployment.restartLocalDeployment(); - await deployment.waitForDeploymentReady(); - - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + harness.deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); + harness.deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); + await harness.reconcileAfterConfigChange(); const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); - await uiHelper.clickButtonByText("Don't log me out", { timeout: 60000 }); + await settingsPage.clickButtonByText("Don't log me out", { + timeout: 60000, + }); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); await common.signOut(); }); test.afterAll(async () => { - console.log("[TEST] Starting cleanup..."); - await deployment.killRunningProcess(); - console.log("[TEST] Cleanup completed"); + await harness.cleanup(); }); }); diff --git a/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts b/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts index 78501274a1..154ec4033e 100644 --- a/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts +++ b/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts @@ -1,97 +1,68 @@ -import { Page, expect, test } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; +import { CatalogBrowsePage } from "../support/pages/catalog-browse-page"; import { CatalogImport } from "../support/pages/catalog-import"; -import { Common, setupBrowser, teardownBrowser } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; +import { SelfServicePage } from "../support/pages/self-service-page"; +import { Common } from "../utils/common"; const t = getTranslations(); const lang = getCurrentLanguage(); -let page: Page; - test.describe("Test timestamp column on Catalog", () => { test.skip( () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), "skipping on OSD-GCP cluster due to RHDHBUGS-555", ); - let uiHelper: UIhelper; + let catalogBrowsePage: CatalogBrowsePage; + let selfServicePage: SelfServicePage; let common: Common; let catalogImport: CatalogImport; const component = "https://github.com/janus-qe/custom-catalog-entities/blob/main/timestamp-catalog-info.yaml"; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(async ({ rhdhPage }) => { test.info().annotations.push({ type: "component", description: "core", }); - page = (await setupBrowser(browser, testInfo)).page; - - common = new Common(page); - uiHelper = new UIhelper(page); - catalogImport = new CatalogImport(page); + common = new Common(rhdhPage); + catalogBrowsePage = new CatalogBrowsePage(rhdhPage); + selfServicePage = new SelfServicePage(rhdhPage); + catalogImport = new CatalogImport(rhdhPage); await common.loginAsGuest(); }); test.beforeEach(async () => { - await uiHelper.openSidebar(t["rhdh"][lang]["menuItem.catalog"]); - await uiHelper.verifyHeading( + await catalogBrowsePage.openSidebar(t["rhdh"][lang]["menuItem.catalog"]); + await catalogBrowsePage.verifyHeading( t["catalog"][lang]["indexPage.title"].replace("{{orgName}}", "My Org"), ); - await uiHelper.openCatalogSidebar("Component"); + await catalogBrowsePage.openCatalogSidebar("Component"); }); test("Import an existing Git repository and verify `Created At` column and value in the Catalog Page", async () => { - await uiHelper.goToSelfServicePage(); - await uiHelper.clickButton( + await selfServicePage.open(); + await selfServicePage.clickImportGitRepositoryLocalized( t["scaffolder"][lang]["templateListPage.contentHeader.registerExistingButtonTitle"], ); await catalogImport.registerExistingComponent(component); - await uiHelper.openCatalogSidebar("Component"); - await uiHelper.searchInputPlaceholder("timestamp-test-created"); - await uiHelper.verifyText("timestamp-test-created"); - await uiHelper.verifyColumnHeading(["Created At"], true); - await uiHelper.verifyRowInTableByUniqueText("timestamp-test-created", [ + await catalogBrowsePage.openCatalogSidebar("Component"); + await catalogBrowsePage.searchCatalog("timestamp-test-created"); + await catalogBrowsePage.verifyText("timestamp-test-created"); + await catalogBrowsePage.verifyColumnHeading(["Created At"], true); + await catalogBrowsePage.verifyRowByUniqueText("timestamp-test-created", [ /^\d{1,2}\/\d{1,2}\/\d{1,4}, \d:\d{1,2}:\d{1,2} (AM|PM)$/u, ]); }); test("Toggle 'CREATED AT' to see if the component list can be sorted in ascending/decending order", async () => { - // Clear search filter from previous test to show all components - const clearButton = page.getByRole("button", { name: "clear search" }); - if ((await clearButton.isVisible()) && (await clearButton.isEnabled())) { - await clearButton.click(); - } - - // Wait for the table to have data rows - await expect(page.getByRole("row").filter({ has: page.getByRole("cell") })).not.toHaveCount(0); - - // Get the first data row's "Created At" cell using semantic selectors - const firstRow = page - .getByRole("row") - .filter({ has: page.getByRole("cell") }) - .first(); - const createdAtCell = firstRow.getByRole("cell").nth(7); - - const column = page.getByRole("columnheader", { - name: "Created At", - exact: true, - }); - - // Click twice to sort descending — newest entries first - await column.click(); - await column.click(); - - // After sorting descending, the first row should have a non-empty "Created At" - await expect(createdAtCell).not.toBeEmpty(); - }); - - test.afterAll(async ({}, testInfo) => { - await teardownBrowser(page, testInfo); + await catalogBrowsePage.clearSearchIfVisible(); + await catalogBrowsePage.sortCreatedAtDescending(); + await catalogBrowsePage.verifyFirstRowCreatedAtNotEmpty(); }); }); diff --git a/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts b/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts index 9ef2d3d647..bd2ede4068 100644 --- a/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts +++ b/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts @@ -1,9 +1,9 @@ import { test, expect } from "@support/coverage/test"; +import { RhdhHomePage } from "../../support/pages/rhdh-home-page"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName, isRecord } from "../../utils/kube-client"; import { ensureRuntimeDeployed } from "../../utils/runtime-deploy"; -import { UIhelper } from "../../utils/ui-helper"; test.describe("Change app-config at e2e test runtime", () => { test.beforeAll(async () => { @@ -53,7 +53,7 @@ test.describe("Change app-config at e2e test runtime", () => { await page.context().clearPermissions(); await page.reload({ waitUntil: "domcontentloaded" }); await common.loginAsGuest(); - await new UIhelper(page).openSidebar("Home"); + await new RhdhHomePage(page).openHomeSidebar(); console.log("Verifying new title in the UI... "); expect(await page.title()).toContain(dynamicTitle); console.log("Title successfully verified in the UI."); diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts index e6f5d555c6..601600633a 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-azure-db.spec.ts @@ -1,5 +1,6 @@ import { test } from "@support/coverage/test"; +import { RhdhHomePage } from "../../support/pages/rhdh-home-page"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { @@ -10,7 +11,6 @@ import { prepareForExternalDatabase, } from "../../utils/postgres-config"; import { ensureRuntimeDeployed } from "../../utils/runtime-deploy"; -import { UIhelper } from "../../utils/ui-helper"; interface AzureDbConfig { name: string; @@ -106,10 +106,10 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt test("Verify successful DB connection", async ({ page }) => { try { - const uiHelper = new UIhelper(page); + const rhdhHomePage = new RhdhHomePage(page); const common = new Common(page); await common.loginAsGuest(); - await uiHelper.verifyHeading("Welcome back!"); + await rhdhHomePage.verifyWelcomeHeading(); } finally { await page.goto("about:blank").catch(() => {}); } diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts index e73a052b0b..93bdb26036 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-crunchy.spec.ts @@ -1,7 +1,8 @@ import { test, expect } from "@support/coverage/test"; +import { CatalogBrowsePage } from "../../support/pages/catalog-browse-page"; +import { RhdhHomePage } from "../../support/pages/rhdh-home-page"; import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; test.describe("Verify TLS configuration with external Crunchy Postgres DB", () => { test.beforeAll(() => { @@ -18,15 +19,16 @@ test.describe("Verify TLS configuration with external Crunchy Postgres DB", () = }); test("Verify successful DB connection", async ({ page }) => { - const uiHelper = new UIhelper(page); + const rhdhHomePage = new RhdhHomePage(page); + const catalogBrowsePage = new CatalogBrowsePage(page); const common = new Common(page); await common.loginAsKeycloakUser(process.env.GH_USER2_ID, process.env.GH_USER2_PASS); - await uiHelper.verifyHeading("Welcome back!"); + await rhdhHomePage.verifyWelcomeHeading(); await page.getByLabel("Catalog").first().click(); - await uiHelper.selectMuiBox("Kind", "Component"); + await catalogBrowsePage.selectKind("Component"); await expect(async () => { - await uiHelper.clickByDataTestId("user-picker-all"); - await uiHelper.verifyRowsInTable(["test-rhdh-qe-2-team-owned"]); + await catalogBrowsePage.clickByDataTestId("user-picker-all"); + await catalogBrowsePage.verifyTableRows(["test-rhdh-qe-2-team-owned"]); }).toPass({ intervals: [1_000, 2_000], timeout: 15_000, diff --git a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts index f01305cdb7..9a5b3fd7b6 100644 --- a/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts +++ b/e2e-tests/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts @@ -1,5 +1,6 @@ import { test } from "@support/coverage/test"; +import { RhdhHomePage } from "../../support/pages/rhdh-home-page"; import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { @@ -10,7 +11,6 @@ import { prepareForExternalDatabase, } from "../../utils/postgres-config"; import { ensureRuntimeDeployed } from "../../utils/runtime-deploy"; -import { UIhelper } from "../../utils/ui-helper"; interface RdsConfig { name: string; @@ -106,10 +106,10 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => test("Verify successful DB connection", async ({ page }) => { try { - const uiHelper = new UIhelper(page); + const rhdhHomePage = new RhdhHomePage(page); const common = new Common(page); await common.loginAsGuest(); - await uiHelper.verifyHeading("Welcome back!"); + await rhdhHomePage.verifyWelcomeHeading(); } finally { await page.goto("about:blank").catch(() => {}); } diff --git a/e2e-tests/playwright/e2e/github-happy-path.spec.ts b/e2e-tests/playwright/e2e/github-happy-path.spec.ts index 015fd84d70..d4aa2a0f32 100644 --- a/e2e-tests/playwright/e2e/github-happy-path.spec.ts +++ b/e2e-tests/playwright/e2e/github-happy-path.spec.ts @@ -1,10 +1,13 @@ -import { test, expect, Page, BrowserContext } from "@support/coverage/test"; +import type { BrowserContext } from "@playwright/test"; +import { test, expect } from "@support/coverage/test"; -import { BackstageShowcase, CatalogImport } from "../support/pages/catalog-import"; +import { CatalogBrowsePage } from "../support/pages/catalog-browse-page"; +import { RhdhInstance, CatalogImport } from "../support/pages/catalog-import"; +import { SelfServicePage } from "../support/pages/self-service-page"; +import { SettingsPage } from "../support/pages/settings-page"; import { RESOURCES } from "../support/test-data/resources"; import { TEMPLATES } from "../support/test-data/templates"; -import { Common, setupBrowser, teardownBrowser } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; +import { Common } from "../utils/common"; type GithubPullRequest = { title: string; number: string }; @@ -36,38 +39,43 @@ function parseGithubPullRequests(data: unknown): GithubPullRequest[] { }); } -async function getShowcasePullRequests( +async function getRhdhPullRequests( state: "open" | "closed" | "all", paginated = false, ): Promise { - const data: unknown = await BackstageShowcase.getShowcasePRs(state, paginated); + const data: unknown = await RhdhInstance.getRhdhPullRequests(state, paginated); return parseGithubPullRequests(data); } -let page: Page; -let browserContext: BrowserContext; - // Blocked by https://issues.redhat.com/browse/RHDHBUGS-2099 -test.describe.fixme("GitHub Happy path", () => { +test.describe("GitHub Happy path", { tag: "@blocked" }, () => { let common: Common; - let uiHelper: UIhelper; + let settingsPage: SettingsPage; + let catalogBrowsePage: CatalogBrowsePage; + let selfServicePage: SelfServicePage; let catalogImport: CatalogImport; - let backstageShowcase: BackstageShowcase; + let rhdhInstance: RhdhInstance; + let browserContext: BrowserContext; const component = "https://github.com/redhat-developer/rhdh/blob/main/catalog-entities/all.yaml"; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeEach(() => { + test.skip(true, "RHDHBUGS-2099: GitHub happy path blocked pending catalog entity updates"); + }); + + test.beforeAll(({ rhdhPage, rhdhContext }) => { test.info().annotations.push({ type: "component", description: "core", }); - ({ page, context: browserContext } = await setupBrowser(browser, testInfo)); - uiHelper = new UIhelper(page); - common = new Common(page); - catalogImport = new CatalogImport(page); - backstageShowcase = new BackstageShowcase(page); - test.info().setTimeout(600 * 1000); + browserContext = rhdhContext; + settingsPage = new SettingsPage(rhdhPage); + catalogBrowsePage = new CatalogBrowsePage(rhdhPage); + selfServicePage = new SelfServicePage(rhdhPage); + common = new Common(rhdhPage); + catalogImport = new CatalogImport(rhdhPage); + rhdhInstance = new RhdhInstance(rhdhPage); }); test("Login as a Github user from Settings page.", async () => { @@ -81,154 +89,124 @@ test.describe.fixme("GitHub Happy path", () => { }); test("Verify Profile is Github Account Name in the Settings page", async () => { - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading(process.env.GH_USER2_ID!); - await uiHelper.verifyHeading(`User Entity: ${process.env.GH_USER2_ID!}`); + await settingsPage.open(); + await settingsPage.verifyGithubUserProfile(process.env.GH_USER2_ID!); }); test("Import an existing Git repository", async () => { - await uiHelper.openSidebar("Catalog"); - await uiHelper.selectMuiBox("Kind", "Component"); - await uiHelper.clickButton("Self-service"); - await uiHelper.clickButton("Import an existing Git repository"); + await catalogBrowsePage.openCatalogSidebar(); + await catalogBrowsePage.selectKind("Component"); + await catalogBrowsePage.importGitRepositoryFromCatalog(); await catalogImport.registerExistingComponent(component); }); test("Verify that the following components were ingested into the Catalog", async () => { - await uiHelper.openSidebar("Catalog"); - await uiHelper.selectMuiBox("Kind", "Group"); - await uiHelper.verifyComponentInCatalog("Group", ["Janus-IDP Authors"]); + await catalogBrowsePage.openCatalogSidebar(); + await catalogBrowsePage.selectKind("Group"); + await catalogBrowsePage.verifyComponentsInCatalog("Group", ["Janus-IDP Authors"]); - await uiHelper.verifyComponentInCatalog("API", ["Petstore"]); - await uiHelper.verifyComponentInCatalog("Component", ["Red Hat Developer Hub"]); + await catalogBrowsePage.verifyComponentsInCatalog("API", ["Petstore"]); + await catalogBrowsePage.verifyComponentsInCatalog("Component", ["Red Hat Developer Hub"]); - await uiHelper.selectMuiBox("Kind", "Resource"); - await uiHelper.verifyRowsInTable([ + await catalogBrowsePage.selectKind("Resource"); + await catalogBrowsePage.verifyTableRows([ "ArgoCD", - "GitHub Showcase repository", + "RHDH GitHub catalog", "KeyCloak", "PostgreSQL cluster", "S3 Object bucket storage", ]); - await uiHelper.openSidebar("Catalog"); - await uiHelper.selectMuiBox("Kind", "User"); - await uiHelper.searchInputPlaceholder("rhdh"); - await uiHelper.verifyRowsInTable(["rhdh-qe rhdh-qe"]); + await catalogBrowsePage.openCatalogSidebar(); + await catalogBrowsePage.selectKind("User"); + await catalogBrowsePage.searchCatalog("rhdh"); + await catalogBrowsePage.verifyTableRows(["rhdh-qe rhdh-qe"]); + await catalogBrowsePage.verifyTableCell("rhdh-qe rhdh-qe"); }); test("Verify all 12 Software Templates appear in the Create page", async () => { - await uiHelper.goToSelfServicePage(); - await uiHelper.verifyHeading("Templates"); + await selfServicePage.open(); + await selfServicePage.verifyTemplatesHeading(); for (const template of TEMPLATES) { - await uiHelper.waitForTitle(template, 4); - await uiHelper.verifyHeading(template); + await selfServicePage.waitForTemplateTitle(template, 4); + await selfServicePage.verifyTemplateHeading(template); } }); test("Click login on the login popup and verify that Overview tab renders", async () => { - await uiHelper.openCatalogSidebar("Component"); - await uiHelper.clickLink("Red Hat Developer Hub"); + await catalogBrowsePage.openCatalogSidebar("Component"); + await catalogBrowsePage.openEntityLink("Red Hat Developer Hub"); const expectedPath = "/catalog/default/component/red-hat-developer-hub"; - // Wait for the expected path in the URL - // Wait until the DOM is loaded - await page.waitForURL(`**${expectedPath}`, { - waitUntil: "domcontentloaded", - timeout: 20000, - }); - // Optionally, verify that the current URL contains the expected path - expect(page.url()).toContain(expectedPath); + await rhdhInstance.waitForEntityPath(expectedPath); await common.clickOnGHloginPopup(); - await uiHelper.verifyLink("About RHDH", { exact: false }); - - // Workaround for RHDHBUGS-2091: Change the size to 10 to avoid information not being displayed - await page.getByRole("button", { name: "20" }).click(); - await page.getByRole("option", { name: "10", exact: true }).click(); - - await backstageShowcase.verifyPRStatisticsRendered(); - await backstageShowcase.verifyAboutCardIsDisplayed(); + await catalogBrowsePage.verifyLink("About RHDH", { exact: false }); + await rhdhInstance.setPullRequestPageSize(10); + await rhdhInstance.verifyPRStatisticsRendered(); + await rhdhInstance.verifyAboutCardIsDisplayed(); }); test("Verify that the Pull/Merge Requests tab renders the 5 most recently updated Open Pull Requests", async () => { - await uiHelper.clickTab("Pull/Merge Requests"); - const openPRs = await getShowcasePullRequests("open"); - await backstageShowcase.verifyPRRows(openPRs, 0, 5); + await catalogBrowsePage.clickTab("Pull/Merge Requests"); + const openPRs = await getRhdhPullRequests("open"); + await rhdhInstance.verifyPRRows(openPRs, 0, 5); }); test("Click on the CLOSED filter and verify that the 5 most recently updated Closed PRs are rendered (same with ALL)", async () => { - // Use semantic selector and wait for button to be ready (no force needed) - const closedButton = page.getByRole("button", { name: "CLOSED" }); - await expect(closedButton).toBeVisible(); - await expect(closedButton).toBeEnabled(); - await closedButton.click(); - const closedPRs = await getShowcasePullRequests("closed"); + await rhdhInstance.clickPullRequestFilter("CLOSED"); + const closedPRs = await getRhdhPullRequests("closed"); await common.waitForLoad(); - await backstageShowcase.verifyPRRows(closedPRs, 0, 5); + await rhdhInstance.verifyPRRows(closedPRs, 0, 5); }); test("Click on the arrows to verify that the next/previous/first/last pages of PRs are loaded", async () => { console.log("Fetching all PRs from GitHub"); - const allPRs = await getShowcasePullRequests("all", true); + const allPRs = await getRhdhPullRequests("all", true); console.log("Clicking on ALL button"); - // Use semantic selector and wait for button to be ready (no force needed) - const allButton = page.getByRole("button", { name: "ALL" }); - await expect(allButton).toBeVisible(); - await expect(allButton).toBeEnabled(); - await allButton.click(); - await backstageShowcase.verifyPRRows(allPRs, 0, 5); + await rhdhInstance.clickPullRequestFilter("ALL"); + await rhdhInstance.verifyPRRows(allPRs, 0, 5); console.log("Clicking on Next Page button"); - await backstageShowcase.clickNextPage(); - await backstageShowcase.verifyPRRows(allPRs, 5, 10); + await rhdhInstance.clickNextPage(); + await rhdhInstance.verifyPRRows(allPRs, 5, 10); - // const lastPagePRs = Math.floor((allPRs.length - 1) / 5) * 5; - // redhat-developer/rhdh have more than 1000 PRs open/closed and by default the latest 1000 PR results are displayed. const lastPagePRs = 996; console.log("Clicking on Last Page button"); - await backstageShowcase.clickLastPage(); - await backstageShowcase.verifyPRRows(allPRs, lastPagePRs, 1000); + await rhdhInstance.clickLastPage(); + await rhdhInstance.verifyPRRows(allPRs, lastPagePRs, 1000); console.log("Clicking on Previous Page button"); - await backstageShowcase.clickPreviousPage(); + await rhdhInstance.clickPreviousPage(); await common.waitForLoad(); - await backstageShowcase.verifyPRRows(allPRs, lastPagePRs - 5, lastPagePRs - 1); + await rhdhInstance.verifyPRRows(allPRs, lastPagePRs - 5, lastPagePRs - 1); }); test("Verify that the 5, 10, 20 items per page option properly displays the correct number of PRs", async () => { - await uiHelper.openCatalogSidebar("Component"); - await uiHelper.clickLink("Red Hat Developer Hub"); + await catalogBrowsePage.openCatalogSidebar("Component"); + await catalogBrowsePage.openEntityLink("Red Hat Developer Hub"); await common.clickOnGHloginPopup(); - await uiHelper.clickTab("Pull/Merge Requests"); - const allPRs = await getShowcasePullRequests("open"); - await backstageShowcase.verifyPRRowsPerPage(5, allPRs); - await backstageShowcase.verifyPRRowsPerPage(10, allPRs); - await backstageShowcase.verifyPRRowsPerPage(20, allPRs); + await catalogBrowsePage.clickTab("Pull/Merge Requests"); + const allPRs = await getRhdhPullRequests("open"); + await rhdhInstance.verifyPRRowsPerPage(5, allPRs); + await rhdhInstance.verifyPRRowsPerPage(10, allPRs); + await rhdhInstance.verifyPRRowsPerPage(20, allPRs); }); - // Blocked by https://issues.redhat.com/browse/RHDHBUGS-2099 - test.fixme("Click on the Dependencies tab and verify that all the relations have been listed and displayed", async () => { - await uiHelper.clickTab("Dependencies"); + test("Click on the Dependencies tab and verify that all the relations have been listed and displayed", async () => { + await catalogBrowsePage.openDependenciesTab(); for (const resource of RESOURCES) { - const resourceElement = page.locator(`#workspace:has-text("${resource}")`); - await resourceElement.scrollIntoViewIfNeeded(); - await expect(resourceElement).toBeVisible(); + await catalogBrowsePage.verifyDependencyResource(resource); } }); - // Blocked by https://issues.redhat.com/browse/RHDHBUGS-2099 - test.fixme("Sign out and verify that you return back to the Sign in page", async () => { - await uiHelper.goToSettingsPage(); + test("Sign out and verify that you return back to the Sign in page", async () => { + await settingsPage.open(); await common.signOut(); await browserContext.clearCookies(); - await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); - }); - - test.afterAll(async ({}, testInfo) => { - await teardownBrowser(page, testInfo); + await settingsPage.verifySignInButtonVisible(); }); }); diff --git a/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts b/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts index 2e0a6366ff..946840b0fe 100644 --- a/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts +++ b/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts @@ -1,12 +1,9 @@ import { test } from "@support/coverage/test"; import { HomePage } from "../support/pages/home-page"; +import { RhdhHomePage } from "../support/pages/rhdh-home-page"; +import { SettingsPage } from "../support/pages/settings-page"; import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; -import { getTranslations, getCurrentLanguage } from "./localization/locale"; - -const t = getTranslations(); -const lang = getCurrentLanguage(); test.describe("Guest Signing Happy path", () => { test.beforeAll(() => { @@ -16,32 +13,33 @@ test.describe("Guest Signing Happy path", () => { }); }); - let uiHelper: UIhelper; + let rhdhHomePage: RhdhHomePage; let homePage: HomePage; + let settingsPage: SettingsPage; let common: Common; test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); + rhdhHomePage = new RhdhHomePage(page); homePage = new HomePage(page); + settingsPage = new SettingsPage(page); common = new Common(page); await common.loginAsGuest(); }); test("Verify the Homepage renders with Search Bar, Quick Access and Starred Entities", async () => { - await uiHelper.verifyHeading("Welcome back!"); - await uiHelper.openSidebar("Home"); + await rhdhHomePage.verifyWelcomeHeading(); + await rhdhHomePage.openHomeSidebar(); await homePage.verifyQuickAccess("Developer Tools", "Podman Desktop"); }); test("Verify Profile is Guest in the Settings page", async () => { - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Guest"); - await uiHelper.verifyHeading("User Entity: guest"); + await settingsPage.open(); + await settingsPage.verifyGuestProfile(); }); test("Sign Out and Verify that you return to the Sign-in page", async () => { - await uiHelper.goToSettingsPage(); + await settingsPage.open(); await common.signOut(); - await uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); + await settingsPage.verifySignInPageTitle(); }); }); diff --git a/e2e-tests/playwright/e2e/home-page-customization.spec.ts b/e2e-tests/playwright/e2e/home-page-customization.spec.ts index 36a13f5454..5f3643dddf 100644 --- a/e2e-tests/playwright/e2e/home-page-customization.spec.ts +++ b/e2e-tests/playwright/e2e/home-page-customization.spec.ts @@ -1,13 +1,13 @@ import { test } from "@support/coverage/test"; import { HomePage } from "../support/pages/home-page"; +import { RhdhHomePage } from "../support/pages/rhdh-home-page"; import { runAccessibilityTests } from "../utils/accessibility"; import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; test.describe("Home page customization", () => { let common: Common; - let uiHelper: UIhelper; + let rhdhHomePage: RhdhHomePage; let homePage: HomePage; test.beforeAll(() => { @@ -18,38 +18,38 @@ test.describe("Home page customization", () => { }); test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); + rhdhHomePage = new RhdhHomePage(page); common = new Common(page); homePage = new HomePage(page); await common.loginAsGuest(); }); test("Verify that home page is customized", async ({ page }, testInfo) => { - await uiHelper.verifyTextinCard("Quick Access", "Quick Access"); + await rhdhHomePage.verifyTextInCard("Quick Access", "Quick Access"); await runAccessibilityTests(page, testInfo); - await uiHelper.verifyTextinCard("Your Starred Entities", "Your Starred Entities"); - await uiHelper.verifyHeading("Placeholder tests"); - await uiHelper.verifyDivHasText("Home page customization test 1"); - await uiHelper.verifyDivHasText("Home page customization test 2"); - await uiHelper.verifyDivHasText("Home page customization test 3"); - await uiHelper.verifyHeading("Markdown tests"); - await uiHelper.verifyTextinCard("Company links", "Company links"); - await uiHelper.verifyHeading("Important company links"); - await uiHelper.verifyHeading("RHDH"); - await uiHelper.verifyTextinCard("Featured Docs", "Featured Docs"); - await uiHelper.verifyTextinCard("Random Joke", "Random Joke"); - await uiHelper.clickButton("Reroll"); + await rhdhHomePage.verifyTextInCard("Your Starred Entities", "Your Starred Entities"); + await rhdhHomePage.verifyHeading("Placeholder tests"); + await rhdhHomePage.verifyDivHasText("Home page customization test 1"); + await rhdhHomePage.verifyDivHasText("Home page customization test 2"); + await rhdhHomePage.verifyDivHasText("Home page customization test 3"); + await rhdhHomePage.verifyHeading("Markdown tests"); + await rhdhHomePage.verifyTextInCard("Company links", "Company links"); + await rhdhHomePage.verifyHeading("Important company links"); + await rhdhHomePage.verifyHeading("RHDH"); + await rhdhHomePage.verifyTextInCard("Featured Docs", "Featured Docs"); + await rhdhHomePage.verifyTextInCard("Random Joke", "Random Joke"); + await rhdhHomePage.clickButton("Reroll"); }); test("Verify that the Top Visited card in the Home page renders without an error", async () => { - await uiHelper.verifyTextinCard("Top Visited", "Top Visited"); + await rhdhHomePage.verifyTextInCard("Top Visited", "Top Visited"); await homePage.verifyVisitedCardContent("Top Visited"); }); test("Verify that the Recently Visited card in the Home page renders without an error", async () => { - await uiHelper.verifyTextinCard("Recently Visited", "Recently Visited"); + await rhdhHomePage.verifyTextInCard("Recently Visited", "Recently Visited"); await homePage.verifyVisitedCardContent("Recently Visited"); }); diff --git a/e2e-tests/playwright/e2e/learning-path-page.spec.ts b/e2e-tests/playwright/e2e/learning-path-page.spec.ts index e280e7fd7c..45259d41c9 100644 --- a/e2e-tests/playwright/e2e/learning-path-page.spec.ts +++ b/e2e-tests/playwright/e2e/learning-path-page.spec.ts @@ -1,8 +1,8 @@ -import { expect, test } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; +import { SidebarPage } from "../support/pages/sidebar-page"; import { runAccessibilityTests } from "../utils/accessibility"; import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; test.describe("Learning Paths", { tag: "@layer3-equivalent" }, () => { test.beforeAll(() => { @@ -13,10 +13,10 @@ test.describe("Learning Paths", { tag: "@layer3-equivalent" }, () => { }); let common: Common; - let uiHelper: UIhelper; + let sidebarPage: SidebarPage; test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); + sidebarPage = new SidebarPage(page); common = new Common(page); await common.loginAsGuest(); }); @@ -24,17 +24,8 @@ test.describe("Learning Paths", { tag: "@layer3-equivalent" }, () => { test("Verify that links in Learning Paths for Backstage opens in a new tab", async ({ page, }, testInfo) => { - await uiHelper.openSidebarButton("References"); - await uiHelper.openSidebar("Learning Paths"); - - // Scope to main content area to get only Learning Path links - const learningPathLinks = page.getByRole("main").getByRole("link"); - - for (const learningPathCard of await learningPathLinks.all()) { - await expect(learningPathCard).toBeVisible(); - await expect(learningPathCard).toHaveAttribute("target", "_blank"); - await expect(learningPathCard).not.toHaveAttribute("href", ""); - } + await sidebarPage.openReferencesLearningPaths(); + await sidebarPage.verifyLearningPathLinksOpenInNewTab(); await runAccessibilityTests(page, testInfo); }); diff --git a/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts b/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts index 74621a494e..bf94dcd03b 100644 --- a/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@support/coverage/test"; +import { CatalogBrowsePage } from "../../support/pages/catalog-browse-page"; import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; test.describe("Test ApplicationListener", () => { test.beforeAll(() => { @@ -11,11 +11,11 @@ test.describe("Test ApplicationListener", () => { }); }); - let uiHelper: UIhelper; + let catalogBrowsePage: CatalogBrowsePage; test.beforeEach(async ({ page }) => { const common = new Common(page); - uiHelper = new UIhelper(page); + catalogBrowsePage = new CatalogBrowsePage(page); await common.loginAsGuest(); }); @@ -28,7 +28,7 @@ test.describe("Test ApplicationListener", () => { } }); - await uiHelper.openSidebar("Catalog"); + await catalogBrowsePage.openCatalogSidebar(); expect(logs.some((l) => l.includes("pathname: /catalog"))).toBeTruthy(); }); diff --git a/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts b/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts index 9a347c01fc..45a5a21f10 100644 --- a/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts @@ -1,7 +1,7 @@ -import { expect, test } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; +import { ApplicationProviderTestPage } from "../../support/pages/application-provider-test-page"; import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; test.describe("Test ApplicationProvider", () => { test.beforeAll(() => { @@ -11,57 +11,24 @@ test.describe("Test ApplicationProvider", () => { }); }); - let uiHelper: UIhelper; + let applicationProviderPage: ApplicationProviderTestPage; let common: Common; test.beforeEach(async ({ page }) => { common = new Common(page); - uiHelper = new UIhelper(page); + applicationProviderPage = new ApplicationProviderTestPage(page); await common.loginAsGuest(); }); - test("Verify that the TestPage is rendered", async ({ page }) => { - await uiHelper.goToPageUrl("/application-provider-test-page"); + test("Verify that the TestPage is rendered", async () => { + await applicationProviderPage.open(); await common.waitForLoad(); - await uiHelper.verifyText("application/provider TestPage"); - await uiHelper.verifyText( - "This card will work only if you register the TestProviderOne and TestProviderTwo correctly.", - ); - - // Verify Context one cards are visible - await uiHelper.verifyTextinCard("Context one", "Context one"); - - // Find card containers within main article that contain "Context one" - /* oxlint-disable playwright/no-raw-locators -- per-card containers are nested divs inside one article */ - const contextOneCards = page - .getByRole("main") - .getByRole("article") - .locator("> div > div") - .filter({ hasText: "Context one" }); - - // Click increment on the first Context one card - await contextOneCards.first().getByRole("button", { name: "+" }).click(); - - // Verify both Context one cards show count of 1 (shared state) - await expect(contextOneCards.first().getByRole("heading", { name: "1" })).toBeVisible(); - await expect(contextOneCards.last().getByRole("heading", { name: "1" })).toBeVisible(); - - // Verify Context two cards are visible - await uiHelper.verifyTextinCard("Context two", "Context two"); - - // Find card containers that contain "Context two" - const contextTwoCards = page - .getByRole("main") - .getByRole("article") - .locator("> div > div") - .filter({ hasText: "Context two" }); - /* oxlint-enable playwright/no-raw-locators */ - - // Click increment on the first Context two card - await contextTwoCards.first().getByRole("button", { name: "+" }).click(); - - // Verify both Context two cards show count of 1 (shared state) - await expect(contextTwoCards.first().getByRole("heading", { name: "1" })).toBeVisible(); - await expect(contextTwoCards.last().getByRole("heading", { name: "1" })).toBeVisible(); + await applicationProviderPage.verifyTestPageContent(); + await applicationProviderPage.verifyContextOneCard(); + await applicationProviderPage.incrementFirstCardCounter("Context one"); + await applicationProviderPage.verifySharedCardCount("Context one", "1"); + await applicationProviderPage.verifyContextTwoCard(); + await applicationProviderPage.incrementFirstCardCounter("Context two"); + await applicationProviderPage.verifySharedCardCount("Context two", "1"); }); }); diff --git a/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts b/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts index 7e73a4ceef..d527b2abef 100644 --- a/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts @@ -1,65 +1,48 @@ -import { Page, test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; -import { UIhelper } from "../../../utils/ui-helper"; +import { SidebarPage } from "../../../support/pages/sidebar-page"; +import { Common } from "../../../utils/common"; import { getTranslations, getCurrentLanguage } from "../../localization/locale"; const t = getTranslations(); const lang = getCurrentLanguage(); -let page: Page; - test.describe("Validate Sidebar Navigation Customization", { tag: "@layer3-equivalent" }, () => { - let uiHelper: UIhelper; + let sidebarPage: SidebarPage; let common: Common; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeEach(async ({ page }) => { test.info().annotations.push({ type: "component", description: "plugins", }); - page = (await setupBrowser(browser, testInfo)).page; - uiHelper = new UIhelper(page); + sidebarPage = new SidebarPage(page); common = new Common(page); await common.loginAsGuest(); }); test("Verify menu order and navigate to Docs", async () => { - // Verify presence of 'References' menu and related items - const referencesMenu = uiHelper.getSideBarMenuItem("References"); - expect(referencesMenu).not.toBeNull(); - expect(referencesMenu.getByText(t["rhdh"][lang]["menuItem.apis"])).not.toBeNull(); - expect(referencesMenu.getByText(t["rhdh"][lang]["menuItem.learningPaths"])).not.toBeNull(); - - // Verify 'Favorites' menu and 'Docs' submenu item - const favoritesMenu = uiHelper.getSideBarMenuItem("Favorites"); - const docsMenuItem = favoritesMenu.getByText(t["rhdh"][lang]["menuItem.docs"]); - expect(docsMenuItem).not.toBeNull(); - - // Open the 'Favorites' menu and navigate to 'Docs' - await uiHelper.openSidebarButton("Favorites"); - await uiHelper.openSidebar(t["rhdh"][lang]["menuItem.docs"]); - - // Verify if the Documentation page has loaded - await uiHelper.verifyHeading("Documentation"); - await uiHelper.verifyText("Documentation available in", false); - - // Verify the presense/absense of the 'Test' buttons in the sidebar - await uiHelper.verifyText("Test enabled"); - await expect(page.getByRole("link", { name: "Test disabled" })).toBeHidden(); - - // Verify the presence/absense of nested 'Test' buttons in the sidebar - await uiHelper.openSidebarButton("Test enabled"); - await uiHelper.verifyText("Test nested enabled"); - await expect(page.getByRole("link", { name: "Test nested disabled" })).toBeHidden(); - - await uiHelper.verifyText("Test_i enabled"); - await expect(page.getByRole("link", { name: "Test_i disabled" })).toBeHidden(); - }); - - test.afterAll(async ({}, testInfo) => { - await teardownBrowser(page, testInfo); + await sidebarPage.verifyMenuItemInSection("References", t["rhdh"][lang]["menuItem.apis"]); + await sidebarPage.verifyMenuItemInSection( + "References", + t["rhdh"][lang]["menuItem.learningPaths"], + ); + await sidebarPage.verifyMenuItemInSection("Favorites", t["rhdh"][lang]["menuItem.docs"]); + + await sidebarPage.openFavoritesDocs(); + + await sidebarPage.verifyDocumentationHeading(); + await sidebarPage.verifyText("Documentation available in", false); + await sidebarPage.verifyText("Test enabled"); + await sidebarPage.verifyLinkHidden("Test disabled"); + + await sidebarPage.openSidebarButton("Test enabled"); + await sidebarPage.verifyText("Test nested enabled"); + await sidebarPage.verifyLinkHidden("Test nested disabled"); + + await sidebarPage.verifyText("Test_i enabled"); + await sidebarPage.verifyLinkHidden("Test_i disabled"); }); }); diff --git a/e2e-tests/playwright/e2e/plugins/http-request.spec.ts b/e2e-tests/playwright/e2e/plugins/http-request.spec.ts index 99049ffe00..9736f9b595 100644 --- a/e2e-tests/playwright/e2e/plugins/http-request.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/http-request.spec.ts @@ -1,8 +1,9 @@ import { test } from "@support/coverage/test"; import { CatalogImport } from "../../support/pages/catalog-import"; +import { ScaffolderFlowPage } from "../../support/pages/scaffolder-flow-page"; +import { SelfServicePage } from "../../support/pages/self-service-page"; import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; // https://github.com/RoadieHQ/roadie-backstage-plugins/tree/main/plugins/scaffolder-actions/scaffolder-backend-module-http-request // Pre-req: Enable roadiehq-scaffolder-backend-module-http-request-dynamic plugin @@ -12,7 +13,8 @@ test.describe("Testing scaffolder-backend-module-http-request to invoke an exter () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), "skipping due to RHDHBUGS-555 on OSD Env", ); - let uiHelper: UIhelper; + let selfServicePage: SelfServicePage; + let scaffolderFlowPage: ScaffolderFlowPage; let common: Common; let catalogImport: CatalogImport; const template = "https://github.com/janus-qe/software-template/blob/main/test-http-request.yaml"; @@ -25,28 +27,19 @@ test.describe("Testing scaffolder-backend-module-http-request to invoke an exter }); test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); + selfServicePage = new SelfServicePage(page); + scaffolderFlowPage = new ScaffolderFlowPage(page); common = new Common(page); await common.loginAsGuest(); catalogImport = new CatalogImport(page); }); test("Create a software template using http-request plugin", async () => { - test.setTimeout(130000); - await uiHelper.goToSelfServicePage(); - await uiHelper.verifyHeading("Templates"); - await uiHelper.clickButton("Import an existing Git repository"); + await selfServicePage.open(); + await selfServicePage.verifyTemplatesHeading(); + await selfServicePage.clickImportGitRepository(); await catalogImport.registerExistingComponent(template, false); - await uiHelper.openSidebar("Catalog"); - await uiHelper.selectMuiBox("Kind", "Template"); - await uiHelper.searchInputPlaceholder("Test HTTP Request"); - await uiHelper.clickLink("Test HTTP Request"); - await uiHelper.verifyHeading("Test HTTP Request"); - await uiHelper.clickLink("Launch Template"); - await uiHelper.verifyHeading("Self-service"); - await uiHelper.clickButton("Create"); - //Checking for Http Status 200 - await uiHelper.verifyText("200", false); + await scaffolderFlowPage.runHttpRequestTemplateFlow(); }); }); diff --git a/e2e-tests/playwright/e2e/plugins/licensed-users-info-backend/licensed-users-info.spec.ts b/e2e-tests/playwright/e2e/plugins/licensed-users-info-backend/licensed-users-info.spec.ts index 6503645cdb..d3c779acc5 100644 --- a/e2e-tests/playwright/e2e/plugins/licensed-users-info-backend/licensed-users-info.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/licensed-users-info-backend/licensed-users-info.spec.ts @@ -2,7 +2,7 @@ import { test, expect, APIRequestContext, APIResponse, request } from "@support/ import playwrightConfig from "../../../../playwright.config"; import { RhdhAuthUiHack } from "../../../support/api/rhdh-auth-hack"; -import { CatalogUsersPO } from "../../../support/page-objects/catalog/catalog-users-obj"; +import { CatalogBrowsePage } from "../../../support/pages/catalog-browse-page"; import { Common } from "../../../utils/common"; interface HealthResponse { @@ -48,6 +48,7 @@ function isLicensedUserArray(value: unknown): value is LicensedUser[] { test.describe("Test licensed users info backend plugin", () => { let common: Common; + let catalogBrowsePage: CatalogBrowsePage; test.beforeAll(() => { test.info().annotations.push({ @@ -63,10 +64,10 @@ test.describe("Test licensed users info backend plugin", () => { test.beforeEach(async ({ page }) => { common = new Common(page); + catalogBrowsePage = new CatalogBrowsePage(page); await common.loginAsGuest(); - await CatalogUsersPO.visitBaseURL(page); + await catalogBrowsePage.openLicensedUsersCatalog(); - // Get the api token const hacker: RhdhAuthUiHack = RhdhAuthUiHack.getInstance(); apiToken = await hacker.getApiToken(page); }); diff --git a/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts b/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts index ba874ff270..201ec74162 100644 --- a/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/scaffolder-backend-module-annotator/annotator.spec.ts @@ -1,14 +1,13 @@ -import { Page, test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; +import { CatalogBrowsePage } from "../../../support/pages/catalog-browse-page"; import { CatalogImport } from "../../../support/pages/catalog-import"; +import { ScaffolderFlowPage } from "../../../support/pages/scaffolder-flow-page"; import { runAccessibilityTests } from "../../../utils/accessibility"; import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; import { APIHelper } from "../../../utils/api-helper"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; +import { Common } from "../../../utils/common"; import { base64Decode } from "../../../utils/helper"; -import { UIhelper } from "../../../utils/ui-helper"; - -let page: Page; test.describe.serial("Test Scaffolder Backend Module Annotator", () => { test.skip( @@ -16,7 +15,8 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { "skipping due to RHDHBUGS-555 on OSD Env", ); - let uiHelper: UIhelper; + let scaffolderFlowPage: ScaffolderFlowPage; + let catalogBrowsePage: CatalogBrowsePage; let common: Common; let catalogImport: CatalogImport; @@ -30,81 +30,48 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { label: "some-label", annotation: "some-annotation", repo: `test-annotator-${Date.now()}`, - // Default repoOwner janus-qe repoOwner: base64Decode(process.env.GITHUB_ORG ?? "amFudXMtcWU="), }; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(async ({ rhdhPage }) => { test.info().annotations.push({ type: "component", description: "plugins", }); - page = (await setupBrowser(browser, testInfo)).page; - - common = new Common(page); - uiHelper = new UIhelper(page); - catalogImport = new CatalogImport(page); + common = new Common(rhdhPage); + scaffolderFlowPage = new ScaffolderFlowPage(rhdhPage); + catalogBrowsePage = new CatalogBrowsePage(rhdhPage); + catalogImport = new CatalogImport(rhdhPage); await common.loginAsGuest(); }); - test("Register the annotator template", async ({}, testInfo) => { - await uiHelper.openSidebar("Catalog"); - await uiHelper.verifyText("Name"); + test("Register the annotator template", async ({ rhdhPage }, testInfo) => { + await catalogBrowsePage.openCatalogSidebar(); + await catalogBrowsePage.verifyText("Name"); - await runAccessibilityTests(page, testInfo); + await runAccessibilityTests(rhdhPage, testInfo); - await uiHelper.clickButton("Self-service"); - await uiHelper.clickButton("Import an existing Git repository"); + await scaffolderFlowPage.openSelfServiceFromCatalog(); + await scaffolderFlowPage.clickImportGitRepository(); await catalogImport.registerExistingComponent(template, false); }); test("Scaffold a component using the annotator template", async () => { - test.setTimeout(130000); - await uiHelper.openSidebar("Catalog"); - await uiHelper.clickButton("Self-service"); - // Wait for the Self-service page to fully load before searching - await uiHelper.verifyHeading("Self-service"); - await uiHelper.searchInputPlaceholder("Create React App Template"); - await uiHelper.verifyText("Create React App Template"); - await uiHelper.waitForTextDisappear("Add ArgoCD to an existing project"); - await uiHelper.clickButton("Choose"); - - await uiHelper.fillTextInputByLabel("Name", reactAppDetails.componentName); - await uiHelper.fillTextInputByLabel("Description", reactAppDetails.description); - await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.owner); - await uiHelper.fillTextInputByLabel("Label", reactAppDetails.label); - await uiHelper.fillTextInputByLabel("Annotation", reactAppDetails.annotation); - await uiHelper.clickButton("Next"); - - await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.repoOwner); - await uiHelper.fillTextInputByLabel("Repository", reactAppDetails.repo); - await uiHelper.pressTab(); - await uiHelper.clickButton("Review"); - - await uiHelper.verifyRowInTableByUniqueText("Owner", [`group:${reactAppDetails.owner}`]); - await uiHelper.verifyRowInTableByUniqueText("Name", [reactAppDetails.componentName]); - await uiHelper.verifyRowInTableByUniqueText("Description", [reactAppDetails.description]); - await uiHelper.verifyRowInTableByUniqueText("Label", [reactAppDetails.label]); - await uiHelper.verifyRowInTableByUniqueText("Annotation", [reactAppDetails.annotation]); - await uiHelper.verifyRowInTableByUniqueText("Repository Location", [ - `github.com?owner=${reactAppDetails.repoOwner}&repo=${reactAppDetails.repo}`, - ]); - - await uiHelper.clickButton("Create"); - await expect(page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ - timeout: 30_000, - }); - await uiHelper.clickLink("Open in catalog"); + await scaffolderFlowPage.openSelfServiceFromCatalog(); + await scaffolderFlowPage.verifySelfServiceHeading(); + await scaffolderFlowPage.fillCreateReactAppTemplateForm(reactAppDetails); + + await scaffolderFlowPage.verifyCreateReactAppReviewTableWithGroupOwner(reactAppDetails); + + await scaffolderFlowPage.clickCreate(); + await scaffolderFlowPage.waitForOpenInCatalogLink(30_000); + await scaffolderFlowPage.clickOpenInCatalog(); }); test("Verify custom label is added to scaffolded component", async () => { - await uiHelper.openCatalogSidebar("Component"); - await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); - await uiHelper.clickLink(reactAppDetails.componentName); + await scaffolderFlowPage.openComponentInCatalog(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml( `labels:\n custom: ${reactAppDetails.label}\n`, @@ -112,11 +79,7 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { }); test("Verify custom annotation is added to scaffolded component", async () => { - await uiHelper.openCatalogSidebar("Component"); - await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); - await uiHelper.clickLink(reactAppDetails.componentName); + await scaffolderFlowPage.openComponentInCatalog(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml( `custom.io/annotation: ${reactAppDetails.annotation}`, @@ -124,31 +87,21 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { }); test("Verify template version annotation is added to scaffolded component", async () => { - await uiHelper.openCatalogSidebar("Component"); - await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - - await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); - await uiHelper.clickLink(reactAppDetails.componentName); + await scaffolderFlowPage.openComponentInCatalog(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml(`backstage.io/template-version: 0.0.1`); }); test("Verify template version annotation is present on the template", async () => { - await uiHelper.openSidebar("Catalog"); - await uiHelper.selectMuiBox("Kind", "Template"); - - await uiHelper.searchInputPlaceholder("Create React App Template\n"); - await uiHelper.verifyRowInTableByUniqueText("Create React App Template", ["website"]); - await uiHelper.clickLink("Create React App Template"); + await scaffolderFlowPage.openTemplateFromCatalog("Create React App Template", "website"); await catalogImport.inspectEntityAndVerifyYaml(`backstage.io/template-version: 0.0.1`); }); - test.afterAll(async ({}, testInfo) => { + test.afterAll(async () => { await APIHelper.githubRequest( "DELETE", GITHUB_API_ENDPOINTS.deleteRepo(reactAppDetails.repoOwner, reactAppDetails.repo), ); - await teardownBrowser(page, testInfo); }); }); diff --git a/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts b/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts index 9d892a85aa..3dfc69f0ce 100644 --- a/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/scaffolder-relation-processor/scaffolder-relation-processor.spec.ts @@ -1,13 +1,12 @@ -import { expect, Page, test } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; +import { CatalogBrowsePage } from "../../../support/pages/catalog-browse-page"; import { CatalogImport } from "../../../support/pages/catalog-import"; +import { ScaffolderFlowPage } from "../../../support/pages/scaffolder-flow-page"; import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; import { APIHelper } from "../../../utils/api-helper"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; +import { Common } from "../../../utils/common"; import { base64Decode } from "../../../utils/helper"; -import { UIhelper } from "../../../utils/ui-helper"; - -let page: Page; test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { test.skip( @@ -15,7 +14,8 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { "skipping due to RHDHBUGS-555 on OSD Env", ); - let uiHelper: UIhelper; + let scaffolderFlowPage: ScaffolderFlowPage; + let catalogBrowsePage: CatalogBrowsePage; let common: Common; let catalogImport: CatalogImport; @@ -30,70 +30,44 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { label: "test-label", annotation: "test-annotation", repo: `test-relation-${Date.now()}`, - // Default repoOwner janus-qe repoOwner: base64Decode(process.env.GITHUB_ORG ?? "amFudXMtcWU="), }; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(async ({ rhdhPage }) => { test.info().annotations.push({ type: "component", description: "plugins", }); - page = (await setupBrowser(browser, testInfo)).page; - - common = new Common(page); - uiHelper = new UIhelper(page); - catalogImport = new CatalogImport(page); + common = new Common(rhdhPage); + scaffolderFlowPage = new ScaffolderFlowPage(rhdhPage); + catalogBrowsePage = new CatalogBrowsePage(rhdhPage); + catalogImport = new CatalogImport(rhdhPage); await common.loginAsGuest(); }); test("Register the template for scaffolder relation processor", async () => { - await uiHelper.openSidebar("Catalog"); - await uiHelper.verifyText("Name"); + await catalogBrowsePage.openCatalogSidebar(); + await catalogBrowsePage.verifyText("Name"); - await uiHelper.clickButton("Self-service"); - await uiHelper.verifyHeading("Self-service"); - await uiHelper.clickButton("Import an existing Git repository"); + await scaffolderFlowPage.openSelfServiceFromCatalog(); + await scaffolderFlowPage.verifySelfServiceHeading(); + await scaffolderFlowPage.clickImportGitRepository(); await catalogImport.registerExistingComponent(template, false); }); test("Scaffold a component to test relation processing", async () => { - test.setTimeout(130000); - await uiHelper.openSidebar("Catalog"); - await uiHelper.clickButton("Self-service"); - await uiHelper.searchInputPlaceholder("Create React App Template"); - await uiHelper.verifyText("Create React App Template"); - await uiHelper.waitForTextDisappear("Add ArgoCD to an existing project"); - await uiHelper.clickButton("Choose"); - - await uiHelper.fillTextInputByLabel("Name", reactAppDetails.componentName); - await uiHelper.fillTextInputByLabel("Description", reactAppDetails.description); - await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.owner); - await uiHelper.fillTextInputByLabel("Label", reactAppDetails.label); - await uiHelper.fillTextInputByLabel("Annotation", reactAppDetails.annotation); - await uiHelper.clickButton("Next"); - - await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.repoOwner); - await uiHelper.fillTextInputByLabel("Repository", reactAppDetails.repo); - await uiHelper.pressTab(); - await uiHelper.clickButton("Review"); - - await uiHelper.clickButton("Create"); - // Wait for the scaffolder task to complete and the link to appear - await expect(page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ - timeout: 60000, - }); - await uiHelper.clickLink("Open in catalog"); - // Ensure the entity page has loaded - await expect(page.getByText(reactAppDetails.componentName)).toBeVisible({ - timeout: 20000, - }); + await scaffolderFlowPage.openSelfServiceFromCatalog(); + await scaffolderFlowPage.fillCreateReactAppTemplateForm(reactAppDetails); + + await scaffolderFlowPage.clickCreate(); + await scaffolderFlowPage.waitForOpenInCatalogLink(); + await scaffolderFlowPage.clickOpenInCatalog(); + await scaffolderFlowPage.verifyComponentNameVisible(reactAppDetails.componentName); }); test("Verify scaffoldedFrom relation in dependency graph and raw YAML", async () => { - // Verify the scaffoldedFrom relation in the YAML view of the entity await catalogImport.inspectEntityAndVerifyYaml( `relations: - type: ownedBy @@ -107,51 +81,34 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { scaffoldedFrom: template:default/create-react-app-template-with-timestamp-entityref`, ); - await uiHelper.openCatalogSidebar("Component"); - await uiHelper.searchInputPlaceholder("test-relation-\n"); - await clickOnRelationTestComponent(); + await catalogBrowsePage.openCatalogSidebar("Component"); + await catalogBrowsePage.searchCatalog("test-relation-\n"); + await catalogBrowsePage.openEntityLinkByHref("/catalog/default/component/test-relation-"); - await uiHelper.clickTab("Dependencies"); + await catalogBrowsePage.openDependenciesTab(); - const labelSelector = 'g[data-testid="label"]'; - const nodeSelector = 'g[data-testid="node"]'; - - await uiHelper.verifyTextInSelector(labelSelector, "scaffolderOf / scaffoldedFrom"); - - await uiHelper.verifyPartialTextInSelector(nodeSelector, reactAppDetails.componentPartialName); + await scaffolderFlowPage.verifyDependencyGraphLabels( + 'g[data-testid="label"]', + 'g[data-testid="node"]', + "scaffolderOf / scaffoldedFrom", + reactAppDetails.componentPartialName, + ); }); test("Verify scaffolderOf relation on the template", async () => { - await uiHelper.openSidebar("Catalog"); - await uiHelper.selectMuiBox("Kind", "Template"); + await scaffolderFlowPage.openTemplateFromCatalog("Create React App Template", "website"); - await uiHelper.searchInputPlaceholder("Create React App Template\n"); - await uiHelper.verifyRowInTableByUniqueText("Create React App Template", ["website"]); - await uiHelper.clickLink("Create React App Template"); - - // Verify the scaffolderOf relation in the YAML view await catalogImport.inspectEntityAndVerifyYaml( `- type: scaffolderOf\n targetRef: component:default/${reactAppDetails.componentName}\n`, ); - // Verify the template is still functional - await uiHelper.clickLink("Launch Template"); - await uiHelper.verifyText("Provide some simple information"); + await scaffolderFlowPage.launchTemplateAndVerifyIntro(); }); - test.afterAll(async ({}, testInfo) => { + test.afterAll(async () => { await APIHelper.githubRequest( "DELETE", GITHUB_API_ENDPOINTS.deleteRepo(reactAppDetails.repoOwner, reactAppDetails.repo), ); - await teardownBrowser(page, testInfo); }); - - async function clickOnRelationTestComponent() { - const selector = 'a[href*="/catalog/default/component/test-relation-"]'; - await page.locator(selector).first().waitFor({ state: "visible" }); - const link = page.locator(selector).first(); - await expect(link).toBeVisible(); - await link.click(); - } }); diff --git a/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts b/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts index e3a11ffc93..5481ee9ad6 100644 --- a/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/user-settings-info-card.spec.ts @@ -1,7 +1,8 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; +import { RhdhHomePage } from "../../support/pages/rhdh-home-page"; +import { SettingsPage } from "../../support/pages/settings-page"; import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; test.describe("Test user settings info card", { tag: "@layer3-equivalent" }, () => { test.beforeAll(() => { @@ -11,32 +12,29 @@ test.describe("Test user settings info card", { tag: "@layer3-equivalent" }, () }); }); - let uiHelper: UIhelper; + let rhdhHomePage: RhdhHomePage; + let settingsPage: SettingsPage; test.beforeEach(async ({ page }) => { const common = new Common(page); await common.loginAsGuest(); - uiHelper = new UIhelper(page); + rhdhHomePage = new RhdhHomePage(page); + settingsPage = new SettingsPage(page); }); - test("Check if customized build info is rendered", async ({ page }) => { - await uiHelper.openSidebar("Home"); - await page.getByText("Guest").click(); - await page.getByRole("menuitem", { name: "Settings" }).click(); + test("Check if customized build info is rendered", async () => { + await rhdhHomePage.openHomeSidebar(); + await settingsPage.openFromProfile("Guest"); - // Verify card header is visible - await expect(page.getByText("RHDH Build info")).toBeVisible(); + await settingsPage.verifyBuildInfoCardVisible(); + await settingsPage.verifyBuildInfoText("TechDocs builder: local"); + await settingsPage.verifyBuildInfoText("Authentication provider: Github"); - // Verify initial card content using text content - await expect(page.getByText("TechDocs builder: local")).toBeVisible(); - await expect(page.getByText("Authentication provider: Github")).toBeVisible(); + await settingsPage.expandShowMoreSection(); - await page.getByTitle("Show more").click(); - - // Verify expanded card content shows RBAC status - await expect(page.getByText("TechDocs builder: local")).toBeVisible(); - await expect(page.getByText("Authentication provider: Github")).toBeVisible(); - await expect(page.getByText("RBAC: disabled")).toBeVisible(); + await settingsPage.verifyBuildInfoText("TechDocs builder: local"); + await settingsPage.verifyBuildInfoText("Authentication provider: Github"); + await settingsPage.verifyBuildInfoText("RBAC: disabled"); }); }); diff --git a/e2e-tests/playwright/e2e/settings.spec.ts b/e2e-tests/playwright/e2e/settings.spec.ts index 69f798e711..0a6f2712a8 100644 --- a/e2e-tests/playwright/e2e/settings.spec.ts +++ b/e2e-tests/playwright/e2e/settings.spec.ts @@ -1,13 +1,13 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; +import { SettingsPage } from "../support/pages/settings-page"; import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; import { getTranslations, getCurrentLanguage } from "./localization/locale"; const t = getTranslations(); const lang = getCurrentLanguage(); -let uiHelper: UIhelper; +let settingsPage: SettingsPage; test.describe(`Settings page`, { tag: "@layer3-equivalent" }, () => { test.beforeEach(async ({ page }) => { @@ -16,61 +16,29 @@ test.describe(`Settings page`, { tag: "@layer3-equivalent" }, () => { description: "core", }); const common = new Common(page); - uiHelper = new UIhelper(page); + settingsPage = new SettingsPage(page); await common.loginAsGuest(); - await uiHelper.goToSettingsPage(); + await settingsPage.open(); }); // Run tests only for the selected language - test(`Verify settings page`, async ({ page }) => { - await uiHelper.hideQuickstartIfVisible(); - await expect(page.getByRole("list").first()).toMatchAriaSnapshot(` - - listitem: - - text: ${t["user-settings"][lang]["languageToggle.title"]} - - paragraph: ${t["user-settings"][lang]["languageToggle.description"]} - `); - - await expect(page.getByTestId("select")).toContainText( - /English|Deutsch|Español|Français|Italiano|日本語/u, - ); - await page - .getByTestId("select") - .getByRole("button", { - name: /English|Deutsch|Español|Français|Italiano|日本語/u, - }) - .click(); - await expect(page.getByRole("listbox")).toMatchAriaSnapshot(` - - listbox: - - option "English" - - option "Deutsch" - - option "Español" - - option "Français" - - option "Italiano" - - option "日本語" - `); - await page.getByRole("option", { name: "Français" }).click(); - await expect(page.getByTestId("select")).toContainText("Français"); - - await uiHelper.verifyText(t["user-settings"]["fr"]["profileCard.title"]); - await uiHelper.verifyText(t["user-settings"]["fr"]["appearanceCard.title"]); - await uiHelper.verifyText(t["user-settings"]["fr"]["themeToggle.title"]); - await page.getByTestId("user-settings-menu").click(); - await expect(page.getByTestId("sign-out")).toContainText( - t["user-settings"]["fr"]["signOutMenu.title"], - ); - await page.keyboard.press(`Escape`); - - await uiHelper.verifyText(t["user-settings"]["fr"]["identityCard.title"]); - await uiHelper.verifyText(t["user-settings"]["fr"]["identityCard.userEntity"] + ": Guest User"); - await uiHelper.verifyText( - t["user-settings"]["fr"]["identityCard.ownershipEntities"] + ": Guest User, team-a", - ); - - await uiHelper.verifyText(t["user-settings"]["fr"]["pinToggle.title"]); - await uiHelper.verifyText(t["user-settings"]["fr"]["pinToggle.description"]); - await uiHelper.uncheckCheckbox(t["user-settings"]["fr"]["pinToggle.ariaLabelTitle"]); - await expect(page.getByText(t["rhdh"]["fr"]["menuItem.apis"])).toBeHidden(); - await uiHelper.checkCheckbox(t["user-settings"]["fr"]["pinToggle.ariaLabelTitle"]); - await uiHelper.verifyText(t["rhdh"]["fr"]["menuItem.home"]); + test(`Verify settings page`, async () => { + await settingsPage.hideQuickstartIfVisible(); + await settingsPage.verifyLanguageToggleList(lang); + await settingsPage.verifyLanguageSelectShowsOptions(); + await settingsPage.openLanguageSelect(); + await settingsPage.verifyLanguageOptionsList(); + await settingsPage.selectLanguage("Français"); + await settingsPage.verifySelectedLanguage("Français"); + + await settingsPage.verifyLocalizedUserSettingsLabelsWithOwnership("fr", "Guest User, team-a"); + await settingsPage.openUserSettingsMenu(); + await settingsPage.verifySignOutMenuLabel(t["user-settings"]["fr"]["signOutMenu.title"]); + await settingsPage.closeUserSettingsMenu(); + + await settingsPage.uncheckCheckbox(t["user-settings"]["fr"]["pinToggle.ariaLabelTitle"]); + await settingsPage.verifySidebarMenuItemHidden(t["rhdh"]["fr"]["menuItem.apis"]); + await settingsPage.checkCheckbox(t["user-settings"]["fr"]["pinToggle.ariaLabelTitle"]); + await settingsPage.verifyText(t["rhdh"]["fr"]["menuItem.home"]); }); }); diff --git a/e2e-tests/playwright/e2e/smoke-test.spec.ts b/e2e-tests/playwright/e2e/smoke-test.spec.ts index 87e2583cdf..13ca656e75 100644 --- a/e2e-tests/playwright/e2e/smoke-test.spec.ts +++ b/e2e-tests/playwright/e2e/smoke-test.spec.ts @@ -1,10 +1,11 @@ import { test } from "@support/coverage/test"; +import { RhdhHomePage } from "../support/pages/rhdh-home-page"; import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; +import { waitForRhdhReady } from "../utils/wait-for-rhdh-ready"; test.describe("Smoke test", { tag: "@smoke" }, () => { - let uiHelper: UIhelper; + let rhdhHomePage: RhdhHomePage; let common: Common; test.beforeAll(() => { @@ -14,13 +15,14 @@ test.describe("Smoke test", { tag: "@smoke" }, () => { }); }); - test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); + test.beforeEach(async ({ page, request }) => { + await waitForRhdhReady(request); + rhdhHomePage = new RhdhHomePage(page); common = new Common(page); await common.loginAsGuest(); }); - test("Verify the Homepage renders", async () => { - await uiHelper.verifyHeading("Welcome back!"); + test("Verify the RHDH instance homepage renders", async () => { + await rhdhHomePage.verifyWelcomeHeading(); }); }); diff --git a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts index b350ce2f9d..920b9fafb7 100644 --- a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts +++ b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts @@ -3,8 +3,8 @@ import { ChildProcessWithoutNullStreams, exec, spawn } from "child_process"; import { expect, test } from "@support/coverage/test"; import Redis from "ioredis"; +import { TechDocsPage } from "../support/pages/techdocs-page"; import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; function streamDataToString(data: Buffer | string): string { return typeof data === "string" ? data : data.toString(); @@ -20,12 +20,12 @@ test.describe("Verify Redis Cache DB", () => { test.describe.configure({ mode: "serial" }); let common: Common; - let uiHelper: UIhelper; + let techDocsPage: TechDocsPage; let portForward: ChildProcessWithoutNullStreams; let redis: Redis; test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); + techDocsPage = new TechDocsPage(page); common = new Common(page); await common.loginAsGuest(); @@ -56,19 +56,15 @@ test.describe("Verify Redis Cache DB", () => { }); test("Open techdoc and verify the cache generated in redis db", async () => { - test.setTimeout(120_000); - portForward.stdout.on("data", (data: Buffer | string) => { console.log(`Port-forward stdout: ${streamDataToString(data)}`); }); - await uiHelper.openSidebarButton("Favorites"); - await uiHelper.openSidebar("Docs"); - await uiHelper.clickLink("Red Hat Developer Hub"); + await techDocsPage.openDocFromFavorites("Red Hat Developer Hub"); // ensure that the docs are generated. if redis configuration has an error, this page will hang and docs won't be generated await expect(async () => { - await uiHelper.verifyHeading("rhdh"); + await techDocsPage.verifyDocHeading("rhdh"); }).toPass({ intervals: [3_000], timeout: 60_000, diff --git a/e2e-tests/playwright/global-setup.ts b/e2e-tests/playwright/global-setup.ts new file mode 100644 index 0000000000..e3444d47e2 --- /dev/null +++ b/e2e-tests/playwright/global-setup.ts @@ -0,0 +1,24 @@ +import { request as playwrightRequest } from "@playwright/test"; + +import { waitForRhdhReady } from "./utils/wait-for-rhdh-ready"; + +/** + * Ensures the deployed RHDH instance responds before any project runs. + * Skipped when BASE_URL is unset (local lint-only runs). + */ +export default async function globalSetup(): Promise { + const baseURL = process.env.BASE_URL; + if (baseURL === undefined || baseURL === "") { + return; + } + + const request = await playwrightRequest.newContext({ + baseURL, + ignoreHTTPSErrors: true, + }); + try { + await waitForRhdhReady(request); + } finally { + await request.dispose(); + } +} diff --git a/e2e-tests/playwright/support/coverage/test.ts b/e2e-tests/playwright/support/coverage/test.ts index cb76b0bcc2..c861bac799 100644 --- a/e2e-tests/playwright/support/coverage/test.ts +++ b/e2e-tests/playwright/support/coverage/test.ts @@ -9,6 +9,10 @@ // Usage in a spec using the built-in { page } fixture: // import { test, expect } from "@support/coverage/test"; // +// For serial specs that share one browser context across a describe block, +// use the worker-scoped fixtures instead of manual beforeAll setup: +// test.beforeAll(async ({ rhdhPage, rhdhContext }) => { ... }); +// // For specs that create their own context/page via browser.newContext(), // import the helpers directly and call them around the test body: // import { startCoverageForPage, stopCoverageForPage } from "@support/coverage/test"; @@ -18,9 +22,11 @@ import { test as baseTest, expect as baseExpect, + type BrowserContext, type Page, type TestInfo, } from "@playwright/test"; +import { setupBrowser, teardownBrowser } from "../../utils/common/browser"; // Re-export all Playwright types and values so specs can replace // `from "@playwright/test"` with this module. The locally-defined `test` // and `expect` below shadow the star re-exports. @@ -117,14 +123,37 @@ export async function stopCoverageForPage( // with the idiomatic `import { test, expect } from "..."` pattern. The project // naming rule requires UPPER_CASE for exported const, but shadowing the // Playwright convention would force every consumer to alias — worse DX. +type RhdhBrowserWorkerFixtures = { + rhdhContext: BrowserContext; + rhdhPage: Page; +}; + // eslint-disable-next-line @typescript-eslint/naming-convention -export const test = baseTest.extend>({ - page: async ({ page }, use, testInfo) => { - await startCoverageForPage(page); - await use(page); - await stopCoverageForPage(page, testInfo); +export const test = baseTest.extend, RhdhBrowserWorkerFixtures>( + { + page: async ({ page }, use, testInfo) => { + await startCoverageForPage(page); + await use(page); + await stopCoverageForPage(page, testInfo); + }, + rhdhContext: [ + async ({ browser }, use, testInfo) => { + const { page, context } = await setupBrowser(browser, testInfo); + await use(context); + await teardownBrowser(page, testInfo); + }, + { scope: "worker" }, + ], + rhdhPage: [ + async ({ rhdhContext }, use) => { + const existingPage = rhdhContext.pages()[0]; + const page = existingPage ?? (await rhdhContext.newPage()); + await use(page); + }, + { scope: "worker" }, + ], }, -}); +); // eslint-disable-next-line @typescript-eslint/naming-convention export const expect = baseExpect; diff --git a/e2e-tests/playwright/support/fixtures/auth-provider-harness.ts b/e2e-tests/playwright/support/fixtures/auth-provider-harness.ts new file mode 100644 index 0000000000..de0f3e8abf --- /dev/null +++ b/e2e-tests/playwright/support/fixtures/auth-provider-harness.ts @@ -0,0 +1,97 @@ +import { expect } from "@playwright/test"; + +import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; + +const DEFAULT_CONFIG_MAPS = { + appConfigMap: "app-config-rhdh", + rbacConfigMap: "rbac-policy", + dynamicPluginsConfigMap: "dynamic-plugins", + secretName: "rhdh-secrets", +} as const; + +/** Shared K8s + RHDH deployment orchestration for auth-provider E2E specs. */ +export class AuthProviderHarness { + readonly deployment: RHDHDeployment; + readonly backstageUrl: string; + readonly backstageBackendUrl: string; + + private constructor( + deployment: RHDHDeployment, + backstageUrl: string, + backstageBackendUrl: string, + ) { + this.deployment = deployment; + this.backstageUrl = backstageUrl; + this.backstageBackendUrl = backstageBackendUrl; + } + + static async create(namespace: string, instanceName = "rhdh"): Promise { + const deployment = new RHDHDeployment( + namespace, + DEFAULT_CONFIG_MAPS.appConfigMap, + DEFAULT_CONFIG_MAPS.rbacConfigMap, + DEFAULT_CONFIG_MAPS.dynamicPluginsConfigMap, + DEFAULT_CONFIG_MAPS.secretName, + ); + deployment.instanceName = instanceName; + const backstageUrl = await deployment.computeBackstageUrl(); + const backstageBackendUrl = await deployment.computeBackstageBackendUrl(); + console.log(`Backstage BaseURL is: ${backstageUrl}`); + return new AuthProviderHarness(deployment, backstageUrl, backstageBackendUrl); + } + + expectEnvVars(envVarNames: string[]): void { + for (const name of envVarNames) { + expect(process.env[name]).toBeDefined(); + } + } + + async loadConfigsAndProvisionNamespace(): Promise { + await this.deployment.loadAllConfigs(); + await this.deployment.deleteNamespaceIfExists(); + await (await this.deployment.createNamespace()).waitForNamespaceActive(); + await this.deployment.createAllConfigs(); + await this.deployment.generateStaticToken(); + } + + async addBaseUrlSecretsIfRemote(): Promise { + if ( + process.env.ISRUNNINGLOCAL === undefined || + process.env.ISRUNNINGLOCAL === "" || + process.env.ISRUNNINGLOCAL === "false" + ) { + await this.deployment.addSecretData("BASE_URL", this.backstageUrl); + await this.deployment.addSecretData("BASE_BACKEND_URL", this.backstageBackendUrl); + } + } + + async addSecretsFromEnv(entries: Record): Promise { + for (const [secretKey, envVar] of Object.entries(entries)) { + await this.deployment.addSecretData(secretKey, process.env[envVar]!); + } + } + + async createSecret(): Promise { + await this.deployment.createSecret(); + } + + async deployAndWait(): Promise { + await this.deployment.createBackstageDeployment(); + await this.deployment.waitForDeploymentReady(); + await this.deployment.waitForSynced(); + } + + async reconcileAfterConfigChange(): Promise { + await this.deployment.updateAllConfigs(); + await this.deployment.restartLocalDeployment(); + await this.deployment.waitForConfigReconciled(); + await this.deployment.waitForDeploymentReady(); + await this.deployment.waitForSynced(); + } + + async cleanup(): Promise { + console.log("[TEST] Starting cleanup..."); + await this.deployment.killRunningProcess(); + console.log("[TEST] Cleanup completed"); + } +} diff --git a/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts b/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts deleted file mode 100644 index e820f4bcfd..0000000000 --- a/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Page, Locator } from "@playwright/test"; - -export const CatalogUsersPO = { - BASE_URL: "/catalog?filters%5Bkind%5D=user&filters%5Buser", - - getListOfUsers(page: Page): Locator { - // Get all user links in the table's body - // Using rowgroup to target tbody, then getting links within cells - // These links point to /catalog/{namespace}/user/{username} - return ( - page - .getByRole("table") - .first() - // Scope to the first table (users table), not pagination table - .getByRole("rowgroup") - // Second rowgroup (data rows), 0-indexed: 0=header, 1=data - .nth(1) - .getByRole("cell") - .getByRole("link") - ); - }, - - getEmailLink(page: Page): Locator { - return page.getByRole("link", { name: /@/u }); - }, - - async visitUserPage(page: Page, username: string) { - // Click on user link in the table by name - await page - .getByRole("table") - .getByRole("link", { name: new RegExp(username, "iu") }) - .first() - .click(); - }, - - getGroupLink(page: Page, groupName: string): Locator { - return page.getByRole("link", { name: new RegExp(groupName, "iu") }); - }, - - async visitBaseURL(page: Page) { - await page.goto(CatalogUsersPO.BASE_URL); - }, -}; diff --git a/e2e-tests/playwright/support/page-objects/global-obj.ts b/e2e-tests/playwright/support/page-objects/global-obj.ts deleted file mode 100644 index b8d9571371..0000000000 --- a/e2e-tests/playwright/support/page-objects/global-obj.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* oxlint-disable playwright/no-raw-locators -- Legacy CSS selector constants; prefer SemanticSelectors get*() methods */ -import { Page, Locator } from "@playwright/test"; - -import { SemanticSelectors } from "../selectors/semantic"; - -/** - * WAIT_OBJECTS - Loading indicators - * @deprecated These MUI class selectors are fragile. Consider using SemanticSelectors or expect() assertions instead. - */ -export const WAIT_OBJECTS = { - /** @deprecated Use expect(locator).not.toBeVisible() for loading indicators */ - MuiLinearProgress: 'div[class*="MuiLinearProgress-root"]', - /** @deprecated Use expect(locator).not.toBeVisible() for loading indicators */ - MuiCircularProgress: '[class*="MuiCircularProgress-root"]', -}; - -/** - * UI_HELPER_ELEMENTS - Legacy MUI class selectors - * @deprecated These selectors are based on MUI implementation details and can break with UI library updates. - * - * Migration Guide: - * - MuiButtonLabel -> Use SemanticSelectors.button(page, 'Button Name') or getButton() method below - * - MuiTableCell -> Use SemanticSelectors.tableCell(page, 'Cell Text') or getTableCell() method - * - MuiTableHead -> Use SemanticSelectors.tableHeader(page, 'Column Name') or getTableHeader() method - * - * New code should use SemanticSelectors class or the get*() methods below. - */ -export const UI_HELPER_ELEMENTS = { - // ======================================== - // LEGACY SELECTORS - Maintained for backward compatibility - // These will be removed in a future phase after all tests are migrated - // ======================================== - - /** @deprecated Use SemanticSelectors.button(page, name) or getButton() */ - MuiButtonLabel: 'span[class^="MuiButton-label"],button[class*="MuiButton-root"]', - /** @deprecated Use SemanticSelectors.button(page, name) with filter */ - MuiToggleButtonLabel: 'span[class^="MuiToggleButton-label"]', - /** @deprecated Use SemanticSelectors.inputByLabel(page, label) */ - MuiBoxLabel: 'div[class*="MuiBox-root"] label', - /** @deprecated Use SemanticSelectors.tableHeader(page, name) or getTableHeader() */ - MuiTableHead: 'th[class*="MuiTableCell-root"]', - /** @deprecated Use SemanticSelectors.tableCell(page, text) or getTableCell() */ - MuiTableCell: 'td[class*="MuiTableCell-root"]', - /** @deprecated Use SemanticSelectors.tableRow(page, text) or getTableRow() */ - MuiTableRow: 'tr[class*="MuiTableRow-root"]', - /** @deprecated Use semantic selectors with appropriate styling checks */ - MuiTypographyColorPrimary: ".MuiTypography-colorPrimary", - /** @deprecated Use SemanticSelectors.checkbox() with appropriate checks */ - MuiSwitchColorPrimary: ".MuiSwitch-colorPrimary", - /** @deprecated Use SemanticSelectors.button(page, name) */ - MuiButtonTextPrimary: ".MuiButton-textPrimary", - /** @deprecated Use getCardByHeading() method or SemanticSelectors.region().filter() */ - MuiCard: (cardHeading: string) => - `//div[contains(@class,'MuiCardHeader-root') and descendant::*[text()='${cardHeading}']]/..`, - /** @deprecated Use getCardByText() method or SemanticSelectors.region().filter() */ - MuiCardRoot: (cardText: string) => - `//div[contains(@class,'MuiCard-root')][descendant::text()[contains(., '${cardText}')]]`, - /** @deprecated Use SemanticSelectors.table(page) or getTable() */ - MuiTable: "table.MuiTable-root", - /** @deprecated Use SemanticSelectors.region().filter() for card headers */ - MuiCardHeader: 'div[class*="MuiCardHeader-root"]', - /** @deprecated Use SemanticSelectors.inputByPlaceholder() or inputByLabel() */ - MuiInputBase: 'div[class*="MuiInputBase-root"]', - /** @deprecated Use appropriate semantic selector based on element role */ - MuiTypography: 'span[class*="MuiTypography-root"]', - /** @deprecated Use SemanticSelectors.alert(page, name) */ - MuiAlert: 'div[class*="MuiAlert-message"]', - /** ✅ This is already semantic, but prefer SemanticSelectors.tab(page, name) */ - tabs: '[role="tab"]', - /** @deprecated Use SemanticSelectors.tableRow(page, text) */ - rowByText: (text: string) => `tr:has(:text-is("${text}"))`, - - // ======================================== - // NEW SEMANTIC METHODS - Preferred approach - // Use these for new code and when refactoring - // ======================================== - - /** - * Get a button by its accessible name - * ✅ Preferred over MuiButtonLabel - * @example UI_HELPER_ELEMENTS.getButton(page, 'Submit').click() - */ - getButton: (page: Page, name: string | RegExp): Locator => SemanticSelectors.button(page, name), - - /** - * Get a link by its accessible name - * ✅ Preferred for navigation elements - * @example UI_HELPER_ELEMENTS.getLink(page, 'View Details').click() - */ - getLink: (page: Page, name: string | RegExp): Locator => SemanticSelectors.link(page, name), - - /** - * Get a table element - * ✅ Preferred over MuiTable - * @example const table = UI_HELPER_ELEMENTS.getTable(page) - */ - getTable: (page: Page): Locator => SemanticSelectors.table(page), - - /** - * Get a table cell by content - * ✅ Preferred over MuiTableCell - * @example UI_HELPER_ELEMENTS.getTableCell(page, 'Active') - */ - getTableCell: (page: Page, text?: string | RegExp): Locator => - SemanticSelectors.tableCell(page, text), - - /** - * Get a table header (column header) - * ✅ Preferred over MuiTableHead - * @example UI_HELPER_ELEMENTS.getTableHeader(page, 'Created At').click() - */ - getTableHeader: (page: Page, name: string | RegExp): Locator => - SemanticSelectors.tableHeader(page, name), - - /** - * Get a table row by content - * ✅ Preferred over MuiTableRow and rowByText - * @example const row = UI_HELPER_ELEMENTS.getTableRow(page, 'Guest User') - */ - getTableRow: (page: Page, text?: string | RegExp): Locator => - SemanticSelectors.tableRow(page, text), - - /** - * Get a heading by text and optional level - * @example UI_HELPER_ELEMENTS.getHeading(page, 'RBAC', 1) - */ - getHeading: (page: Page, name: string | RegExp, level?: 1 | 2 | 3 | 4 | 5 | 6): Locator => - SemanticSelectors.heading(page, name, level), - - /** - * Get a tab by name - * ✅ Preferred over tabs selector - * @example UI_HELPER_ELEMENTS.getTab(page, 'Settings').click() - */ - getTab: (page: Page, name: string | RegExp): Locator => SemanticSelectors.tab(page, name), - - /** - * Get a dialog/modal - * @example const dialog = UI_HELPER_ELEMENTS.getDialog(page, 'Confirm Delete') - */ - getDialog: (page: Page, name?: string | RegExp): Locator => SemanticSelectors.dialog(page, name), - - /** - * Get a card by heading text (semantic alternative to MuiCard) - * ✅ Preferred over MuiCard XPath selector - * @example const card = UI_HELPER_ELEMENTS.getCardByHeading(page, 'RHDH Build info') - */ - getCardByHeading: (page: Page, heading: string | RegExp): Locator => { - // Find region or article containing the heading - return page - .locator('[role="region"], article, section') - .filter({ - has: page.getByRole("heading", { name: heading }), - }) - .first(); - }, - - /** - * Get a card by text content (semantic alternative to MuiCardRoot) - * ✅ Preferred over MuiCardRoot XPath selector - * @example const card = UI_HELPER_ELEMENTS.getCardByText(page, 'Context one') - */ - getCardByText: (page: Page, text: string | RegExp): Locator => { - return page - .locator('[role="region"], article, section') - .filter({ - hasText: text, - }) - .first(); - }, - - /** - * Get an input by label - * ✅ Preferred over MuiInputBase - * @example UI_HELPER_ELEMENTS.getInputByLabel(page, 'Email').fill('test@example.com') - */ - getInputByLabel: (page: Page, label: string | RegExp): Locator => - SemanticSelectors.inputByLabel(page, label), - - /** - * Get an input by placeholder - * ✅ Preferred over MuiInputBase - * @example UI_HELPER_ELEMENTS.getInputByPlaceholder(page, 'Search...').fill('test') - */ - getInputByPlaceholder: (page: Page, placeholder: string | RegExp): Locator => - SemanticSelectors.inputByPlaceholder(page, placeholder), - - /** - * Get an alert element - * ✅ Preferred over MuiAlert - * @example await expect(UI_HELPER_ELEMENTS.getAlert(page, 'Error')).toBeVisible() - */ - getAlert: (page: Page, name?: string | RegExp): Locator => SemanticSelectors.alert(page, name), - - /** - * Get navigation element - * @example const nav = UI_HELPER_ELEMENTS.getNavigation(page) - */ - getNavigation: (page: Page, name?: string | RegExp): Locator => - SemanticSelectors.navigation(page, name), - - /** - * Get menu item - * @example UI_HELPER_ELEMENTS.getMenuItem(page, 'Delete').click() - */ - getMenuItem: (page: Page, name: string | RegExp): Locator => - SemanticSelectors.menuItem(page, name), -}; diff --git a/e2e-tests/playwright/support/page-objects/page-obj.ts b/e2e-tests/playwright/support/page-objects/page-obj.ts deleted file mode 100644 index 1f24d2cb9c..0000000000 --- a/e2e-tests/playwright/support/page-objects/page-obj.ts +++ /dev/null @@ -1,272 +0,0 @@ -/* oxlint-disable playwright/no-raw-locators -- Legacy CSS selector constants; prefer SemanticSelectors get*() methods */ -import { Page, Locator } from "@playwright/test"; - -import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; -import { SemanticSelectors } from "../selectors/semantic"; - -const t = getTranslations(); -const lang = getCurrentLanguage(); - -/** - * HOME_PAGE_COMPONENTS - Home page element selectors - */ -export const HOME_PAGE_COMPONENTS = { - // Legacy selectors - maintained for backward compatibility - /** @deprecated Use SemanticSelectors.region() with appropriate filter */ - MuiAccordion: 'div[class*="MuiAccordion-root-"]', - /** @deprecated Use SemanticSelectors.region() or article with appropriate filter */ - MuiCard: 'div[class*="MuiCard-root-"]', - - // Semantic methods - preferred - /** - * Get accordion/expandable section by heading text - * ✅ Preferred over MuiAccordion - * @example HOME_PAGE_COMPONENTS.getAccordion(page, 'Quick Access').click() - */ - getAccordion: (page: Page, heading: string | RegExp): Locator => - page - .getByRole("button", { name: heading, expanded: false }) - .or(page.getByRole("button", { name: heading, expanded: true })), - - /** - * Get card by heading or content - * ✅ Preferred over MuiCard - * @example HOME_PAGE_COMPONENTS.getCard(page, 'Recently Visited') - */ - getCard: (page: Page, headingOrText: string | RegExp): Locator => - page - .locator('[role="region"], article, section') - .filter({ - hasText: headingOrText, - }) - .first(), -}; - -/** - * SEARCH_OBJECTS_COMPONENTS - Search input selectors - */ -export const SEARCH_OBJECTS_COMPONENTS = { - // Legacy selectors - maintained for backward compatibility - ariaLabelSearch: `input[aria-label="${t["search-react"][lang]["searchBar.title"]}"]`, - placeholderSearch: `input[placeholder="${t["search-react"][lang]["searchBar.title"]}"]`, - - // Semantic methods - preferred - /** - * Get search input (tries label first, then placeholder) - * ✅ Preferred approach - * @example SEARCH_OBJECTS_COMPONENTS.getSearchInput(page).fill('test') - */ - getSearchInput: (page: Page): Locator => { - const searchTitle = t["search-react"][lang]["searchBar.title"]; - return page.getByLabel(searchTitle).or(page.getByPlaceholder(searchTitle)); - }, -}; - -/** - * CATALOG_IMPORT_COMPONENTS - Catalog import selectors - */ -export const CATALOG_IMPORT_COMPONENTS = { - // This selector is already semantic (using name attribute) - componentURL: 'input[name="url"]', - - // Semantic method - preferred - /** - * Get component URL input - * ✅ Preferred when label exists - * @example CATALOG_IMPORT_COMPONENTS.getURLInput(page).fill('https://...') - */ - getURLInput: (page: Page): Locator => page.locator('input[name="url"]'), -}; - -/** - * KUBERNETES_COMPONENTS - Kubernetes plugin selectors - */ -export const KUBERNETES_COMPONENTS = { - // Legacy selectors - maintained for backward compatibility - /** @deprecated Use getClusterAccordion() method */ - MuiAccordion: 'div[class*="MuiAccordion-root-"]', - /** ✅ This is already semantic - using aria-label */ - statusOk: 'span[aria-label="Status ok"]', - /** ✅ This is already semantic - using aria-label */ - podLogs: 'label[aria-label="get logs"]', - /** @deprecated Use SemanticSelectors.alert() */ - MuiSnackbarContent: 'div[class*="MuiSnackbarContent-message-"]', - - // Semantic methods - preferred - /** - * Get cluster accordion by cluster name - * ✅ Preferred over MuiAccordion - * @example KUBERNETES_COMPONENTS.getClusterAccordion(page, 'production').click() - */ - getClusterAccordion: (page: Page, clusterName?: string | RegExp): Locator => { - if (clusterName !== undefined) { - return page - .getByRole("button", { name: clusterName, expanded: false }) - .or(page.getByRole("button", { name: clusterName, expanded: true })); - } - // Get first accordion button (buttons with expanded attribute) - return page - .getByRole("button", { expanded: false }) - .or(page.getByRole("button", { expanded: true })) - .first(); - }, - - /** - * Get status indicator - * @example await expect(KUBERNETES_COMPONENTS.getStatus(page, 'ok')).toBeVisible() - */ - getStatus: (page: Page, status: string): Locator => - page.locator(`span[aria-label="Status ${status}"]`), - - /** - * Get pod logs label/button - * @example KUBERNETES_COMPONENTS.getPodLogsButton(page).click() - */ - getPodLogsButton: (page: Page): Locator => page.locator('label[aria-label="get logs"]'), - - /** - * Get error/notification snackbar - * ✅ Preferred over MuiSnackbarContent - * @example await expect(KUBERNETES_COMPONENTS.getNotification(page)).toContainText('Error') - */ - getNotification: (page: Page, message?: string | RegExp): Locator => - message === undefined ? SemanticSelectors.alert(page) : SemanticSelectors.alert(page, message), -}; - -/** - * BACKSTAGE_SHOWCASE_COMPONENTS - Table pagination selectors - */ -export const BACKSTAGE_SHOWCASE_COMPONENTS = { - // Legacy selectors - maintained for backward compatibility - /** ✅ These are already semantic - using aria-label */ - tableNextPage: 'button[aria-label="Next Page"]', - tablePreviousPage: 'button[aria-label="Previous Page"]', - tableLastPage: 'button[aria-label="Last Page"]', - tableFirstPage: 'button[aria-label="First Page"]', - /** @deprecated Use getTableRows() method */ - tableRows: 'table[class*="MuiTable-root-"] tbody tr', - /** @deprecated Use pagination role-based selector */ - tablePageSelectBox: 'div[class*="MuiTablePagination-input"]', - - // Semantic methods - preferred - /** - * Get next page button - * ✅ Already semantic, but wrapped for consistency - * @example BACKSTAGE_SHOWCASE_COMPONENTS.getNextPageButton(page).click() - */ - getNextPageButton: (page: Page): Locator => page.getByRole("button", { name: "Next Page" }), - - /** - * Get previous page button - * @example BACKSTAGE_SHOWCASE_COMPONENTS.getPreviousPageButton(page).click() - */ - getPreviousPageButton: (page: Page): Locator => - page.getByRole("button", { name: "Previous Page" }), - - /** - * Get last page button - * @example BACKSTAGE_SHOWCASE_COMPONENTS.getLastPageButton(page).click() - */ - getLastPageButton: (page: Page): Locator => page.getByRole("button", { name: "Last Page" }), - - /** - * Get first page button - * @example BACKSTAGE_SHOWCASE_COMPONENTS.getFirstPageButton(page).click() - */ - getFirstPageButton: (page: Page): Locator => page.getByRole("button", { name: "First Page" }), - - /** - * Get table rows - * ✅ Preferred over tableRows - * @example const rows = BACKSTAGE_SHOWCASE_COMPONENTS.getTableRows(page) - */ - getTableRows: (page: Page): Locator => SemanticSelectors.table(page).locator("tbody tr"), - - /** - * Get specific table row by content - * @example const row = BACKSTAGE_SHOWCASE_COMPONENTS.getTableRow(page, 'Guest User') - */ - getTableRow: (page: Page, text: string | RegExp): Locator => - SemanticSelectors.tableRow(page, text), -}; - -/** - * SETTINGS_PAGE_COMPONENTS - Settings page selectors - */ -export const SETTINGS_PAGE_COMPONENTS = { - // These are already using data-testid which is acceptable - userSettingsMenu: 'button[data-testid="user-settings-menu"]', - signOut: 'li[data-testid="sign-out"]', - - // Semantic methods - preferred - /** - * Get user settings menu button - * @example SETTINGS_PAGE_COMPONENTS.getUserSettingsMenu(page).click() - */ - getUserSettingsMenu: (page: Page): Locator => page.getByTestId("user-settings-menu"), - - /** - * Get sign out menu item - * @example SETTINGS_PAGE_COMPONENTS.getSignOut(page).click() - */ - getSignOut: (page: Page): Locator => page.getByTestId("sign-out"), -}; - -/** - * ROLES_PAGE_COMPONENTS - RBAC roles page selectors - */ -export const ROLES_PAGE_COMPONENTS = { - // These are already using data-testid which is acceptable - editRole: (name: string) => `button[data-testid="edit-role-${name}"]`, - deleteRole: (name: string) => `button[data-testid="delete-role-${name}"]`, - - // Semantic methods - preferred - /** - * Get edit role button - * @example ROLES_PAGE_COMPONENTS.getEditRoleButton(page, 'admin').click() - */ - getEditRoleButton: (page: Page, name: string): Locator => page.getByTestId(`edit-role-${name}`), - - /** - * Get delete role button - * @example ROLES_PAGE_COMPONENTS.getDeleteRoleButton(page, 'guest').click() - */ - getDeleteRoleButton: (page: Page, name: string): Locator => - page.getByTestId(`delete-role-${name}`), -}; - -/** - * DELETE_ROLE_COMPONENTS - Delete role dialog selectors - */ -export const DELETE_ROLE_COMPONENTS = { - // This selector is already semantic (using name attribute) - roleName: 'input[name="delete-role"]', - - // Semantic method - preferred - /** - * Get role name confirmation input - * @example DELETE_ROLE_COMPONENTS.getRoleNameInput(page).fill('role-name') - */ - getRoleNameInput: (page: Page): Locator => page.locator('input[name="delete-role"]'), -}; - -/** - * ROLE_OVERVIEW_COMPONENTS_TEST_ID - Role overview test IDs - */ -export const ROLE_OVERVIEW_COMPONENTS_TEST_ID = { - updatePolicies: "update-policies", - updateMembers: "update-members", - - // Semantic methods - preferred - /** - * Get update policies button - * @example ROLE_OVERVIEW_COMPONENTS_TEST_ID.getUpdatePoliciesButton(page).click() - */ - getUpdatePoliciesButton: (page: Page): Locator => page.getByTestId("update-policies"), - - /** - * Get update members button - * @example ROLE_OVERVIEW_COMPONENTS_TEST_ID.getUpdateMembersButton(page).click() - */ - getUpdateMembersButton: (page: Page): Locator => page.getByTestId("update-members"), -}; diff --git a/e2e-tests/playwright/support/page-objects/page.ts b/e2e-tests/playwright/support/page-objects/page.ts deleted file mode 100644 index 664afa6f71..0000000000 --- a/e2e-tests/playwright/support/page-objects/page.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Page } from "@playwright/test"; - -import { UIhelper } from "../../utils/ui-helper"; - -export enum PagesUrl { - RBAC = "/rbac", -} - -export abstract class PageObject { - protected page: Page; - protected url: PagesUrl; - protected uiHelper: UIhelper; - - constructor(page: Page, url: PagesUrl) { - this.page = page; - this.url = url; - this.uiHelper = new UIhelper(this.page); - } - - async goto() { - await this.page.goto(this.url); - } - - async verifyATextIsVisible(text: string) { - await this.uiHelper.verifyText(text); - } -} diff --git a/e2e-tests/playwright/support/pages/application-provider-test-page.ts b/e2e-tests/playwright/support/pages/application-provider-test-page.ts new file mode 100644 index 0000000000..d01604dea6 --- /dev/null +++ b/e2e-tests/playwright/support/pages/application-provider-test-page.ts @@ -0,0 +1,53 @@ +import { expect, Page } from "@playwright/test"; + +import { UIhelper } from "../../utils/ui-helper"; + +/** Application provider plugin test page interactions. */ +export class ApplicationProviderTestPage { + private readonly page: Page; + private readonly ui: UIhelper; + + constructor(page: Page) { + this.page = page; + this.ui = new UIhelper(page); + } + + async open(): Promise { + await this.ui.goToPageUrl("/application-provider-test-page"); + } + + async verifyTestPageContent(): Promise { + await this.ui.verifyText("application/provider TestPage"); + await this.ui.verifyText( + "This card will work only if you register the TestProviderOne and TestProviderTwo correctly.", + ); + } + + async verifyContextOneCard(): Promise { + await this.ui.verifyTextinCard("Context one", "Context one"); + } + + async verifyContextTwoCard(): Promise { + await this.ui.verifyTextinCard("Context two", "Context two"); + } + + private contextCards(contextLabel: string) { + /* oxlint-disable playwright/no-raw-locators -- per-card containers are nested divs inside one article */ + return this.page + .getByRole("main") + .getByRole("article") + .locator("> div > div") + .filter({ hasText: contextLabel }); + /* oxlint-enable playwright/no-raw-locators */ + } + + async incrementFirstCardCounter(contextLabel: string): Promise { + await this.contextCards(contextLabel).first().getByRole("button", { name: "+" }).click(); + } + + async verifySharedCardCount(contextLabel: string, count: string): Promise { + const cards = this.contextCards(contextLabel); + await expect(cards.first().getByRole("heading", { name: count })).toBeVisible(); + await expect(cards.last().getByRole("heading", { name: count })).toBeVisible(); + } +} diff --git a/e2e-tests/playwright/support/pages/catalog-browse-page.ts b/e2e-tests/playwright/support/pages/catalog-browse-page.ts new file mode 100644 index 0000000000..9fe27eb88e --- /dev/null +++ b/e2e-tests/playwright/support/pages/catalog-browse-page.ts @@ -0,0 +1,168 @@ +import { expect, Page } from "@playwright/test"; + +import * as interaction from "../../utils/ui-helper/interaction"; +import * as misc from "../../utils/ui-helper/misc"; +import * as navigation from "../../utils/ui-helper/navigation"; +import * as table from "../../utils/ui-helper/table"; +import * as verification from "../../utils/ui-helper/verification"; +import { SEARCH_OBJECTS_COMPONENTS } from "../selectors/page-selectors"; + +/** Catalog browse and entity list interactions. */ +export class CatalogBrowsePage { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + private async fillSearch(query: string): Promise { + await this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, query); + } + + async openCatalogSidebar(kind?: string): Promise { + if (kind !== undefined) { + await navigation.openCatalogSidebar(this.page, kind); + return; + } + await navigation.openSidebar(this.page, "Catalog"); + } + + async openSidebar(label: string): Promise { + await navigation.openSidebar(this.page, label); + } + + async selectKind(kind: string): Promise { + await navigation.selectMuiBox(this.page, "Kind", kind); + } + + async verifyComponentsInCatalog(kind: string, names: string[]): Promise { + await misc.verifyComponentInCatalog(this.page, kind, names); + } + + async verifyTableRows(rows: string[]): Promise { + await verification.verifyRowsInTable(this.page, rows); + } + + async searchCatalog(query: string): Promise { + await this.fillSearch(query); + } + + async verifyRowByUniqueText(text: string, columns: string[] | RegExp[]): Promise { + await table.verifyRowInTableByUniqueText(this.page, text, columns); + } + + async openEntityLink(name: string): Promise { + await interaction.clickLink(this.page, name); + } + + async openDependenciesTab(): Promise { + await interaction.clickTab(this.page, "Dependencies"); + } + + async clickButton(label: string): Promise { + await interaction.clickButton(this.page, label); + } + + async verifyHeading(heading: string | RegExp): Promise { + await verification.verifyHeading(this.page, heading); + } + + async verifyText(text: string | RegExp, exact = true): Promise { + await verification.verifyText(this.page, text, exact); + } + + async verifyColumnHeading(headings: string[], exact = true): Promise { + await verification.verifyColumnHeading(this.page, headings, exact); + } + + async clickTab(tabName: string): Promise { + await interaction.clickTab(this.page, tabName); + } + + async verifyLink( + label: string, + options?: { exact?: boolean; notVisible?: boolean }, + ): Promise { + await verification.verifyLink(this.page, label, options); + } + + async clickByDataTestId(dataTestId: string): Promise { + await interaction.clickByDataTestId(this.page, dataTestId); + } + + async openSelfServiceFromCatalog(): Promise { + await navigation.openSidebar(this.page, "Catalog"); + await interaction.clickButton(this.page, "Self-service"); + } + + async importGitRepositoryFromCatalog(): Promise { + await this.openSelfServiceFromCatalog(); + await interaction.clickButton(this.page, "Import an existing Git repository"); + } + + async verifyTextInSelector(selector: string, expectedText: string): Promise { + await verification.verifyTextInSelector(this.page, selector, expectedText); + } + + async verifyPartialTextInSelector(selector: string, partialText: string): Promise { + await verification.verifyPartialTextInSelector(this.page, selector, partialText); + } + + async openTemplateFromCatalog(templateName: string): Promise { + await navigation.openSidebar(this.page, "Catalog"); + await navigation.selectMuiBox(this.page, "Kind", "Template"); + await this.fillSearch(`${templateName}\n`); + await table.verifyRowInTableByUniqueText(this.page, templateName, [templateName]); + await interaction.clickLink(this.page, templateName); + } + + async clearSearchIfVisible(): Promise { + const clearButton = this.page.getByRole("button", { name: "clear search" }); + if (await clearButton.isVisible()) { + await expect(clearButton).toBeEnabled(); + await clearButton.click(); + } + } + + async sortCreatedAtDescending(): Promise { + await expect( + this.page.getByRole("row").filter({ has: this.page.getByRole("cell") }), + ).not.toHaveCount(0); + + const column = this.page.getByRole("columnheader", { + name: "Created At", + exact: true, + }); + await column.click(); + await column.click(); + } + + async verifyFirstRowCreatedAtNotEmpty(): Promise { + const firstRow = this.page + .getByRole("row") + .filter({ has: this.page.getByRole("cell") }) + .first(); + const createdAtCell = firstRow.getByRole("cell").nth(7); + await expect(createdAtCell).not.toBeEmpty(); + } + + async openEntityLinkByHref(hrefFragment: string): Promise { + const link = this.page.locator(`a[href*="${hrefFragment}"]`).first(); + await expect(link).toBeVisible(); + await link.click(); + } + + async verifyTableCell(text: string): Promise { + await expect(this.page.getByRole("cell", { name: text })).toBeVisible(); + } + + async openLicensedUsersCatalog(): Promise { + await this.page.goto("/catalog?filters%5Bkind%5D=user&filters%5Buser"); + } + + async verifyDependencyResource(resource: string): Promise { + const resourceElement = this.page.locator(`#workspace:has-text("${resource}")`); + await resourceElement.scrollIntoViewIfNeeded(); + await expect(resourceElement).toBeVisible(); + } +} diff --git a/e2e-tests/playwright/support/pages/catalog-import.ts b/e2e-tests/playwright/support/pages/catalog-import.ts index 228535037a..64b31abe06 100644 --- a/e2e-tests/playwright/support/pages/catalog-import.ts +++ b/e2e-tests/playwright/support/pages/catalog-import.ts @@ -2,7 +2,7 @@ import { Page, expect } from "@playwright/test"; import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; import { UIhelper } from "../../utils/ui-helper"; -import { CATALOG_IMPORT_COMPONENTS } from "../page-objects/page-obj"; +import { CATALOG_IMPORT_COMPONENTS } from "../selectors/page-selectors"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -85,4 +85,4 @@ export class CatalogImport { } } -export { BackstageShowcase } from "./backstage-showcase"; +export { RhdhInstance } from "./rhdh-instance"; diff --git a/e2e-tests/playwright/support/pages/catalog-users-page.ts b/e2e-tests/playwright/support/pages/catalog-users-page.ts new file mode 100644 index 0000000000..a29bfc43f8 --- /dev/null +++ b/e2e-tests/playwright/support/pages/catalog-users-page.ts @@ -0,0 +1,40 @@ +import { Page, Locator } from "@playwright/test"; + +export const CATALOG_USERS_BASE_URL = "/catalog?filters%5Bkind%5D=user&filters%5Buser"; + +/** Catalog users list and entity page interactions. */ +export class CatalogUsersPage { + static readonly BASE_URL = CATALOG_USERS_BASE_URL; + + constructor(private readonly page: Page) {} + + getListOfUsers(): Locator { + return this.page + .getByRole("table") + .first() + .getByRole("rowgroup") + .nth(1) + .getByRole("cell") + .getByRole("link"); + } + + getEmailLink(): Locator { + return this.page.getByRole("link", { name: /@/u }); + } + + async visitUserPage(username: string): Promise { + await this.page + .getByRole("table") + .getByRole("link", { name: new RegExp(username, "iu") }) + .first() + .click(); + } + + getGroupLink(groupName: string): Locator { + return this.page.getByRole("link", { name: new RegExp(groupName, "iu") }); + } + + async visitBaseURL(): Promise { + await this.page.goto(CatalogUsersPage.BASE_URL); + } +} diff --git a/e2e-tests/playwright/support/pages/home-page.ts b/e2e-tests/playwright/support/pages/home-page.ts index 9d04660f62..879d780b2f 100644 --- a/e2e-tests/playwright/support/pages/home-page.ts +++ b/e2e-tests/playwright/support/pages/home-page.ts @@ -1,23 +1,21 @@ import { Page, expect } from "@playwright/test"; -import { UIhelper } from "../../utils/ui-helper"; +import * as verification from "../../utils/ui-helper/verification"; /* oxlint-disable playwright/no-raw-locators -- MUI home page layout selectors */ -import { HOME_PAGE_COMPONENTS, SEARCH_OBJECTS_COMPONENTS } from "../page-objects/page-obj"; +import { HOME_PAGE_COMPONENTS, SEARCH_OBJECTS_COMPONENTS } from "../selectors/page-selectors"; export class HomePage { private page: Page; - private uiHelper: UIhelper; constructor(page: Page) { this.page = page; - this.uiHelper = new UIhelper(page); } async verifyQuickSearchBar(text: string) { const searchBar = SEARCH_OBJECTS_COMPONENTS.getSearchInput(this.page); await searchBar.waitFor(); await searchBar.fill(""); await searchBar.pressSequentially(`${text}\n`); - await this.uiHelper.verifyLink(text); + await verification.verifyLink(this.page, text); } async verifyQuickAccess(section: string, items: string | string[], expand = false) { diff --git a/e2e-tests/playwright/support/pages/rhdh-home-page.ts b/e2e-tests/playwright/support/pages/rhdh-home-page.ts new file mode 100644 index 0000000000..72fecff4ba --- /dev/null +++ b/e2e-tests/playwright/support/pages/rhdh-home-page.ts @@ -0,0 +1,42 @@ +import { expect, Page } from "@playwright/test"; + +import { UIhelper } from "../../utils/ui-helper"; + +/** RHDH instance home page interactions. */ +export class RhdhHomePage { + private readonly page: Page; + private readonly ui: UIhelper; + + constructor(page: Page) { + this.page = page; + this.ui = new UIhelper(page); + } + + async verifyWelcomeHeading(): Promise { + await this.ui.verifyHeading("Welcome back!"); + } + + async openHomeSidebar(): Promise { + await this.ui.openSidebar("Home"); + } + + async verifyTextInCard(cardHeading: string, text: string | RegExp, exact = true): Promise { + await this.ui.verifyTextinCard(cardHeading, text, exact); + } + + async verifyHeading(heading: string | RegExp): Promise { + await this.ui.verifyHeading(heading); + } + + async verifyDivHasText(text: string | RegExp): Promise { + await this.ui.verifyDivHasText(text); + } + + async clickButton(label: string): Promise { + await this.ui.clickButton(label); + } + + async verifyMainHeadingVisible(): Promise { + await expect(this.page.getByRole("heading", { level: 1 })).toBeVisible(); + } +} diff --git a/e2e-tests/playwright/support/pages/backstage-showcase.ts b/e2e-tests/playwright/support/pages/rhdh-instance.ts similarity index 55% rename from e2e-tests/playwright/support/pages/backstage-showcase.ts rename to e2e-tests/playwright/support/pages/rhdh-instance.ts index 7a047dc571..c8cb05390f 100644 --- a/e2e-tests/playwright/support/pages/backstage-showcase.ts +++ b/e2e-tests/playwright/support/pages/rhdh-instance.ts @@ -2,9 +2,10 @@ import { Page, expect } from "@playwright/test"; import { APIHelper } from "../../utils/api-helper"; import { UIhelper } from "../../utils/ui-helper"; -import { BACKSTAGE_SHOWCASE_COMPONENTS } from "../page-objects/page-obj"; +import { RHDH_INSTANCE_TABLE } from "../selectors/rhdh-instance-table"; -export class BackstageShowcase { +/** Page object for RHDH instance catalog views (PR tables, entity cards). */ +export class RhdhInstance { private readonly page: Page; private uiHelper: UIhelper; @@ -13,20 +14,20 @@ export class BackstageShowcase { this.uiHelper = new UIhelper(page); } - static getShowcasePRs(state: "open" | "closed" | "all", paginated = false) { + static getRhdhPullRequests(state: "open" | "closed" | "all", paginated = false) { return APIHelper.getGitHubPRs("redhat-developer", "rhdh", state, paginated); } async clickNextPage() { - await BACKSTAGE_SHOWCASE_COMPONENTS.getNextPageButton(this.page).click(); + await RHDH_INSTANCE_TABLE.getNextPageButton(this.page).click(); } async clickPreviousPage() { - await BACKSTAGE_SHOWCASE_COMPONENTS.getPreviousPageButton(this.page).click(); + await RHDH_INSTANCE_TABLE.getPreviousPageButton(this.page).click(); } async clickLastPage() { - await BACKSTAGE_SHOWCASE_COMPONENTS.getLastPageButton(this.page).click(); + await RHDH_INSTANCE_TABLE.getLastPageButton(this.page).click(); } async verifyPRRowsPerPage(rows: number, allPRs: { title: string; number: string }[]) { @@ -37,7 +38,7 @@ export class BackstageShowcase { notVisible: true, }); - const tableRows = BACKSTAGE_SHOWCASE_COMPONENTS.getTableRows(this.page); + const tableRows = RHDH_INSTANCE_TABLE.getTableRows(this.page); await expect(tableRows).toHaveCount(rows); } @@ -61,4 +62,25 @@ export class BackstageShowcase { await this.uiHelper.verifyRowsInTable([allPRs[i].title], false); } } + + async waitForEntityPath(path: string): Promise { + await this.page.waitForURL(`**${path}`, { + waitUntil: "domcontentloaded", + timeout: 20_000, + }); + expect(this.page.url()).toContain(path); + } + + /** Workaround for RHDHBUGS-2091: smaller page size avoids missing PR stats. */ + async setPullRequestPageSize(size: number): Promise { + await this.page.getByRole("button", { name: "20" }).click(); + await this.page.getByRole("option", { name: String(size), exact: true }).click(); + } + + async clickPullRequestFilter(name: string): Promise { + const button = this.page.getByRole("button", { name }); + await expect(button).toBeVisible(); + await expect(button).toBeEnabled(); + await button.click(); + } } diff --git a/e2e-tests/playwright/support/pages/scaffolder-flow-page.ts b/e2e-tests/playwright/support/pages/scaffolder-flow-page.ts new file mode 100644 index 0000000000..da6bceef40 --- /dev/null +++ b/e2e-tests/playwright/support/pages/scaffolder-flow-page.ts @@ -0,0 +1,181 @@ +import { expect, Page } from "@playwright/test"; + +import * as interaction from "../../utils/ui-helper/interaction"; +import * as navigation from "../../utils/ui-helper/navigation"; +import * as table from "../../utils/ui-helper/table"; +import * as verification from "../../utils/ui-helper/verification"; +import { SEARCH_OBJECTS_COMPONENTS } from "../selectors/page-selectors"; + +export type ReactAppTemplateDetails = { + componentName: string; + description: string; + owner: string; + label: string; + annotation: string; + repoOwner: string; + repo: string; +}; + +/** Scaffolder and self-service template flows. */ +export class ScaffolderFlowPage { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + private async fillSearch(query: string): Promise { + await this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, query); + } + + async openImportGitRepository(): Promise { + await navigation.openSidebar(this.page, "Catalog"); + await interaction.clickButton(this.page, "Self-service"); + await interaction.clickButton(this.page, "Import an existing Git repository"); + } + + async openSelfServiceFromCatalog(): Promise { + await navigation.openSidebar(this.page, "Catalog"); + await interaction.clickButton(this.page, "Self-service"); + } + + async verifySelfServiceHeading(): Promise { + await verification.verifyHeading(this.page, "Self-service"); + } + + async clickImportGitRepository(): Promise { + await interaction.clickButton(this.page, "Import an existing Git repository"); + } + + async runCreateReactAppTemplate(details: ReactAppTemplateDetails): Promise { + await navigation.openSidebar(this.page, "Catalog"); + await interaction.clickButton(this.page, "Self-service"); + await verification.verifyHeading(this.page, "Self-service"); + await this.fillSearch("Create React App Template"); + await verification.verifyText(this.page, "Create React App Template"); + await verification.waitForTextDisappear(this.page, "Add ArgoCD to an existing project"); + await interaction.clickButton(this.page, "Choose"); + + await interaction.fillTextInputByLabel(this.page, "Name", details.componentName); + await interaction.fillTextInputByLabel(this.page, "Description", details.description); + await interaction.fillTextInputByLabel(this.page, "Owner", details.owner); + await interaction.fillTextInputByLabel(this.page, "Label", details.label); + await interaction.fillTextInputByLabel(this.page, "Annotation", details.annotation); + await interaction.clickButton(this.page, "Next"); + + await interaction.fillTextInputByLabel(this.page, "Owner", details.repoOwner); + await interaction.fillTextInputByLabel(this.page, "Repository", details.repo); + await interaction.pressTab(this.page); + await interaction.clickButton(this.page, "Review"); + } + + async fillCreateReactAppTemplateForm(details: ReactAppTemplateDetails): Promise { + await this.fillSearch("Create React App Template"); + await verification.verifyText(this.page, "Create React App Template"); + await verification.waitForTextDisappear(this.page, "Add ArgoCD to an existing project"); + await interaction.clickButton(this.page, "Choose"); + + await interaction.fillTextInputByLabel(this.page, "Name", details.componentName); + await interaction.fillTextInputByLabel(this.page, "Description", details.description); + await interaction.fillTextInputByLabel(this.page, "Owner", details.owner); + await interaction.fillTextInputByLabel(this.page, "Label", details.label); + await interaction.fillTextInputByLabel(this.page, "Annotation", details.annotation); + await interaction.clickButton(this.page, "Next"); + + await interaction.fillTextInputByLabel(this.page, "Owner", details.repoOwner); + await interaction.fillTextInputByLabel(this.page, "Repository", details.repo); + await interaction.pressTab(this.page); + await interaction.clickButton(this.page, "Review"); + } + + async verifyCreateReactAppReviewTable(details: ReactAppTemplateDetails): Promise { + await table.verifyRowInTableByUniqueText(this.page, "Owner", [details.owner]); + await table.verifyRowInTableByUniqueText(this.page, "Name", [details.componentName]); + await table.verifyRowInTableByUniqueText(this.page, "Description", [details.description]); + await table.verifyRowInTableByUniqueText(this.page, "Label", [details.label]); + await table.verifyRowInTableByUniqueText(this.page, "Annotation", [details.annotation]); + await table.verifyRowInTableByUniqueText(this.page, "Repository Location", [ + `${details.repoOwner}/${details.repo}`, + ]); + } + + async verifyCreateReactAppReviewTableWithGroupOwner( + details: ReactAppTemplateDetails, + ): Promise { + await table.verifyRowInTableByUniqueText(this.page, "Owner", [`group:${details.owner}`]); + await table.verifyRowInTableByUniqueText(this.page, "Name", [details.componentName]); + await table.verifyRowInTableByUniqueText(this.page, "Description", [details.description]); + await table.verifyRowInTableByUniqueText(this.page, "Label", [details.label]); + await table.verifyRowInTableByUniqueText(this.page, "Annotation", [details.annotation]); + await table.verifyRowInTableByUniqueText(this.page, "Repository Location", [ + `github.com?owner=${details.repoOwner}&repo=${details.repo}`, + ]); + } + + async createAndOpenInCatalog(): Promise { + await interaction.clickButton(this.page, "Create"); + await interaction.clickLink(this.page, "Open in catalog"); + } + + async clickCreate(): Promise { + await interaction.clickButton(this.page, "Create"); + } + + async clickOpenInCatalog(): Promise { + await interaction.clickLink(this.page, "Open in catalog"); + } + + async waitForOpenInCatalogLink(timeout = 60_000): Promise { + await expect(this.page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ timeout }); + } + + async verifyComponentNameVisible(name: string, timeout = 20_000): Promise { + await expect(this.page.getByText(name)).toBeVisible({ timeout }); + } + + async openTemplateFromCatalog(templateName: string, kindColumn = templateName): Promise { + await navigation.openSidebar(this.page, "Catalog"); + await navigation.selectMuiBox(this.page, "Kind", "Template"); + await this.fillSearch(`${templateName}\n`); + await table.verifyRowInTableByUniqueText(this.page, templateName, [kindColumn]); + await interaction.clickLink(this.page, templateName); + } + + async launchTemplateAndVerifyIntro(): Promise { + await interaction.clickLink(this.page, "Launch Template"); + await verification.verifyText(this.page, "Provide some simple information"); + } + + async openComponentInCatalog( + componentName: string, + kindColumn: string | string[] = "website", + ): Promise { + await navigation.openCatalogSidebar(this.page, "Component"); + await this.fillSearch(componentName); + const columns = Array.isArray(kindColumn) ? kindColumn : [kindColumn]; + await table.verifyRowInTableByUniqueText(this.page, componentName, columns); + await interaction.clickLink(this.page, componentName); + } + + async verifyDependencyGraphLabels( + labelSelector: string, + nodeSelector: string, + relationLabel: string, + nodePartialText: string, + ): Promise { + await verification.verifyTextInSelector(this.page, labelSelector, relationLabel); + await verification.verifyPartialTextInSelector(this.page, nodeSelector, nodePartialText); + } + + async runHttpRequestTemplateFlow(): Promise { + await navigation.openSidebar(this.page, "Catalog"); + await navigation.selectMuiBox(this.page, "Kind", "Template"); + await this.fillSearch("Test HTTP Request"); + await interaction.clickLink(this.page, "Test HTTP Request"); + await verification.verifyHeading(this.page, "Test HTTP Request"); + await interaction.clickLink(this.page, "Launch Template"); + await verification.verifyHeading(this.page, "Self-service"); + await interaction.clickButton(this.page, "Create"); + await verification.verifyText(this.page, "200", false); + } +} diff --git a/e2e-tests/playwright/support/pages/self-service-page.ts b/e2e-tests/playwright/support/pages/self-service-page.ts new file mode 100644 index 0000000000..9fc87e489c --- /dev/null +++ b/e2e-tests/playwright/support/pages/self-service-page.ts @@ -0,0 +1,52 @@ +import { Page } from "@playwright/test"; + +import { UIhelper } from "../../utils/ui-helper"; + +/** Self-service / scaffolder template list interactions. */ +export class SelfServicePage { + private readonly ui: UIhelper; + + constructor(page: Page) { + this.ui = new UIhelper(page); + } + + async open(): Promise { + await this.ui.goToSelfServicePage(); + } + + async verifyTemplatesHeading(): Promise { + await this.ui.verifyHeading("Templates"); + } + + async clickImportGitRepository(): Promise { + await this.ui.clickButton("Import an existing Git repository"); + } + + async clickImportGitRepositoryLocalized(buttonTitle: string): Promise { + await this.ui.clickButton(buttonTitle); + } + + async waitForTemplateTitle(template: string, level = 4): Promise { + await this.ui.waitForTitle(template, level); + } + + async verifyHeading(heading: string): Promise { + await this.ui.verifyHeading(heading); + } + + async clickButton(label: string): Promise { + await this.ui.clickButton(label); + } + + async searchTemplate(name: string): Promise { + await this.ui.searchInputPlaceholder(name); + } + + async verifyTemplateHeading(template: string): Promise { + await this.ui.verifyHeading(template); + } + + async verifyText(text: string, exact = true): Promise { + await this.ui.verifyText(text, exact); + } +} diff --git a/e2e-tests/playwright/support/pages/settings-page.ts b/e2e-tests/playwright/support/pages/settings-page.ts new file mode 100644 index 0000000000..68a5e23ff2 --- /dev/null +++ b/e2e-tests/playwright/support/pages/settings-page.ts @@ -0,0 +1,215 @@ +import { expect, Page } from "@playwright/test"; + +import { getCurrentLanguage, getTranslations } from "../../e2e/localization/locale"; +import * as interaction from "../../utils/ui-helper/interaction"; +import * as misc from "../../utils/ui-helper/misc"; +import * as navigation from "../../utils/ui-helper/navigation"; +import * as verification from "../../utils/ui-helper/verification"; +import { SETTINGS_PAGE_COMPONENTS } from "../selectors/page-selectors"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +const LANGUAGE_OPTIONS_PATTERN = /English|Deutsch|Español|Français|Italiano|日本語/u; + +/** Settings and profile interactions. */ +export class SettingsPage { + private readonly page: Page; + + constructor(page: Page) { + this.page = page; + } + + async open(): Promise { + await navigation.goToSettingsPage(this.page); + } + + async verifyProfileHeading(name: string): Promise { + await verification.verifyHeading(this.page, name); + } + + async verifyGithubUserProfile(userId: string): Promise { + await verification.verifyHeading(this.page, userId); + await verification.verifyHeading(this.page, `User Entity: ${userId}`); + } + + async verifySignInButtonVisible(): Promise { + await expect(this.page.getByRole("button", { name: "Sign In" })).toBeVisible(); + } + + async verifyGuestProfile(): Promise { + await this.verifyProfileHeading("Guest"); + await verification.verifyHeading(this.page, "User Entity: guest"); + } + + async verifySignInPageTitle(): Promise { + await verification.verifyHeading(this.page, t["rhdh"][lang]["signIn.page.title"]); + } + + async verifySignInError(message: string | RegExp): Promise { + await verification.verifyAlertErrorMessage(this.page, message); + } + + async hideQuickstartIfVisible(): Promise { + await misc.hideQuickstartIfVisible(this.page); + } + + async verifyText(text: string | RegExp, exact = true): Promise { + await verification.verifyText(this.page, text, exact); + } + + async goToPageUrl(url: string, heading?: string): Promise { + await navigation.goToPageUrl(this.page, url, heading); + } + + async verifyTextVisible(text: string, exact = false, timeout = 10000): Promise { + await verification.verifyTextVisible(this.page, text, exact, timeout); + } + + async clickButtonByText( + buttonText: string | RegExp, + options?: { exact?: boolean; timeout?: number; force?: boolean }, + ): Promise { + await interaction.clickButtonByText(this.page, buttonText, options); + } + + async uncheckCheckbox(label: string): Promise { + await interaction.uncheckCheckbox(this.page, label); + } + + async checkCheckbox(label: string): Promise { + await interaction.checkCheckbox(this.page, label); + } + + async verifyLocalizedUserSettingsLabels( + locale: keyof (typeof t)["user-settings"], + ): Promise { + const labels = t["user-settings"][locale]; + await verification.verifyText(this.page, labels["profileCard.title"]); + await verification.verifyText(this.page, labels["appearanceCard.title"]); + await verification.verifyText(this.page, labels["themeToggle.title"]); + await verification.verifyText(this.page, labels["signOutMenu.title"]); + await verification.verifyText(this.page, labels["identityCard.title"]); + await verification.verifyText(this.page, `${labels["identityCard.userEntity"]}: Guest User`); + await verification.verifyText( + this.page, + `${labels["identityCard.ownershipEntities"]}: ownershipEntities`, + ); + await verification.verifyText(this.page, labels["pinToggle.title"]); + await verification.verifyText(this.page, labels["pinToggle.description"]); + } + + async verifyLocalizedUserSettingsLabelsWithOwnership( + locale: keyof (typeof t)["user-settings"], + ownershipEntities: string, + ): Promise { + const labels = t["user-settings"][locale]; + await verification.verifyText(this.page, labels["profileCard.title"]); + await verification.verifyText(this.page, labels["appearanceCard.title"]); + await verification.verifyText(this.page, labels["themeToggle.title"]); + await verification.verifyText(this.page, labels["identityCard.title"]); + await verification.verifyText(this.page, `${labels["identityCard.userEntity"]}: Guest User`); + await verification.verifyText( + this.page, + `${labels["identityCard.ownershipEntities"]}: ${ownershipEntities}`, + ); + await verification.verifyText(this.page, labels["pinToggle.title"]); + await verification.verifyText(this.page, labels["pinToggle.description"]); + } + + async togglePinSidebar(locale: keyof (typeof t)["user-settings"]): Promise { + const labels = t["user-settings"][locale]; + await interaction.uncheckCheckbox(this.page, labels["pinToggle.ariaLabelTitle"]); + await interaction.checkCheckbox(this.page, labels["pinToggle.ariaLabelTitle"]); + } + + async verifyLanguageToggleList(locale: keyof (typeof t)["user-settings"]): Promise { + const labels = t["user-settings"][locale]; + await expect(this.page.getByRole("list").first()).toMatchAriaSnapshot(` + - listitem: + - text: ${labels["languageToggle.title"]} + - paragraph: ${labels["languageToggle.description"]} + `); + } + + async verifyLanguageSelectShowsOptions(): Promise { + await expect(this.page.getByTestId("select")).toContainText(LANGUAGE_OPTIONS_PATTERN); + } + + async openLanguageSelect(): Promise { + await this.page + .getByTestId("select") + .getByRole("button", { name: LANGUAGE_OPTIONS_PATTERN }) + .click(); + } + + async verifyLanguageOptionsList(): Promise { + await expect(this.page.getByRole("listbox")).toMatchAriaSnapshot(` + - listbox: + - option "English" + - option "Deutsch" + - option "Español" + - option "Français" + - option "Italiano" + - option "日本語" + `); + } + + async selectLanguage(language: string): Promise { + await this.page.getByRole("option", { name: language }).click(); + } + + async verifySelectedLanguage(language: string): Promise { + await expect(this.page.getByTestId("select")).toContainText(language); + } + + async openUserSettingsMenu(): Promise { + await SETTINGS_PAGE_COMPONENTS.getUserSettingsMenu(this.page).click(); + } + + async verifySignOutMenuLabel(text: string): Promise { + await expect(SETTINGS_PAGE_COMPONENTS.getSignOut(this.page)).toContainText(text); + } + + async closeUserSettingsMenu(): Promise { + await this.page.keyboard.press("Escape"); + } + + async verifySidebarMenuItemHidden(text: string): Promise { + await expect(this.page.getByText(text)).toBeHidden(); + } + + async openFromProfile(userName: string): Promise { + await this.page.getByText(userName).click(); + await this.page.getByRole("menuitem", { name: "Settings" }).click(); + } + + async verifyBuildInfoCardVisible(): Promise { + await expect(this.page.getByText("RHDH Build info")).toBeVisible(); + } + + async verifyBuildInfoText(text: string): Promise { + await expect(this.page.getByText(text)).toBeVisible(); + } + + async expandShowMoreSection(): Promise { + await this.page.getByTitle("Show more").click(); + } + + async verifyGuestSignInMethodNotListed(): Promise { + const signInMethodsContainer = this.page.getByRole("main"); + const signInMethods = await signInMethodsContainer + .getByRole("heading", { level: 6 }) + .allInnerTexts(); + expect(signInMethods).not.toContain("Guest"); + } + + async verifyInactivityLogoutMessageHidden(timeout = 30_000): Promise { + await expect(this.page.getByText("Logging out due to inactivity")).toBeHidden({ timeout }); + } + + async verifyRhdhMetadata(): Promise { + await this.expandShowMoreSection(); + await verification.verifyText(this.page, "RHDH Metadata"); + } +} diff --git a/e2e-tests/playwright/support/pages/sidebar-page.ts b/e2e-tests/playwright/support/pages/sidebar-page.ts new file mode 100644 index 0000000000..0e5a792571 --- /dev/null +++ b/e2e-tests/playwright/support/pages/sidebar-page.ts @@ -0,0 +1,65 @@ +import { expect, Page } from "@playwright/test"; + +import { getCurrentLanguage, getTranslations } from "../../e2e/localization/locale"; +import { UIhelper } from "../../utils/ui-helper"; +import { expectSidebarLinkVisible } from "../../utils/ui-helper/navigation"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +/** Sidebar navigation on the RHDH instance. */ +export class SidebarPage { + private readonly page: Page; + private readonly ui: UIhelper; + + constructor(page: Page) { + this.page = page; + this.ui = new UIhelper(page); + } + + getSideBarMenuItem(name: string) { + return this.ui.getSideBarMenuItem(name); + } + + async openSidebar(label: string): Promise { + await this.ui.openSidebar(label); + } + + async openSidebarButton(label: string): Promise { + await this.ui.openSidebarButton(label); + } + + async openReferencesLearningPaths(): Promise { + await this.ui.openSidebarLinkInSection("References", "Learning Paths"); + } + + async openFavoritesDocs(): Promise { + await this.ui.openSidebarLinkInSection("Favorites", t["rhdh"][lang]["menuItem.docs"]); + } + + async verifyDocumentationHeading(): Promise { + await this.ui.verifyHeading("Documentation"); + } + + async verifyText(text: string | RegExp, exact = true): Promise { + await this.ui.verifyText(text, exact); + } + + async verifyLinkHidden(name: string): Promise { + await expect(this.page.getByRole("link", { name })).toBeHidden(); + } + + async verifyMenuItemInSection(section: string, itemText: string): Promise { + await expectSidebarLinkVisible(this.page, itemText, section); + } + + async verifyLearningPathLinksOpenInNewTab(): Promise { + const learningPathLinks = this.page.getByRole("main").getByRole("link"); + + for (const learningPathLink of await learningPathLinks.all()) { + await expect(learningPathLink).toBeVisible(); + await expect(learningPathLink).toHaveAttribute("target", "_blank"); + await expect(learningPathLink).not.toHaveAttribute("href", ""); + } + } +} diff --git a/e2e-tests/playwright/support/pages/techdocs-page.ts b/e2e-tests/playwright/support/pages/techdocs-page.ts new file mode 100644 index 0000000000..c0eda56a28 --- /dev/null +++ b/e2e-tests/playwright/support/pages/techdocs-page.ts @@ -0,0 +1,24 @@ +import { Page } from "@playwright/test"; + +import { UIhelper } from "../../utils/ui-helper"; +import { SidebarPage } from "./sidebar-page"; + +/** TechDocs navigation and content verification. */ +export class TechDocsPage { + private readonly ui: UIhelper; + private readonly sidebar: SidebarPage; + + constructor(page: Page) { + this.ui = new UIhelper(page); + this.sidebar = new SidebarPage(page); + } + + async openDocFromFavorites(docName: string): Promise { + await this.sidebar.openFavoritesDocs(); + await this.ui.clickLink(docName); + } + + async verifyDocHeading(heading: string): Promise { + await this.ui.verifyHeading(heading); + } +} diff --git a/e2e-tests/playwright/support/selectors/page-selectors.ts b/e2e-tests/playwright/support/selectors/page-selectors.ts new file mode 100644 index 0000000000..4f84d82b6e --- /dev/null +++ b/e2e-tests/playwright/support/selectors/page-selectors.ts @@ -0,0 +1,115 @@ +/* oxlint-disable playwright/no-raw-locators -- Legacy CSS selector constants; prefer SemanticSelectors get*() methods */ +import { Page, Locator } from "@playwright/test"; + +import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; +import { SemanticSelectors } from "./semantic"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +/** Home page element selectors. */ +export const HOME_PAGE_COMPONENTS = { + /** @deprecated Use SemanticSelectors.region() with appropriate filter */ + MuiAccordion: 'div[class*="MuiAccordion-root-"]', + /** @deprecated Use SemanticSelectors.region() or article with appropriate filter */ + MuiCard: 'div[class*="MuiCard-root-"]', + + getAccordion: (page: Page, heading: string | RegExp): Locator => + page + .getByRole("button", { name: heading, expanded: false }) + .or(page.getByRole("button", { name: heading, expanded: true })), + + getCard: (page: Page, headingOrText: string | RegExp): Locator => + page + .locator('[role="region"], article, section') + .filter({ + hasText: headingOrText, + }) + .first(), +}; + +/** Search input selectors. */ +export const SEARCH_OBJECTS_COMPONENTS = { + ariaLabelSearch: `input[aria-label="${t["search-react"][lang]["searchBar.title"]}"]`, + placeholderSearch: `input[placeholder="${t["search-react"][lang]["searchBar.title"]}"]`, + + getSearchInput: (page: Page): Locator => { + const searchTitle = t["search-react"][lang]["searchBar.title"]; + return page.getByLabel(searchTitle).or(page.getByPlaceholder(searchTitle)); + }, +}; + +/** Catalog import selectors. */ +export const CATALOG_IMPORT_COMPONENTS = { + componentURL: 'input[name="url"]', + + getURLInput: (page: Page): Locator => page.locator('input[name="url"]'), +}; + +/** Kubernetes plugin selectors. */ +export const KUBERNETES_COMPONENTS = { + /** @deprecated Use getClusterAccordion() method */ + MuiAccordion: 'div[class*="MuiAccordion-root-"]', + statusOk: 'span[aria-label="Status ok"]', + podLogs: 'label[aria-label="get logs"]', + /** @deprecated Use SemanticSelectors.alert() */ + MuiSnackbarContent: 'div[class*="MuiSnackbarContent-message-"]', + + getClusterAccordion: (page: Page, clusterName?: string | RegExp): Locator => { + if (clusterName !== undefined) { + return page + .getByRole("button", { name: clusterName, expanded: false }) + .or(page.getByRole("button", { name: clusterName, expanded: true })); + } + return page + .getByRole("button", { expanded: false }) + .or(page.getByRole("button", { expanded: true })) + .first(); + }, + + getStatus: (page: Page, status: string): Locator => + page.locator(`span[aria-label="Status ${status}"]`), + + getPodLogsButton: (page: Page): Locator => page.locator('label[aria-label="get logs"]'), + + getNotification: (page: Page, message?: string | RegExp): Locator => + message === undefined ? SemanticSelectors.alert(page) : SemanticSelectors.alert(page, message), +}; + +/** Settings page selectors. */ +export const SETTINGS_PAGE_COMPONENTS = { + userSettingsMenu: 'button[data-testid="user-settings-menu"]', + signOut: 'li[data-testid="sign-out"]', + + getUserSettingsMenu: (page: Page): Locator => page.getByTestId("user-settings-menu"), + + getSignOut: (page: Page): Locator => page.getByTestId("sign-out"), +}; + +/** RBAC roles page selectors. */ +export const ROLES_PAGE_COMPONENTS = { + editRole: (name: string) => `button[data-testid="edit-role-${name}"]`, + deleteRole: (name: string) => `button[data-testid="delete-role-${name}"]`, + + getEditRoleButton: (page: Page, name: string): Locator => page.getByTestId(`edit-role-${name}`), + + getDeleteRoleButton: (page: Page, name: string): Locator => + page.getByTestId(`delete-role-${name}`), +}; + +/** Delete role dialog selectors. */ +export const DELETE_ROLE_COMPONENTS = { + roleName: 'input[name="delete-role"]', + + getRoleNameInput: (page: Page): Locator => page.locator('input[name="delete-role"]'), +}; + +/** Role overview test IDs. */ +export const ROLE_OVERVIEW_COMPONENTS_TEST_ID = { + updatePolicies: "update-policies", + updateMembers: "update-members", + + getUpdatePoliciesButton: (page: Page): Locator => page.getByTestId("update-policies"), + + getUpdateMembersButton: (page: Page): Locator => page.getByTestId("update-members"), +}; diff --git a/e2e-tests/playwright/support/selectors/rhdh-instance-table.ts b/e2e-tests/playwright/support/selectors/rhdh-instance-table.ts new file mode 100644 index 0000000000..0e7732e1e8 --- /dev/null +++ b/e2e-tests/playwright/support/selectors/rhdh-instance-table.ts @@ -0,0 +1,18 @@ +import { Page } from "@playwright/test"; + +import { SemanticSelectors } from "./semantic"; + +/** Table pagination helpers for RHDH instance catalog entity pages. */ +export const RHDH_INSTANCE_TABLE = { + getNextPageButton: (page: Page) => page.getByRole("button", { name: "Next Page" }), + + getPreviousPageButton: (page: Page) => page.getByRole("button", { name: "Previous Page" }), + + getLastPageButton: (page: Page) => page.getByRole("button", { name: "Last Page" }), + + getFirstPageButton: (page: Page) => page.getByRole("button", { name: "First Page" }), + + getTableRows: (page: Page) => SemanticSelectors.table(page).getByRole("row"), + + getTableRow: (page: Page, text: string | RegExp) => SemanticSelectors.tableRow(page, text), +}; diff --git a/e2e-tests/playwright/support/page-objects/ui-locators.ts b/e2e-tests/playwright/support/selectors/ui-locators.ts similarity index 71% rename from e2e-tests/playwright/support/page-objects/ui-locators.ts rename to e2e-tests/playwright/support/selectors/ui-locators.ts index d9e886ce5b..c339c07759 100644 --- a/e2e-tests/playwright/support/page-objects/ui-locators.ts +++ b/e2e-tests/playwright/support/selectors/ui-locators.ts @@ -1,13 +1,20 @@ /* oxlint-disable playwright/no-raw-locators -- legacy card/table region selectors pending SemanticSelectors migration */ import { Locator, Page } from "@playwright/test"; -import { SemanticSelectors } from "../selectors/semantic"; -import { UI_HELPER_ELEMENTS } from "./global-obj"; +import { SemanticSelectors } from "./semantic"; + +const legacyCardByHeading = (cardHeading: string) => + `//div[contains(@class,'MuiCardHeader-root') and descendant::*[text()='${cardHeading}']]/..`; + +const legacyCardByText = (cardText: string) => + `//div[contains(@class,'MuiCard-root')][descendant::text()[contains(., '${cardText}')]]`; + +const legacyRowByText = (text: string) => `tr:has(:text-is("${text}"))`; export function getCardByHeading(page: Page, heading: string | RegExp): Locator { if (typeof heading === "string") { /* oxlint-disable-next-line typescript/no-deprecated -- MUI cards lack region/heading roles; XPath matches production DOM */ - return page.locator(UI_HELPER_ELEMENTS.MuiCard(heading)); + return page.locator(legacyCardByHeading(heading)); } return page .locator('[role="region"], article, section') @@ -20,7 +27,7 @@ export function getCardByHeading(page: Page, heading: string | RegExp): Locator export function getCardByText(page: Page, text: string | RegExp): Locator { if (typeof text === "string") { /* oxlint-disable-next-line typescript/no-deprecated -- MUI cards lack region roles; XPath matches production DOM */ - return page.locator(UI_HELPER_ELEMENTS.MuiCardRoot(text)); + return page.locator(legacyCardByText(text)); } return page .locator('[role="region"], article, section') @@ -39,7 +46,7 @@ export function getTableRow(page: Page, text?: string | RegExp): Locator { } if (typeof text === "string") { /* oxlint-disable-next-line typescript/no-deprecated -- :text-is() avoids ambiguous hasText row matches in review tables */ - return page.locator(UI_HELPER_ELEMENTS.rowByText(text)); + return page.locator(legacyRowByText(text)); } return SemanticSelectors.tableRow(page, text); } diff --git a/e2e-tests/playwright/support/test-data/resources.ts b/e2e-tests/playwright/support/test-data/resources.ts index b3600a4288..0945b64fc7 100644 --- a/e2e-tests/playwright/support/test-data/resources.ts +++ b/e2e-tests/playwright/support/test-data/resources.ts @@ -4,7 +4,7 @@ export const RESOURCES = [ // "Janus-IDP", "Red Hat Developer Hub", "ArgoCD", - "GitHub Showcase repository", + "RHDH GitHub catalog", "KeyCloak", "S3 Object bucket storage", "PostgreSQL cluster", diff --git a/e2e-tests/playwright/utils/accessibility.ts b/e2e-tests/playwright/utils/accessibility.ts index 0388f20c03..ccfb6deeaa 100644 --- a/e2e-tests/playwright/utils/accessibility.ts +++ b/e2e-tests/playwright/utils/accessibility.ts @@ -6,16 +6,42 @@ export async function runAccessibilityTests( testInfo: TestInfo, attachName = "accessibility-scan-results.violations.json", ) { + // Let Backstage loading indicators finish before scanning the page shell. + await page + .locator('[role="progressbar"]') + .first() + .waitFor({ state: "hidden", timeout: 60_000 }) + .catch(() => {}); + // Type mismatch between Playwright's Page and AxeBuilder's expected type // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- @axe-core/playwright Page type differs from @playwright/test const accessibilityScanResults = await new AxeBuilder({ page } as unknown as { page: typeof page; }) .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) - .disableRules(["color-contrast"]) + .disableRules([ + "color-contrast", + // Known global shell violations tracked under RHDHPLAN-954. + "aria-progressbar-name", + "list", + "nested-interactive", + ]) .analyze(); await testInfo.attach(attachName, { body: JSON.stringify(accessibilityScanResults.violations, null, 2), contentType: "application/json", }); + + const criticalOrSeriousViolations = accessibilityScanResults.violations.filter( + (violation) => violation.impact === "critical" || violation.impact === "serious", + ); + + if (criticalOrSeriousViolations.length > 0) { + const summary = criticalOrSeriousViolations + .map((violation) => `${violation.id} (${violation.impact})`) + .join(", "); + throw new Error( + `Accessibility scan found ${criticalOrSeriousViolations.length} critical/serious violation(s): ${summary}`, + ); + } } diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts index 238ecea354..5c4d480878 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/k8s.ts @@ -7,6 +7,7 @@ import { expect } from "@playwright/test"; import * as yaml from "yaml"; import { hasErrorResponse } from "../../errors"; +import { sleep } from "../../poll-until"; import { BackstageCr, currentDirName, @@ -359,14 +360,13 @@ export async function killRunningProcess( console.log("Local production server process killed?", killed); await new Promise((resolvePromise) => { - state.runningProcess?.on("exit", () => { - setTimeout(() => { - console.log("Process termination timeout reached after 5 seconds."); - state.runningProcess = null; - resolvePromise(); - }, 5000); + state.runningProcess?.once("exit", () => { + resolvePromise(); }); }); + await sleep(5000); + console.log("Process termination buffer elapsed."); + state.runningProcess = null; const baseUrl = await getBackstageUrl(); try { diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts index fbb025785d..d3f37ba23c 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/logs.ts @@ -3,7 +3,8 @@ import stream from "stream"; import * as k8s from "@kubernetes/client-node"; import { getErrorMessage, hasErrorResponse } from "../../errors"; -import { RHDHDeploymentState, sleep, syncedLogRegex } from "./types"; +import { pollUntil } from "../../poll-until"; +import { RHDHDeploymentState, syncedLogRegex } from "./types"; async function resolvePodName( state: RHDHDeploymentState, @@ -65,7 +66,6 @@ async function streamPodLogsUntilMatch( timeoutMs: number, ): Promise { console.log(`Reading logs for pod ${podName}`); - const startTime = Date.now(); let found = false; const log = new k8s.Log(state.kc); const logStream = new stream.PassThrough(); @@ -93,17 +93,15 @@ async function streamPodLogsUntilMatch( timestamps: false, }); - while (Date.now() - startTime < timeoutMs) { - if (found) { - break; - } - await sleep(1000); - } - if (found) { - logStream.end(); - logStream.removeAllListeners(); - } - return found; + await pollUntil(() => Promise.resolve(found), { + timeoutMs, + intervalMs: 500, + label: `Log pattern ${searchString} in pod ${podName}`, + }); + + logStream.end(); + logStream.removeAllListeners(); + return true; } export async function followPodLogs( @@ -169,15 +167,13 @@ export async function followLocalLogs( console.log("Local log stream ended."); }); - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - if (found) { - break; - } - await sleep(1000); - } + await pollUntil(() => Promise.resolve(found), { + timeoutMs, + intervalMs: 500, + label: `Log pattern ${searchString} in local process output`, + }); - return found; + return true; } export function followLogs( @@ -201,5 +197,4 @@ export async function waitForSynced(state: RHDHDeploymentState): Promise { const synced = await followLogs(state, syncedLogRegex, 120000); const { expect } = await import("@playwright/test"); expect(synced).toBe(true); - await sleep(2000); } diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts index 90e8275a43..56acbdd4c5 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/types.ts @@ -48,13 +48,7 @@ export interface RHDHDeploymentState { configReconcileBaselineGeneration: number | undefined; } -export function sleep(ms: number): Promise { - return new Promise((resolvePromise) => { - setTimeout(() => { - resolvePromise(); - }, ms); - }); -} +export { sleep } from "../../poll-until"; export function isRecord(value: unknown): value is YamlConfig { return typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts index def11e0955..e5cb082b91 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/wait.ts @@ -1,12 +1,15 @@ import * as k8s from "@kubernetes/client-node"; import { getErrorMessage, hasErrorResponse } from "../../errors"; -import { BackstageCr, RHDHDeploymentState, sleep } from "./types"; +import { pollUntil, pollUntilStable } from "../../poll-until"; +import { BackstageCr, RHDHDeploymentState } from "./types"; const BACKSTAGE_LABELS = { "app.kubernetes.io/name": "backstage", } as const; +const POLL_INTERVAL_MS = 500; + function buildLabelSelector(instanceName: string): string { const labels = { ...BACKSTAGE_LABELS, @@ -46,20 +49,17 @@ export async function waitForConfigReconciled( const baseline = state.configReconcileBaselineGeneration ?? (await getDeploymentGeneration(state)); - const startTime = Date.now(); - - while (Date.now() - startTime < timeoutMs) { - const currentGeneration = await getDeploymentGeneration(state); - if (currentGeneration > baseline) { - console.log( - `[INFO] Config reconciled - deployment generation ${baseline} -> ${currentGeneration}`, - ); - return; - } - await sleep(1000); - } - console.log(`[INFO] No deployment generation change after ${timeoutMs}ms, proceeding`); + try { + await pollUntil(async () => (await getDeploymentGeneration(state)) > baseline, { + timeoutMs, + intervalMs: POLL_INTERVAL_MS, + label: `Config reconcile (generation > ${baseline})`, + }); + console.log(`[INFO] Config reconciled - deployment generation > ${baseline}`); + } catch { + console.log(`[INFO] No deployment generation change after ${timeoutMs}ms, proceeding`); + } } function hasRolloutStarted( @@ -109,129 +109,98 @@ function isDeploymentReady(deployment: k8s.V1Deployment, cr: BackstageCr): boole ); } +async function getLabeledDeployment( + state: RHDHDeploymentState, + labelSelector: string, +): Promise { + const deployments = await state.appsV1Api.listNamespacedDeployment( + state.namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + + if (deployments.body.items.length === 0) { + throw new Error(`No deployment found with labels: ${labelSelector}`); + } + + return deployments.body.items[0]; +} + async function waitForRolloutStart( state: RHDHDeploymentState, labelSelector: string, rolloutStartTimeout: number, - startTime: number, ): Promise<{ rolloutStarted: boolean; initialGeneration: number }> { let initialGeneration = 0; - let rolloutStarted = false; - - while (Date.now() - startTime < rolloutStartTimeout) { - const deployments = await state.appsV1Api.listNamespacedDeployment( - state.namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector, - ); - if (deployments.body.items.length === 0) { - throw new Error(`No deployment found with labels: ${labelSelector}`); - } + try { + await pollUntil( + async () => { + const deployment = await getLabeledDeployment(state, labelSelector); - const deployment = deployments.body.items[0]; - const conditions = deployment.status?.conditions ?? []; + if (initialGeneration === 0) { + initialGeneration = deployment.metadata?.generation ?? 0; + console.log(`[INFO] Initial deployment generation: ${initialGeneration}`); + } - if (initialGeneration === 0) { - initialGeneration = deployment.metadata?.generation ?? 0; - console.log(`[INFO] Initial deployment generation: ${initialGeneration}`); - } + const currentGeneration = deployment.metadata?.generation ?? 0; + const observedGeneration = deployment.status?.observedGeneration ?? 0; + const isProgressing = (deployment.status?.conditions ?? []).some( + (condition) => condition.type === "Progressing" && condition.status === "True", + ); - const currentGeneration = deployment.metadata?.generation ?? 0; - const observedGeneration = deployment.status?.observedGeneration ?? 0; - const isProgressing = conditions.some( - (condition) => condition.type === "Progressing" && condition.status === "True", + return hasRolloutStarted( + initialGeneration, + currentGeneration, + observedGeneration, + isProgressing, + ); + }, + { + timeoutMs: rolloutStartTimeout, + intervalMs: POLL_INTERVAL_MS, + label: "Deployment rollout start", + }, ); - if ( - hasRolloutStarted(initialGeneration, currentGeneration, observedGeneration, isProgressing) - ) { - rolloutStarted = true; - console.log( - `[INFO] Rollout detected - Generation: ${currentGeneration}, Observed: ${observedGeneration}`, - ); - return { rolloutStarted, initialGeneration }; - } - - const elapsedSinceStart = Date.now() - startTime; + console.log("[INFO] Rollout detected"); + return { rolloutStarted: true, initialGeneration }; + } catch { console.log( - `[INFO] Waiting for rollout to start... (${Math.round(elapsedSinceStart / 1000)}s elapsed)`, + `[INFO] No rollout detected after ${rolloutStartTimeout}ms, checking if deployment is already ready`, ); - await sleep(2000); + return { rolloutStarted: true, initialGeneration }; } - - console.log( - `[INFO] No rollout detected after ${rolloutStartTimeout}ms, checking if deployment is already ready`, - ); - return { rolloutStarted: true, initialGeneration }; } async function pollDeploymentReady( state: RHDHDeploymentState, labelSelector: string, timeoutMs: number, - startTime: number, ): Promise { - const rolloutStartTimeout = 60000; - const { rolloutStarted } = await waitForRolloutStart( - state, - labelSelector, - rolloutStartTimeout, - startTime, - ); - - if (!rolloutStarted) { - throw new Error("Rollout did not start within timeout"); - } - - while (Date.now() - startTime < timeoutMs) { - try { - const deployments = await state.appsV1Api.listNamespacedDeployment( - state.namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector, - ); - - if (deployments.body.items.length === 0) { - throw new Error(`No deployment found with labels: ${labelSelector}`); - } - - const deployment = deployments.body.items[0]; - - if (isDeploymentReady(deployment, state.cr)) { - await sleep(5000); - return; - } + const rolloutStartTimeout = 60_000; + await waitForRolloutStart(state, labelSelector, rolloutStartTimeout); - const desiredReplicas = state.cr.spec.replicas ?? 1; - const availableReplicas = deployment.status?.availableReplicas ?? 0; - const readyReplicas = deployment.status?.readyReplicas ?? 0; - const updatedReplicas = deployment.status?.updatedReplicas ?? 0; - const observedGeneration = deployment.status?.observedGeneration ?? 0; - const currentGeneration = deployment.metadata?.generation ?? 0; - - console.log( - `[INFO] Deployment is progressing - Available: ${availableReplicas}, Ready: ${readyReplicas}, Updated: ${updatedReplicas}, Desired: ${desiredReplicas}, Observed Gen: ${observedGeneration}/${currentGeneration}`, - ); - - await sleep(5000); - } catch (error) { - if (Date.now() - startTime >= timeoutMs) { - throw new Error(`Timeout waiting for deployment to be ready: ${getErrorMessage(error)}`, { - cause: error, - }); + await pollUntilStable( + async () => { + try { + const deployment = await getLabeledDeployment(state, labelSelector); + return isDeploymentReady(deployment, state.cr); + } catch (error) { + console.log(`[INFO] Deployment readiness check failed: ${getErrorMessage(error)}`); + return false; } - await sleep(5000); - } - } - - throw new Error(`Timeout waiting for deployment to be ready after ${timeoutMs}ms`); + }, + { + timeoutMs, + intervalMs: POLL_INTERVAL_MS, + stableChecks: 2, + label: `Deployment ready (${labelSelector})`, + }, + ); } export async function waitForDeploymentReady( @@ -244,41 +213,33 @@ export async function waitForDeploymentReady( } const labelSelector = buildLabelSelector(state.instanceName); - const startTime = Date.now(); - await pollDeploymentReady(state, labelSelector, timeoutMs, startTime); + await pollDeploymentReady(state, labelSelector, timeoutMs); } export async function waitForNamespaceActive( state: RHDHDeploymentState, timeoutMs: number = 30000, ): Promise { - const startTime = Date.now(); if (state.isRunningLocal) { console.log("Skipping namespace active check as isRunningLocal is true."); return; } - while (Date.now() - startTime < timeoutMs) { - try { - const response = await state.k8sApi.readNamespace(state.namespace); - const phase = response.body.status?.phase; - - if (phase === "Active") { - return; - } - - await sleep(1000); - } catch (error) { - if (Date.now() - startTime >= timeoutMs) { - throw new Error(`Timeout waiting for namespace to be active: ${getErrorMessage(error)}`, { - cause: error, - }); + await pollUntil( + async () => { + try { + const response = await state.k8sApi.readNamespace(state.namespace); + return response.body.status?.phase === "Active"; + } catch { + return false; } - await sleep(1000); - } - } - - throw new Error(`Timeout waiting for namespace to be active after ${timeoutMs}ms`); + }, + { + timeoutMs, + intervalMs: POLL_INTERVAL_MS, + label: `Namespace ${state.namespace} active`, + }, + ); } export async function ensureBackstageCRIsAvailable( @@ -290,29 +251,28 @@ export async function ensureBackstageCRIsAvailable( return; } - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - try { - const customObjectsApi = state.kc.makeApiClient(k8s.CustomObjectsApi); - await customObjectsApi.getClusterCustomObject( - "apiextensions.k8s.io", - "v1", - "customresourcedefinitions", - "backstages.rhdh.redhat.com", - ); - return; - } catch (error) { - console.log(`Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`); - if (Date.now() - startTime >= timeoutMs) { - throw new Error( - `Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`, - { cause: error }, + await pollUntil( + async () => { + try { + const customObjectsApi = state.kc.makeApiClient(k8s.CustomObjectsApi); + await customObjectsApi.getClusterCustomObject( + "apiextensions.k8s.io", + "v1", + "customresourcedefinitions", + "backstages.rhdh.redhat.com", ); + return true; + } catch (error) { + console.log(`Waiting for Backstage CRD: ${getErrorMessage(error)}`); + return false; } - await sleep(5000); - } - } - throw new Error(`Timeout waiting for Backstage CRD to be available after ${timeoutMs}ms`); + }, + { + timeoutMs, + intervalMs: POLL_INTERVAL_MS, + label: "Backstage CRD available", + }, + ); } export async function deleteNamespaceIfExists( @@ -327,19 +287,24 @@ export async function deleteNamespaceIfExists( try { await state.k8sApi.deleteNamespace(state.namespace); - const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs) { - try { - await state.k8sApi.readNamespace(state.namespace); - await sleep(1000); - } catch (error) { - if (hasErrorResponse(error) && error.response?.statusCode === 404) { - return; + await pollUntil( + async () => { + try { + await state.k8sApi.readNamespace(state.namespace); + return false; + } catch (error) { + if (hasErrorResponse(error) && error.response?.statusCode === 404) { + return true; + } + throw error; } - throw error; - } - } - throw new Error(`Timeout waiting for namespace to be deleted after ${timeoutMs}ms`); + }, + { + timeoutMs, + intervalMs: POLL_INTERVAL_MS, + label: `Namespace ${state.namespace} deleted`, + }, + ); } catch (e) { if (hasErrorResponse(e) && e.response?.statusCode === 404) { return; diff --git a/e2e-tests/playwright/utils/common/browser.ts b/e2e-tests/playwright/utils/common/browser.ts index 42b59dc978..5041eebc6d 100644 --- a/e2e-tests/playwright/utils/common/browser.ts +++ b/e2e-tests/playwright/utils/common/browser.ts @@ -30,16 +30,23 @@ export function parseAuthStateCookies(content: string): Cookie[] { return cookies; } +function resolveVideoDir(testInfo: TestInfo): string { + const specStem = + typeof testInfo.file === "string" && testInfo.file !== "" + ? path.parse(testInfo.file).name.replace(/\.spec$/u, "") + : `worker-${testInfo.workerIndex}`; + const suiteName = testInfo.titlePath?.[1] ?? testInfo.titlePath?.[0] ?? "suite"; + return `test-results/${specStem}/${suiteName}`; +} + export async function setupBrowser(browser: Browser, testInfo: TestInfo) { + const videoDir = resolveVideoDir(testInfo); + const context = await browser.newContext({ - ...(testInfo.retry > 0 && { - recordVideo: { - dir: `test-results/${path - .parse(testInfo.file) - .name.replace(".spec", "")}/${testInfo.titlePath[1]}`, - size: { width: 1280, height: 720 }, - }, - }), + recordVideo: { + dir: videoDir, + size: { width: 1280, height: 720 }, + }, }); const page = await context.newPage(); await startCoverageForPage(page); @@ -49,5 +56,9 @@ export async function setupBrowser(browser: Browser, testInfo: TestInfo) { export async function teardownBrowser(page: Page, testInfo: TestInfo): Promise { await stopCoverageForPage(page, testInfo); - await page.close(); + const context = page.context(); + if (!page.isClosed()) { + await page.close(); + } + await context.close(); } diff --git a/e2e-tests/playwright/utils/common/index.ts b/e2e-tests/playwright/utils/common/index.ts index be52055712..4f2b5c0779 100644 --- a/e2e-tests/playwright/utils/common/index.ts +++ b/e2e-tests/playwright/utils/common/index.ts @@ -1,12 +1,13 @@ import * as fs from "fs"; -import { test, Page } from "@playwright/test"; +import { expect, Page } from "@playwright/test"; import { authenticator } from "otplib"; import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; -import { SETTINGS_PAGE_COMPONENTS } from "../../support/page-objects/page-obj"; +import { SETTINGS_PAGE_COMPONENTS } from "../../support/selectors/page-selectors"; import { getErrorMessage } from "../errors"; import { UIhelper } from "../ui-helper"; +import { waitForNextTotpWindow, waitForRhdhReady } from "../wait-for-rhdh-ready"; import { handleGitHubPopupLogin, handleGitlabPopupLogin, @@ -36,9 +37,14 @@ export class Common { this.uiHelper = new UIhelper(page); } + private async waitForAppReady(timeout = 120_000): Promise { + await waitForRhdhReady(this.page.request, timeout); + await this.waitForLoad(timeout); + } + async loginAsGuest() { await this.page.goto("/"); - await this.waitForLoad(240000); + await this.waitForAppReady(); // RHIDP-2043: Remove dialog handler after dynamic Guest Authentication Provider plugin is created this.page.on("dialog", async (dialog) => { console.log(`Dialog message: ${dialog.message()}`); @@ -52,7 +58,7 @@ export class Common { async waitForLoad(timeout = 120000) { for (const selector of LOADING_INDICATOR_SELECTORS) { - await this.page.waitForSelector(selector, { + await this.page.locator(selector).waitFor({ state: "hidden", timeout: timeout, }); @@ -67,7 +73,8 @@ export class Common { private async logintoGithub(userid: string) { await this.page.goto("https://github.com/login"); - await this.page.waitForSelector("#login_field"); + /* oxlint-disable playwright/no-raw-locators -- GitHub login page (third-party) */ + await expect(this.page.locator("#login_field")).toBeVisible(); await this.page.fill("#login_field", userid); const password = @@ -83,21 +90,18 @@ export class Common { await this.page.click('[value="Sign in"]'); await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); - test.setTimeout(130000); if ( (await this.uiHelper.isTextVisible( "The two-factor code you entered has already been used", )) || (await this.uiHelper.isTextVisible("too many codes have been submitted", 3000)) ) { - // GitHub TOTP codes cannot be reused within ~30s; wait for the next window. - await new Promise((resolve) => { - setTimeout(resolve, 60_000); - }); + await waitForNextTotpWindow(); await this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); } - await this.page.waitForLoadState("networkidle"); + await this.page.waitForLoadState("domcontentloaded"); + /* oxlint-enable playwright/no-raw-locators */ } async logintoKeycloak(userid: string, password: string) { @@ -125,7 +129,7 @@ export class Common { password: string = process.env.GH_USER_PASS ?? "", ) { await this.page.goto("/"); - await this.waitForLoad(240000); + await this.waitForAppReady(); await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); await this.logintoKeycloak(userid, password); await this.uiHelper.waitForSideBarVisible(); @@ -139,13 +143,13 @@ export class Common { await this.page.context().addCookies(cookies); console.log(`Reusing existing authentication state for user: ${userid}`); await this.page.goto("/"); - await this.waitForLoad(12000); + await this.waitForAppReady(30_000); await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); await this.checkAndReauthorizeGithubApp(); } else { await this.logintoGithub(userid); await this.page.goto("/"); - await this.waitForLoad(240000); + await this.waitForAppReady(); await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); await this.checkAndReauthorizeGithubApp(); await this.uiHelper.waitForSideBarVisible(); @@ -232,9 +236,9 @@ export class Common { async keycloakLogin(username: string, password: string) { await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, - ); + await expect( + this.page.locator(`p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`), + ).toBeVisible(); const [popup] = await Promise.all([ this.page.waitForEvent("popup"), @@ -246,9 +250,9 @@ export class Common { async githubLogin(username: string, password: string, twofactor: string) { await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.github.message"]}")`, - ); + await expect( + this.page.locator(`p:has-text("${t["rhdh"][lang]["signIn.providers.github.message"]}")`), + ).toBeVisible(); const [popup] = await Promise.all([ this.page.waitForEvent("popup"), @@ -279,9 +283,9 @@ export class Common { async gitlabLogin(username: string, password: string) { await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.gitlab.message"]}")`, - ); + await expect( + this.page.locator(`p:has-text("${t["rhdh"][lang]["signIn.providers.gitlab.message"]}")`), + ).toBeVisible(); const [popup] = await Promise.all([ this.page.waitForEvent("popup"), @@ -293,9 +297,9 @@ export class Common { async MicrosoftAzureLogin(username: string, password: string) { await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.microsoft.message"]}")`, - ); + await expect( + this.page.locator(`p:has-text("${t["rhdh"][lang]["signIn.providers.microsoft.message"]}")`), + ).toBeVisible(); const [popup] = await Promise.all([ this.page.waitForEvent("popup"), @@ -307,9 +311,9 @@ export class Common { async pingFederateLogin(username: string, password: string) { await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, - ); + await expect( + this.page.locator(`p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`), + ).toBeVisible(); const [popup] = await Promise.all([ this.page.waitForEvent("popup"), diff --git a/e2e-tests/playwright/utils/constants.ts b/e2e-tests/playwright/utils/constants.ts index 9ec9431da9..42da49803a 100644 --- a/e2e-tests/playwright/utils/constants.ts +++ b/e2e-tests/playwright/utils/constants.ts @@ -1,7 +1,10 @@ export const GITHUB_URL = "https://github.com/"; export const JANUS_ORG = "janus-idp"; export const JANUS_QE_ORG = "janus-qe"; -export const SHOWCASE_REPO = `${JANUS_ORG}/backstage-showcase`; +/** GitHub repo used for sample catalog imports in E2E tests. */ +export const RHDH_DEMO_CATALOG_REPO = `${JANUS_ORG}/backstage-showcase`; +/** @deprecated Use RHDH_DEMO_CATALOG_REPO instead. */ +export const SHOWCASE_REPO = RHDH_DEMO_CATALOG_REPO; export const CATALOG_FILE = "catalog-info.yaml"; export const NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE = /Login failed;u caused by Error: Failed to sign-in, unable to resolve user identity. Please verify that your catalog contains the expected User entities that would match your configured sign-in resolver./u; diff --git a/e2e-tests/playwright/utils/keycloak/keycloak.ts b/e2e-tests/playwright/utils/keycloak/keycloak.ts index 2326a9ec26..396523c461 100644 --- a/e2e-tests/playwright/utils/keycloak/keycloak.ts +++ b/e2e-tests/playwright/utils/keycloak/keycloak.ts @@ -1,6 +1,6 @@ import { expect, Page } from "@playwright/test"; -import { CatalogUsersPO } from "../../support/page-objects/catalog/catalog-users-obj"; +import { CatalogUsersPage } from "../../support/pages/catalog-users-page"; import { base64Decode } from "../helper"; import { UIhelper } from "../ui-helper"; import Group from "./group"; @@ -110,18 +110,19 @@ class Keycloak { uiHelper: UIhelper, keycloak: Keycloak, ) { - await CatalogUsersPO.visitUserPage(page, keycloakUser.username); - const emailLink = CatalogUsersPO.getEmailLink(page); + const catalogUsers = new CatalogUsersPage(page); + await catalogUsers.visitUserPage(keycloakUser.username); + const emailLink = catalogUsers.getEmailLink(); await expect(emailLink).toBeVisible(); await uiHelper.verifyDivHasText(`${keycloakUser.firstName} ${keycloakUser.lastName}`); const groups = await keycloak.getGroupsOfUser(token, keycloakUser.id); for (const group of groups) { - const groupLink = CatalogUsersPO.getGroupLink(page, group.name); + const groupLink = catalogUsers.getGroupLink(group.name); await expect(groupLink).toBeVisible(); } - await CatalogUsersPO.visitBaseURL(page); + await catalogUsers.visitBaseURL(); } } diff --git a/e2e-tests/playwright/utils/kube-client-deployment-pods.ts b/e2e-tests/playwright/utils/kube-client-deployment-pods.ts new file mode 100644 index 0000000000..3943c6594e --- /dev/null +++ b/e2e-tests/playwright/utils/kube-client-deployment-pods.ts @@ -0,0 +1,40 @@ +import * as k8s from "@kubernetes/client-node"; + +import { pollUntil } from "./poll-until"; + +export async function waitForPodsTerminatedImpl( + coreV1Api: k8s.CoreV1Api, + getDeploymentPodSelector: (deploymentName: string, namespace: string) => Promise, + deploymentName: string, + namespace: string, + timeoutMs = 120_000, +): Promise { + const labelSelector = await getDeploymentPodSelector(deploymentName, namespace); + + await pollUntil( + async () => { + const response = await coreV1Api.listNamespacedPod( + namespace, + undefined, + undefined, + undefined, + undefined, + labelSelector, + ); + const activePods = response.body.items.filter( + (pod) => pod.metadata?.deletionTimestamp === undefined, + ); + if (activePods.length === 0) { + console.log(`All pods for ${deploymentName} terminated.`); + return true; + } + console.log(`Waiting for ${activePods.length} pod(s) for ${deploymentName} to terminate...`); + return false; + }, + { + timeoutMs, + intervalMs: 2000, + label: `Pods for ${deploymentName} terminated`, + }, + ); +} diff --git a/e2e-tests/playwright/utils/kube-client/deployment/restart.ts b/e2e-tests/playwright/utils/kube-client/deployment/restart.ts index d7ad531ae6..ff1a969e04 100644 --- a/e2e-tests/playwright/utils/kube-client/deployment/restart.ts +++ b/e2e-tests/playwright/utils/kube-client/deployment/restart.ts @@ -1,4 +1,4 @@ -import { getKubeApiErrorMessage, sleep } from "../helpers"; +import { getKubeApiErrorMessage } from "../helpers"; async function scaleDeploymentDown( scaleDeployment: (deploymentName: string, namespace: string, replicas: number) => Promise, @@ -8,6 +8,7 @@ async function scaleDeploymentDown( expectedReplicas: number, timeout?: number, ) => Promise, + waitForPodsTerminated: (deploymentName: string, namespace: string) => Promise, logPodConditionsForDeployment: (deploymentName: string, namespace: string) => Promise, deploymentName: string, namespace: string, @@ -18,7 +19,7 @@ async function scaleDeploymentDown( await scaleDeployment(deploymentName, namespace, 0); await waitForDeploymentReady(deploymentName, namespace, 0, 300000); console.log("Waiting for pods to be fully terminated..."); - await sleep(10000); + await waitForPodsTerminated(deploymentName, namespace); } async function scaleDeploymentUp( @@ -45,6 +46,7 @@ export async function restartDeploymentImpl( expectedReplicas: number, timeout?: number, ) => Promise, + waitForPodsTerminated: (deploymentName: string, namespace: string) => Promise, logPodConditionsForDeployment: (deploymentName: string, namespace: string) => Promise, logDeploymentEvents: (deploymentName: string, namespace: string) => Promise, deploymentName: string, @@ -55,6 +57,7 @@ export async function restartDeploymentImpl( await scaleDeploymentDown( scaleDeployment, waitForDeploymentReady, + waitForPodsTerminated, logPodConditionsForDeployment, deploymentName, namespace, diff --git a/e2e-tests/playwright/utils/kube-client/deployment/wait.ts b/e2e-tests/playwright/utils/kube-client/deployment/wait.ts index 8a98e17abc..1f7a9c9d2a 100644 --- a/e2e-tests/playwright/utils/kube-client/deployment/wait.ts +++ b/e2e-tests/playwright/utils/kube-client/deployment/wait.ts @@ -1,6 +1,7 @@ import * as k8s from "@kubernetes/client-node"; -import { getKubeApiErrorMessage, PodFailureResult, sleep } from "../helpers"; +import { pollUntil } from "../../poll-until"; +import { getKubeApiErrorMessage, PodFailureResult } from "../helpers"; export interface DeploymentDiagnostics { logDeploymentEvents: (deploymentName: string, namespace: string) => Promise; @@ -14,6 +15,8 @@ export interface DeploymentDiagnostics { ) => Promise; } +const POLL_INTERVAL_MS = 2000; + async function handlePodFailureDuringWait( diagnostics: DeploymentDiagnostics, deploymentName: string, @@ -129,46 +132,64 @@ export async function waitForDeploymentReadyImpl( namespace: string, expectedReplicas: number, timeout: number = 300000, - checkInterval: number = 10000, + checkInterval: number = POLL_INTERVAL_MS, labelSelector?: string, ): Promise { - const endTime = Date.now() + timeout; const podSelector = await getDeploymentPodSelector(deploymentName, namespace); const finalLabelSelector = labelSelector ?? podSelector; - const progressLogStart = endTime - timeout + checkInterval * 2; - - while (Date.now() < endTime) { - try { - const isReady = await checkDeploymentReplicaStatus( - appsApi, - checkPodFailureStates, - logPodConditions, - diagnostics, - deploymentName, - namespace, - expectedReplicas, - podSelector, - finalLabelSelector, + let loggedProgress = false; + + try { + await pollUntil( + async () => { + try { + const isReady = await checkDeploymentReplicaStatus( + appsApi, + checkPodFailureStates, + logPodConditions, + diagnostics, + deploymentName, + namespace, + expectedReplicas, + podSelector, + finalLabelSelector, + ); + if (isReady) { + return true; + } + + if (!loggedProgress) { + await logDeploymentWaitProgress(appsApi, deploymentName, namespace, expectedReplicas); + loggedProgress = true; + } + return false; + } catch (error) { + console.error(`Error checking deployment status: ${getKubeApiErrorMessage(error)}`); + if (isPodStartupFailure(error)) { + throw error; + } + return false; + } + }, + { + timeoutMs: timeout, + intervalMs: checkInterval, + label: `Deployment ${deploymentName} ready (${expectedReplicas} replicas)`, + }, + ); + } catch (error) { + await logDeploymentTimeoutDiagnostics( + diagnostics, + deploymentName, + namespace, + finalLabelSelector, + ); + if (error instanceof Error && error.message.includes("Condition not met")) { + throw new Error( + `Deployment ${deploymentName} did not become ready in time (timeout: ${timeout / 1000}s).`, + { cause: error }, ); - if (isReady) { - return; - } - - if (Date.now() > progressLogStart) { - await logDeploymentWaitProgress(appsApi, deploymentName, namespace, expectedReplicas); - } - } catch (error) { - console.error(`Error checking deployment status: ${getKubeApiErrorMessage(error)}`); - if (isPodStartupFailure(error)) { - throw error; - } } - - await sleep(checkInterval); + throw error; } - - await logDeploymentTimeoutDiagnostics(diagnostics, deploymentName, namespace, finalLabelSelector); - throw new Error( - `Deployment ${deploymentName} did not become ready in time (timeout: ${timeout / 1000}s).`, - ); } diff --git a/e2e-tests/playwright/utils/kube-client/helpers.ts b/e2e-tests/playwright/utils/kube-client/helpers.ts index e91f9b1dc2..02f291afbd 100644 --- a/e2e-tests/playwright/utils/kube-client/helpers.ts +++ b/e2e-tests/playwright/utils/kube-client/helpers.ts @@ -148,13 +148,7 @@ export function rejectAsError(reject: (reason: Error) => void, err: unknown): vo reject(err instanceof Error ? err : new Error(getErrorMessage(err))); } -export function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, ms); - }); -} +export { sleep, pollUntil } from "../poll-until"; export function podNameOrUnknown(name: string | undefined): string { return name !== undefined && name !== "" ? name : "unknown"; diff --git a/e2e-tests/playwright/utils/kube-client/index.ts b/e2e-tests/playwright/utils/kube-client/index.ts index bbc1352eea..2ad77bda90 100644 --- a/e2e-tests/playwright/utils/kube-client/index.ts +++ b/e2e-tests/playwright/utils/kube-client/index.ts @@ -3,6 +3,7 @@ import { V1ConfigMap } from "@kubernetes/client-node"; import * as yaml from "js-yaml"; import { hasStatusCode } from "../errors"; +import { waitForPodsTerminatedImpl } from "../kube-client-deployment-pods"; import { findAppConfigMapName, updateConfigMapTitleImpl } from "./configmap"; import { restartDeploymentImpl } from "./deployment/restart"; import { getDeploymentPodSelectorImpl, scaleDeploymentImpl } from "./deployment/scale"; @@ -365,6 +366,7 @@ export class KubeClient { return restartDeploymentImpl( (name, ns, replicas) => this.scaleDeployment(name, ns, replicas), (name, ns, replicas, t) => this.waitForDeploymentReady(name, ns, replicas, t), + (name, ns) => this.waitForPodsTerminated(name, ns), (name, ns) => this.logPodConditionsForDeployment(name, ns), (name, ns) => this.logDeploymentEvents(name, ns), deploymentName, @@ -372,6 +374,15 @@ export class KubeClient { ); } + waitForPodsTerminated(deploymentName: string, namespace: string) { + return waitForPodsTerminatedImpl( + this.coreV1Api, + (name, ns) => this.getDeploymentPodSelector(name, ns), + deploymentName, + namespace, + ); + } + private getDeploymentPodSelector(deploymentName: string, namespace: string): Promise { return getDeploymentPodSelectorImpl(this.appsApi, deploymentName, namespace); } diff --git a/e2e-tests/playwright/utils/poll-until.ts b/e2e-tests/playwright/utils/poll-until.ts new file mode 100644 index 0000000000..8b343bb9d1 --- /dev/null +++ b/e2e-tests/playwright/utils/poll-until.ts @@ -0,0 +1,79 @@ +const DEFAULT_POLL_INTERVAL_MS = 500; + +export function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, ms); + }); +} + +export type PollUntilOptions = { + timeoutMs?: number; + intervalMs?: number; + label?: string; +}; + +/** Poll until `condition` returns true or timeout. */ +export async function pollUntil( + condition: () => Promise, + options: PollUntilOptions = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 60_000; + const intervalMs = options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (await condition()) { + return; + } + await sleep(intervalMs); + } + + throw new Error(options.label ?? `Condition not met within ${timeoutMs}ms`); +} + +/** Poll until `condition` is true for `stableChecks` consecutive evaluations. */ +export async function pollUntilStable( + condition: () => Promise, + options: PollUntilOptions & { stableChecks?: number } = {}, +): Promise { + const stableChecks = options.stableChecks ?? 2; + let consecutive = 0; + + await pollUntil(async () => { + if (await condition()) { + consecutive += 1; + return consecutive >= stableChecks; + } + consecutive = 0; + return false; + }, options); +} + +/** Poll until `fn` returns a non-null value. */ +export async function pollForValue( + fn: () => Promise, + options: PollUntilOptions = {}, +): Promise { + let result: T | null | undefined; + + await pollUntil(async () => { + result = await fn(); + return result !== null && result !== undefined; + }, options); + + if (result === null || result === undefined) { + throw new Error(options.label ?? "pollForValue: no value returned"); + } + return result; +} + +/** Wait until the next UTC TOTP window (30s) plus a small buffer. */ +export async function waitForNextTotpWindow(bufferMs = 1000): Promise { + const now = Date.now(); + const windowMs = 30_000; + const msIntoWindow = now % windowMs; + const waitMs = msIntoWindow === 0 ? bufferMs : windowMs - msIntoWindow + bufferMs; + await sleep(waitMs); +} diff --git a/e2e-tests/playwright/utils/ui-helper/class.ts b/e2e-tests/playwright/utils/ui-helper/class.ts index cac7e08c57..53fe19a264 100644 --- a/e2e-tests/playwright/utils/ui-helper/class.ts +++ b/e2e-tests/playwright/utils/ui-helper/class.ts @@ -1,6 +1,6 @@ import { Locator, Page } from "@playwright/test"; -import { SEARCH_OBJECTS_COMPONENTS } from "../../support/page-objects/page-obj"; +import { SEARCH_OBJECTS_COMPONENTS } from "../../support/selectors/page-selectors"; import * as interaction from "./interaction"; import * as misc from "./misc"; import * as navigation from "./navigation"; @@ -19,8 +19,11 @@ export class UIhelper { return misc.verifyComponentInCatalog(this.page, kind, expectedRows); } - getSideBarMenuItem(menuItem: string): Locator { - return this.page.getByTestId("login-button").getByText(menuItem); + getSideBarMenuItem(sectionName: string): Locator { + const sidebar = navigation.getSidebarNav(this.page); + return sidebar.filter({ + has: sidebar.getByRole("button", { name: sectionName, exact: true }), + }); } fillTextInputByLabel(label: string, text: string) { @@ -154,6 +157,10 @@ export class UIhelper { return navigation.openSidebarButton(this.page, navBarButtonLabel); } + openSidebarLinkInSection(sectionName: string, linkName: string) { + return navigation.openSidebarLinkInSection(this.page, sectionName, linkName); + } + selectMuiBox(label: string, value: string, notVisible?: boolean) { return navigation.selectMuiBox(this.page, label, value, notVisible); } diff --git a/e2e-tests/playwright/utils/ui-helper/interaction.ts b/e2e-tests/playwright/utils/ui-helper/interaction.ts index 637c847bb9..cf7f5d2109 100644 --- a/e2e-tests/playwright/utils/ui-helper/interaction.ts +++ b/e2e-tests/playwright/utils/ui-helper/interaction.ts @@ -1,6 +1,6 @@ import { expect, Locator, Page } from "@playwright/test"; -import { getCardByText } from "../../support/page-objects/ui-locators"; +import { getCardByText } from "../../support/selectors/ui-locators"; import { getErrorMessage } from "../errors"; import { DEFAULT_CLICK_BUTTON_BY_TEXT_OPTIONS, DEFAULT_CLICK_BUTTON_OPTIONS } from "./defaults"; @@ -43,7 +43,7 @@ export async function clickBtnByTitleIfNotPressed(page: Page, title: string) { export async function clickByDataTestId(page: Page, dataTestId: string) { const element = page.getByTestId(dataTestId); await element.waitFor({ state: "visible" }); - await element.dispatchEvent("click"); + await element.click(); } export async function clickDivByTitle(page: Page, title: string) { diff --git a/e2e-tests/playwright/utils/ui-helper/misc.ts b/e2e-tests/playwright/utils/ui-helper/misc.ts index abd9a1fbd3..8b7b12c408 100644 --- a/e2e-tests/playwright/utils/ui-helper/misc.ts +++ b/e2e-tests/playwright/utils/ui-helper/misc.ts @@ -1,7 +1,7 @@ import { expect, Page } from "@playwright/test"; import { getCurrentLanguage } from "../../e2e/localization/locale"; -import { getCardByHeading } from "../../support/page-objects/ui-locators"; +import { getCardByHeading } from "../../support/selectors/ui-locators"; import { clickButtonByLabel, clickByDataTestId, clickLink } from "./interaction"; import { openSidebar, selectMuiBox } from "./navigation"; import { verifyCellsInTable } from "./table"; diff --git a/e2e-tests/playwright/utils/ui-helper/navigation.ts b/e2e-tests/playwright/utils/ui-helper/navigation.ts index 27690adad0..24c10bd32e 100644 --- a/e2e-tests/playwright/utils/ui-helper/navigation.ts +++ b/e2e-tests/playwright/utils/ui-helper/navigation.ts @@ -1,4 +1,4 @@ -import { expect, Page } from "@playwright/test"; +import { expect, Locator, Page } from "@playwright/test"; import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; import { getErrorMessage } from "../errors"; @@ -8,6 +8,144 @@ import { verifyHeading } from "./verification"; const t = getTranslations(); const lang = getCurrentLanguage(); +/** Left nav excluding the global header bar (profile menu uses a separate navigation). */ +export function getSidebarNav(page: Page): Locator { + return page + .getByRole("navigation") + .filter({ hasNot: page.getByTestId("KeyboardArrowDownOutlinedIcon") }) + .first(); +} + +function sidebarLinks(page: Page, linkName: string): Locator { + return getSidebarNav(page).getByRole("link", { name: linkName, exact: true }); +} + +async function resolveVisibleSidebarLink(page: Page, linkName: string): Promise { + const candidates = sidebarLinks(page, linkName); + await expect(candidates.first()).toBeAttached({ timeout: 15_000 }); + + const count = await candidates.count(); + for (let index = 0; index < count; index++) { + const candidate = candidates.nth(index); + if (await candidate.isVisible()) { + return candidate; + } + } + + throw new Error(`Sidebar link "${linkName}" is not visible`); +} + +async function expandSidebarSection(page: Page, sectionLabel: string): Promise { + const sectionButton = getSidebarNav(page).getByRole("button", { + name: sectionLabel, + exact: true, + }); + await expect(sectionButton).toBeVisible(); + + const expanded = await sectionButton.getAttribute("aria-expanded"); + if (expanded === "true") { + return; + } + if (expanded === "false") { + await sectionButton.click(); + await expect(sectionButton).toHaveAttribute("aria-expanded", "true"); + return; + } + await sectionButton.click(); +} + +/** Collapse expanded sidebar sections so nested links are not covered by layout overlays. */ +async function collapseOtherExpandedSidebarSections( + page: Page, + keepSection: string, +): Promise { + const nav = getSidebarNav(page); + const keepButton = nav.getByRole("button", { name: keepSection, exact: true }); + const keepHandle = await keepButton.elementHandle(); + const buttons = nav.getByRole("button"); + const count = await buttons.count(); + for (let index = 0; index < count; index++) { + const button = buttons.nth(index); + if ((await button.getAttribute("aria-expanded")) !== "true") { + continue; + } + if (keepHandle !== null && (await button.evaluate((el, keep) => el === keep, keepHandle))) { + continue; + } + await button.click(); + await expect(button).toHaveAttribute("aria-expanded", "false"); + } +} + +async function resolveVisibleSidebarLinkInSection( + page: Page, + sectionLabel: string, + linkName: string, +): Promise { + const sectionButton = getSidebarNav(page).getByRole("button", { + name: sectionLabel, + exact: true, + }); + await expect(sectionButton).toHaveAttribute("aria-expanded", "true"); + + const sectionGroup = sectionButton.locator("xpath=.."); + const scopedLink = sectionGroup.getByRole("link", { name: linkName, exact: true }); + if ((await scopedLink.count()) > 0 && (await scopedLink.first().isVisible())) { + return scopedLink.first(); + } + + return resolveVisibleSidebarLink(page, linkName); +} + +async function activateSidebarLink(page: Page, resolveLink: () => Promise): Promise { + try { + await expect(async () => { + const link = await resolveLink(); + await link.scrollIntoViewIfNeeded(); + await expect(link).toBeEnabled(); + await link.click({ timeout: 3000 }); + }).toPass({ + intervals: [500], + timeout: 15_000, + }); + } catch { + const link = await resolveLink(); + const href = await link.getAttribute("href"); + // RHDH sidebar MUI layout can intercept pointer events on nested links in CI. + if (href !== null && href !== "") { + await page.goto(href); + return; + } + throw new Error("Sidebar link is not clickable and has no href fallback"); + } +} + +async function clickSidebarLink(page: Page, linkName: string): Promise { + await activateSidebarLink(page, () => resolveVisibleSidebarLink(page, linkName)); +} + +async function clickSidebarLinkInSection( + page: Page, + sectionLabel: string, + linkName: string, +): Promise { + await activateSidebarLink(page, () => + resolveVisibleSidebarLinkInSection(page, sectionLabel, linkName), + ); +} + +export async function expectSidebarLinkVisible( + page: Page, + linkName: string, + sectionName?: string, +): Promise { + if (sectionName !== undefined && sectionName !== "") { + await expandSidebarSection(page, sectionName); + } + const link = await resolveVisibleSidebarLink(page, linkName); + await expect(link).toBeVisible(); +} + export async function openProfileDropdown(page: Page) { const header = getGlobalHeader(page); await expect(header).toBeVisible(); @@ -47,13 +185,23 @@ export async function goToSelfServicePage(page: Page) { } export async function waitForSideBarVisible(page: Page) { - await page.waitForSelector("nav a", { timeout: 10_000 }); + await expect(getSidebarNav(page).getByRole("link").first()).toBeVisible({ + timeout: 10_000, + }); } export async function openSidebar(page: Page, navBarText: string) { - const navLink = page.locator(`nav a:has-text("${navBarText}")`).first(); - await navLink.waitFor({ state: "visible", timeout: 15_000 }); - await navLink.dispatchEvent("click"); + await clickSidebarLink(page, navBarText); +} + +export async function openSidebarLinkInSection( + page: Page, + sectionName: string, + linkName: string, +): Promise { + await collapseOtherExpandedSidebarSections(page, sectionName); + await expandSidebarSection(page, sectionName); + await clickSidebarLinkInSection(page, sectionName, linkName); } export async function openCatalogSidebar(page: Page, kind: string) { @@ -69,9 +217,7 @@ export async function openCatalogSidebar(page: Page, kind: string) { } export async function openSidebarButton(page: Page, navBarButtonLabel: string) { - const navLink = page.locator(`nav button[aria-label="${navBarButtonLabel}"]`); - await navLink.waitFor({ state: "visible" }); - await navLink.click(); + await expandSidebarSection(page, navBarButtonLabel); } export async function selectMuiBox(page: Page, label: string, value: string, notVisible?: boolean) { diff --git a/e2e-tests/playwright/utils/ui-helper/table.ts b/e2e-tests/playwright/utils/ui-helper/table.ts index d9f2f0cb58..c85eb60920 100644 --- a/e2e-tests/playwright/utils/ui-helper/table.ts +++ b/e2e-tests/playwright/utils/ui-helper/table.ts @@ -1,6 +1,6 @@ import { expect, Locator, Page } from "@playwright/test"; -import { getTableCell, getTableRow } from "../../support/page-objects/ui-locators"; +import { getTableCell, getTableRow } from "../../support/selectors/ui-locators"; import { DEFAULT_VERIFY_BUTTON_URL_OPTIONS } from "./defaults"; export async function verifyCellsInTable(page: Page, texts: (string | RegExp)[]) { @@ -84,7 +84,9 @@ export async function clickOnButtonInTableByUniqueText( } export async function verifyTableHeadingAndRows(page: Page, texts: string[]) { - await page.waitForSelector("table tbody tr", { state: "visible" }); + await expect( + page.getByRole("table").getByRole("rowgroup").last().getByRole("row").first(), + ).toBeVisible(); for (const column of texts) { const columnSelector = `table th:has-text("${column}")`; const columnCount = await page.locator(columnSelector).count(); diff --git a/e2e-tests/playwright/utils/ui-helper/verification.ts b/e2e-tests/playwright/utils/ui-helper/verification.ts index ffb25312e2..59195a9832 100644 --- a/e2e-tests/playwright/utils/ui-helper/verification.ts +++ b/e2e-tests/playwright/utils/ui-helper/verification.ts @@ -69,7 +69,7 @@ export async function verifyRowsInTable( } export async function waitForTextDisappear(page: Page, text: string) { - await page.waitForSelector(`text=${text}`, { state: "detached" }); + await expect(page.getByText(text)).toHaveCount(0); } async function verifyTextInLocator( @@ -172,7 +172,7 @@ export async function verifyParagraph(page: Page, paragraph: string) { } export async function waitForTitle(page: Page, text: string, level: number = 1) { - await page.waitForSelector(`h${level}:has-text("${text}")`); + await expect(page.locator(`h${level}:has-text("${text}")`)).toBeVisible(); } export async function verifyAlertErrorMessage(page: Page, message: string | RegExp) { diff --git a/e2e-tests/playwright/utils/ui-helper/visibility.ts b/e2e-tests/playwright/utils/ui-helper/visibility.ts index 8aac3d8199..f096d0c85b 100644 --- a/e2e-tests/playwright/utils/ui-helper/visibility.ts +++ b/e2e-tests/playwright/utils/ui-helper/visibility.ts @@ -7,11 +7,8 @@ async function isElementVisible( force = false, ): Promise { try { - await page.waitForSelector(locator, { - state: "visible", - timeout, - }); const button = page.locator(locator).first(); + await button.waitFor({ state: "visible", timeout }); return await button.isVisible(); } catch (error) { if (force) throw error; diff --git a/e2e-tests/playwright/utils/wait-for-rhdh-ready.ts b/e2e-tests/playwright/utils/wait-for-rhdh-ready.ts new file mode 100644 index 0000000000..b963cf2e8a --- /dev/null +++ b/e2e-tests/playwright/utils/wait-for-rhdh-ready.ts @@ -0,0 +1,23 @@ +import { expect, type APIRequestContext } from "@playwright/test"; + +export { waitForNextTotpWindow } from "./poll-until"; + +/** Poll the RHDH instance health endpoint until it responds OK. */ +export async function waitForRhdhReady( + request: APIRequestContext, + timeoutMs = 120_000, +): Promise { + await expect + .poll( + async () => { + const response = await request.get("/healthcheck"); + if (response.status() !== 200) { + return false; + } + const body: unknown = await response.json(); + return typeof body === "object" && body !== null && Reflect.get(body, "status") === "ok"; + }, + { timeout: timeoutMs, intervals: [2_000] }, + ) + .toBe(true); +} diff --git a/e2e-tests/unit/poll-until.test.ts b/e2e-tests/unit/poll-until.test.ts new file mode 100644 index 0000000000..bb36344dc1 --- /dev/null +++ b/e2e-tests/unit/poll-until.test.ts @@ -0,0 +1,245 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + pollForValue, + pollUntil, + pollUntilStable, + sleep, + waitForNextTotpWindow, +} from "../playwright/utils/poll-until"; + +describe("sleep", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resolves after the requested delay", async () => { + const promise = sleep(250); + await vi.advanceTimersByTimeAsync(250); + await expect(promise).resolves.toBeUndefined(); + }); +}); + +describe("pollUntil", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns immediately when the condition is true on the first call", async () => { + let calls = 0; + await pollUntil(() => { + calls += 1; + return Promise.resolve(true); + }); + expect(calls).toBe(1); + }); + + it("polls until the condition becomes true", async () => { + let calls = 0; + const promise = pollUntil( + () => { + calls += 1; + return Promise.resolve(calls >= 3); + }, + { timeoutMs: 5000, intervalMs: 500 }, + ); + + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await promise; + + expect(calls).toBe(3); + }); + + it("throws the default timeout error", async () => { + const promise = pollUntil(() => Promise.resolve(false), { + timeoutMs: 1000, + intervalMs: 500, + }); + + const rejection = expect(promise).rejects.toThrow(/Condition not met within 1000ms/u); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await rejection; + }); + + it("throws a custom label on timeout", async () => { + const promise = pollUntil(() => Promise.resolve(false), { + timeoutMs: 1000, + intervalMs: 500, + label: "deployment not ready", + }); + + const rejection = expect(promise).rejects.toThrow(/deployment not ready/u); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await rejection; + }); + + it("propagates errors from the condition", async () => { + await expect(pollUntil(() => Promise.reject(new Error("condition failed")))).rejects.toThrow( + /condition failed/u, + ); + }); +}); + +describe("pollUntilStable", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("requires consecutive true results", async () => { + let calls = 0; + const promise = pollUntilStable( + () => { + calls += 1; + return Promise.resolve(true); + }, + { timeoutMs: 5000, intervalMs: 500, stableChecks: 2 }, + ); + + await vi.advanceTimersByTimeAsync(500); + await promise; + + expect(calls).toBe(2); + }); + + it("resets the consecutive counter when the condition becomes false", async () => { + const results = [true, false, true, true]; + let index = 0; + let calls = 0; + const promise = pollUntilStable( + () => { + calls += 1; + return Promise.resolve(results[index++]); + }, + { timeoutMs: 5000, intervalMs: 500, stableChecks: 2 }, + ); + + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await promise; + + expect(calls).toBe(4); + }); + + it("times out when stability is never reached", async () => { + const promise = pollUntilStable(() => Promise.resolve(true), { + timeoutMs: 1000, + intervalMs: 500, + stableChecks: 5, + }); + + const rejection = expect(promise).rejects.toThrow(/Condition not met within 1000ms/u); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await rejection; + }); +}); + +describe("pollForValue", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the first non-null value", async () => { + const responses: Array = [null, "ready"]; + let calls = 0; + const promise = pollForValue( + () => { + calls += 1; + return Promise.resolve(responses[calls - 1] ?? null); + }, + { timeoutMs: 5000, intervalMs: 500 }, + ); + + await vi.advanceTimersByTimeAsync(500); + const value = await promise; + + expect(value).toBe("ready"); + expect(calls).toBe(2); + }); + + it("keeps polling while the function returns null or undefined", async () => { + const responses: Array = [null, undefined, 42]; + let calls = 0; + const promise = pollForValue( + () => { + calls += 1; + return Promise.resolve(responses[calls - 1] ?? null); + }, + { timeoutMs: 5000, intervalMs: 500 }, + ); + + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + const value = await promise; + + expect(value).toBe(42); + expect(calls).toBe(3); + }); + + it("throws a custom label on timeout", async () => { + const promise = pollForValue(() => Promise.resolve(null), { + timeoutMs: 1000, + intervalMs: 500, + label: "value never appeared", + }); + + const rejection = expect(promise).rejects.toThrow(/value never appeared/u); + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + await rejection; + }); +}); + +describe("waitForNextTotpWindow", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("waits only the buffer at the start of a TOTP window", async () => { + vi.setSystemTime(0); + + const promise = waitForNextTotpWindow(1000); + await vi.advanceTimersByTimeAsync(1000); + await expect(promise).resolves.toBeUndefined(); + }); + + it("waits until the next window plus buffer mid-window", async () => { + vi.setSystemTime(15_000); + + const promise = waitForNextTotpWindow(1000); + await vi.advanceTimersByTimeAsync(16_000); + await expect(promise).resolves.toBeUndefined(); + }); + + it("uses a custom buffer", async () => { + vi.setSystemTime(0); + + const promise = waitForNextTotpWindow(250); + await vi.advanceTimersByTimeAsync(250); + await expect(promise).resolves.toBeUndefined(); + }); +}); diff --git a/e2e-tests/vitest.config.ts b/e2e-tests/vitest.config.ts new file mode 100644 index 0000000000..adc7882cab --- /dev/null +++ b/e2e-tests/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + // E2E specs: playwright/e2e/**/*.spec.ts (Playwright). Unit tests: unit/**/*.test.ts. + include: ["unit/**/*.test.ts"], + }, +}); diff --git a/e2e-tests/yarn.lock b/e2e-tests/yarn.lock index 0be3e9837f..b8161a6776 100644 --- a/e2e-tests/yarn.lock +++ b/e2e-tests/yarn.lock @@ -302,6 +302,34 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:1.11.1": + version: 1.11.1 + resolution: "@emnapi/core@npm:1.11.1" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.2" + tslib: "npm:^2.4.0" + checksum: 10c0/2c6defdac2d1d26090384655d7d6c9614fa553853b1760597686749e9375dc2aa0dae80a2615b81c254600f5d531d07d8466cde0d331a8caae64b93f3ca5937e + languageName: node + linkType: hard + +"@emnapi/runtime@npm:1.11.1": + version: 1.11.1 + resolution: "@emnapi/runtime@npm:1.11.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/04332fb62076afc440aa23316c04bec42f584ca8b074e5507d08e2b33a47cbe0493b1aadb8f3c1057b64ae1e17f5bde1a7bc37f7facc9d0bc25c18197cbd366f + languageName: node + linkType: hard + +"@emnapi/wasi-threads@npm:1.2.2": + version: 1.2.2 + resolution: "@emnapi/wasi-threads@npm:1.2.2" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/f0dc8269d6b20ae5a7c7b36e7a6a333452009d461038ef4febb29da2f3f78c1e2b1576d7e8970a5c5789ed3caedc1f80f5b0c2a5373bdaf8d03b20432bb55747 + languageName: node + linkType: hard + "@felipecrs/decompress-tarxz@npm:5.0.4": version: 5.0.4 resolution: "@felipecrs/decompress-tarxz@npm:5.0.4" @@ -344,6 +372,13 @@ __metadata: languageName: node linkType: hard +"@jridgewell/sourcemap-codec@npm:^1.5.5": + version: 1.5.5 + resolution: "@jridgewell/sourcemap-codec@npm:1.5.5" + checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0 + languageName: node + linkType: hard + "@jsep-plugin/assignment@npm:^1.3.0": version: 1.3.0 resolution: "@jsep-plugin/assignment@npm:1.3.0" @@ -421,6 +456,18 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.6": + version: 1.1.6 + resolution: "@napi-rs/wasm-runtime@npm:1.1.6" + dependencies: + "@tybys/wasm-util": "npm:^0.10.3" + peerDependencies: + "@emnapi/core": ^1.7.1 + "@emnapi/runtime": ^1.7.1 + checksum: 10c0/344518bf3ef65051dda4c00969f293aa4a21ab7dc7822b3f48519b17cd5eaa3f0bc34898d115d50ba59b1817a0cb905d46f7a7223c8249239cd14c28db388e10 + languageName: node + linkType: hard + "@npmcli/agent@npm:^2.0.0": version: 2.2.2 resolution: "@npmcli/agent@npm:2.2.2" @@ -785,6 +832,13 @@ __metadata: languageName: node linkType: hard +"@oxc-project/types@npm:=0.137.0": + version: 0.137.0 + resolution: "@oxc-project/types@npm:0.137.0" + checksum: 10c0/5a6a50174e5ac79aebf38a120fe57be7a84c8bb0c77117f30de15183aa5ab0161e78364d2d3725397090e362e5c5f6eda754b53057b0b63983e3ee604f888aca + languageName: node + linkType: hard + "@oxfmt/binding-android-arm-eabi@npm:0.56.0": version: 0.56.0 resolution: "@oxfmt/binding-android-arm-eabi@npm:0.56.0" @@ -1111,6 +1165,129 @@ __metadata: languageName: node linkType: hard +"@rolldown/binding-android-arm64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-android-arm64@npm:1.1.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-darwin-arm64@npm:1.1.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-darwin-x64@npm:1.1.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-freebsd-x64@npm:1.1.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.1.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.1.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.1.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.1.3" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.1.3" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.1.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.1.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.1.3" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-wasm32-wasi@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-wasm32-wasi@npm:1.1.3" + dependencies: + "@emnapi/core": "npm:1.11.1" + "@emnapi/runtime": "npm:1.11.1" + "@napi-rs/wasm-runtime": "npm:^1.1.6" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.1.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.1.3": + version: 1.1.3 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.1.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/pluginutils@npm:^1.0.0": + version: 1.0.1 + resolution: "@rolldown/pluginutils@npm:1.0.1" + checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd + languageName: node + linkType: hard + +"@standard-schema/spec@npm:^1.1.0": + version: 1.1.0 + resolution: "@standard-schema/spec@npm:1.1.0" + checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526 + languageName: node + linkType: hard + "@tokenizer/inflate@npm:^0.2.6": version: 0.2.7 resolution: "@tokenizer/inflate@npm:0.2.7" @@ -1129,6 +1306,15 @@ __metadata: languageName: node linkType: hard +"@tybys/wasm-util@npm:^0.10.3": + version: 0.10.3 + resolution: "@tybys/wasm-util@npm:0.10.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/fd2bd2a79c6cd8c79ed1cf7a0fa375c64589264c88a27acaf9756d556b453ea222b62a4f68dd2fbb8b3a78b6bab3b1f4fb2431b6afc6aeda8344b53a521a1cd3 + languageName: node + linkType: hard + "@types/aws-lambda@npm:^8.10.83": version: 8.10.147 resolution: "@types/aws-lambda@npm:8.10.147" @@ -1136,6 +1322,30 @@ __metadata: languageName: node linkType: hard +"@types/chai@npm:^5.2.2": + version: 5.2.3 + resolution: "@types/chai@npm:5.2.3" + dependencies: + "@types/deep-eql": "npm:*" + assertion-error: "npm:^2.0.1" + checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f + languageName: node + linkType: hard + +"@types/deep-eql@npm:*": + version: 4.0.2 + resolution: "@types/deep-eql@npm:4.0.2" + checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844 + languageName: node + linkType: hard + +"@types/estree@npm:^1.0.0": + version: 1.0.9 + resolution: "@types/estree@npm:1.0.9" + checksum: 10c0/3ad3286ca2988cd550dafb8f2ad599c8474868e954fa601a36655bdfefd8039f7c714b8c1c7f2ae219ffbd58bd4660e66fa7479a0120fc02d4777057d4865387 + languageName: node + linkType: hard + "@types/js-yaml@npm:4.0.9": version: 4.0.9 resolution: "@types/js-yaml@npm:4.0.9" @@ -1143,16 +1353,6 @@ __metadata: languageName: node linkType: hard -"@types/node-fetch@npm:2.6.13": - version: 2.6.13 - resolution: "@types/node-fetch@npm:2.6.13" - dependencies: - "@types/node": "npm:*" - form-data: "npm:^4.0.4" - checksum: 10c0/6313c89f62c50bd0513a6839cdff0a06727ac5495ccbb2eeda51bb2bbbc4f3c0a76c0393a491b7610af703d3d2deb6cf60e37e59c81ceeca803ffde745dbf309 - languageName: node - linkType: hard - "@types/node@npm:*": version: 24.10.1 resolution: "@types/node@npm:24.10.1" @@ -1211,6 +1411,88 @@ __metadata: languageName: node linkType: hard +"@vitest/expect@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/expect@npm:4.1.9" + dependencies: + "@standard-schema/spec": "npm:^1.1.0" + "@types/chai": "npm:^5.2.2" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + chai: "npm:^6.2.2" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/243bacaed2cba5e0ea4ec7465662fcec465a358a0e06381e337fac49426aa67a73b104fbb9d65d8bccadfba8f70e27f57ffb897aacfa140f579a556367357875 + languageName: node + linkType: hard + +"@vitest/mocker@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/mocker@npm:4.1.9" + dependencies: + "@vitest/spy": "npm:4.1.9" + estree-walker: "npm:^3.0.3" + magic-string: "npm:^0.30.21" + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + checksum: 10c0/707353b7435bbfd441cc754e4ee7bc5921b70d07b051c6e414b6bbe4ca369154702b0ddeb603389469fe87ca1983e002eb2d55044582661f54a1945dd27e5c82 + languageName: node + linkType: hard + +"@vitest/pretty-format@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/pretty-format@npm:4.1.9" + dependencies: + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/5b96295f25ab885616230ad1355fc82f490bebb39cc707688d7c8969c08270d7e076ed8a10af4e762ed57145193c6061a1f549f136f0ded344f8db0c2b3fb3de + languageName: node + linkType: hard + +"@vitest/runner@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/runner@npm:4.1.9" + dependencies: + "@vitest/utils": "npm:4.1.9" + pathe: "npm:^2.0.3" + checksum: 10c0/d206b4891a64b1f55c346f832b0a7b489108094d8ae34438d3b53e78be7b45b139fa95ffa027c98c357bd532268ee573168de1943235b7eed32a9236ed5978bb + languageName: node + linkType: hard + +"@vitest/snapshot@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/snapshot@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + magic-string: "npm:^0.30.21" + pathe: "npm:^2.0.3" + checksum: 10c0/c3099df12ad1f9c1e180441856c9eb82f1990f87ff16aafedd6fa19978eaff20bc59220b692a99fcc822daef86eab256ba3dadb49544b7bd625b57c49cd9d995 + languageName: node + linkType: hard + +"@vitest/spy@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/spy@npm:4.1.9" + checksum: 10c0/e51f328f55b76e8ba66e5e18f183484a8dc0a092685b101112d3e9fb8e989ddca162c98ddf00254476502c25bc05c4ec1e277fd6ad8bfc702464c08f6b5dd115 + languageName: node + linkType: hard + +"@vitest/utils@npm:4.1.9": + version: 4.1.9 + resolution: "@vitest/utils@npm:4.1.9" + dependencies: + "@vitest/pretty-format": "npm:4.1.9" + convert-source-map: "npm:^2.0.0" + tinyrainbow: "npm:^3.1.0" + checksum: 10c0/d55506c077fd72c091eb66f02926f0abf72801c87a085f565698289562f47befa114ae2c680ab8736dfe46abab0cfd6b8031f2ac519bafeb37578aa6e5ad03c5 + languageName: node + linkType: hard + "@xhmikosr/decompress-tar@npm:^8.1.0": version: 8.1.0 resolution: "@xhmikosr/decompress-tar@npm:8.1.0" @@ -1358,6 +1640,13 @@ __metadata: languageName: node linkType: hard +"assertion-error@npm:^2.0.1": + version: 2.0.1 + resolution: "assertion-error@npm:2.0.1" + checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8 + languageName: node + linkType: hard + "async-function@npm:^1.0.0": version: 1.0.0 resolution: "async-function@npm:1.0.0" @@ -1706,6 +1995,13 @@ __metadata: languageName: node linkType: hard +"chai@npm:^6.2.2": + version: 6.2.2 + resolution: "chai@npm:6.2.2" + checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53 + languageName: node + linkType: hard + "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -1796,7 +2092,7 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8, combined-stream@npm:~1.0.6": +"combined-stream@npm:^1.0.6, combined-stream@npm:~1.0.6": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" dependencies: @@ -1826,6 +2122,13 @@ __metadata: languageName: node linkType: hard +"convert-source-map@npm:^2.0.0": + version: 2.0.0 + resolution: "convert-source-map@npm:2.0.0" + checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b + languageName: node + linkType: hard + "core-util-is@npm:1.0.2": version: 1.0.2 resolution: "core-util-is@npm:1.0.2" @@ -2007,6 +2310,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + "detect-node@npm:^2.0.4": version: 2.1.0 resolution: "detect-node@npm:2.1.0" @@ -2039,7 +2349,6 @@ __metadata: "@playwright/test": "npm:1.61.0" "@types/js-yaml": "npm:4.0.9" "@types/node": "npm:24.13.2" - "@types/node-fetch": "npm:2.6.13" "@types/pg": "npm:8.20.0" eslint-plugin-check-file: "npm:3.3.1" eslint-plugin-playwright: "npm:2.10.4" @@ -2057,6 +2366,7 @@ __metadata: shellcheck: "npm:4.1.0" typescript: "npm:6.0.3" uuid: "npm:14.0.1" + vitest: "npm:^4.1.9" winston: "npm:3.14.2" languageName: unknown linkType: soft @@ -2170,6 +2480,13 @@ __metadata: languageName: node linkType: hard +"es-module-lexer@npm:^2.0.0": + version: 2.1.0 + resolution: "es-module-lexer@npm:2.1.0" + checksum: 10c0/93bcf2454fa72d67fe3ccd0abef8ce7933f5840a319513418a643dd8e9c6aa8f49709cecfae02ded722805dd327232d30723a807cc52e6809d6ac697c62c29fb + languageName: node + linkType: hard + "es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1": version: 1.1.1 resolution: "es-object-atoms@npm:1.1.1" @@ -2179,18 +2496,6 @@ __metadata: languageName: node linkType: hard -"es-set-tostringtag@npm:^2.1.0": - version: 2.1.0 - resolution: "es-set-tostringtag@npm:2.1.0" - dependencies: - es-errors: "npm:^1.3.0" - get-intrinsic: "npm:^1.2.6" - has-tostringtag: "npm:^1.0.2" - hasown: "npm:^2.0.2" - checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af - languageName: node - linkType: hard - "es6-error@npm:^4.1.1": version: 4.1.1 resolution: "es6-error@npm:4.1.1" @@ -2228,6 +2533,15 @@ __metadata: languageName: node linkType: hard +"estree-walker@npm:^3.0.3": + version: 3.0.3 + resolution: "estree-walker@npm:3.0.3" + dependencies: + "@types/estree": "npm:^1.0.0" + checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d + languageName: node + linkType: hard + "events-universal@npm:^1.0.0": version: 1.0.1 resolution: "events-universal@npm:1.0.1" @@ -2237,6 +2551,13 @@ __metadata: languageName: node linkType: hard +"expect-type@npm:^1.3.0": + version: 1.3.0 + resolution: "expect-type@npm:1.3.0" + checksum: 10c0/8412b3fe4f392c420ab41dae220b09700e4e47c639a29ba7ba2e83cc6cffd2b4926f7ac9e47d7e277e8f4f02acda76fd6931cb81fd2b382fa9477ef9ada953fd + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -2302,6 +2623,18 @@ __metadata: languageName: node linkType: hard +"fdir@npm:^6.5.0": + version: 6.5.0 + resolution: "fdir@npm:6.5.0" + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + checksum: 10c0/e345083c4306b3aed6cb8ec551e26c36bab5c511e99ea4576a16750ddc8d3240e63826cc624f5ae17ad4dc82e68a253213b60d556c11bfad064b7607847ed07f + languageName: node + linkType: hard + "fecha@npm:^4.2.0": version: 4.2.3 resolution: "fecha@npm:4.2.3" @@ -2400,19 +2733,6 @@ __metadata: languageName: node linkType: hard -"form-data@npm:^4.0.4": - version: 4.0.6 - resolution: "form-data@npm:4.0.6" - dependencies: - asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.4" - mime-types: "npm:^2.1.35" - checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff - languageName: node - linkType: hard - "form-data@npm:~2.3.2": version: 2.3.3 resolution: "form-data@npm:2.3.3" @@ -2459,6 +2779,16 @@ __metadata: languageName: node linkType: hard +"fsevents@npm:~2.3.3": + version: 2.3.3 + resolution: "fsevents@npm:2.3.3" + dependencies: + node-gyp: "npm:latest" + checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60 + conditions: os=darwin + languageName: node + linkType: hard + "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin": version: 2.3.2 resolution: "fsevents@patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1" @@ -2468,6 +2798,15 @@ __metadata: languageName: node linkType: hard +"fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": + version: 2.3.3 + resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" + dependencies: + node-gyp: "npm:latest" + conditions: os=darwin + languageName: node + linkType: hard + "function-bind@npm:^1.1.2": version: 1.1.2 resolution: "function-bind@npm:1.1.2" @@ -2482,7 +2821,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.3.0": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" dependencies: @@ -2658,15 +2997,6 @@ __metadata: languageName: node linkType: hard -"hasown@npm:^2.0.4": - version: 2.0.4 - resolution: "hasown@npm:2.0.4" - dependencies: - function-bind: "npm:^1.1.2" - checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506 - languageName: node - linkType: hard - "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -3129,6 +3459,126 @@ __metadata: languageName: node linkType: hard +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:^1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + languageName: node + linkType: hard + "lodash.includes@npm:^4.3.0": version: 4.3.0 resolution: "lodash.includes@npm:4.3.0" @@ -3206,6 +3656,15 @@ __metadata: languageName: node linkType: hard +"magic-string@npm:^0.30.21": + version: 0.30.21 + resolution: "magic-string@npm:0.30.21" + dependencies: + "@jridgewell/sourcemap-codec": "npm:^1.5.5" + checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a + languageName: node + linkType: hard + "make-dir@npm:^1.0.0": version: 1.3.0 resolution: "make-dir@npm:1.3.0" @@ -3277,7 +3736,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.19": +"mime-types@npm:^2.1.12, mime-types@npm:~2.1.19": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -3443,6 +3902,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.12": + version: 3.3.15 + resolution: "nanoid@npm:3.3.15" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/e0b12e3a1d361f74150fa4b25631d0ae29f7162dab01a12f0f1be1f53b7a2a219f9b729504e474d4821207d0fe349bd3c97569ab5cf7ec2fff6aa94711956c93 + languageName: node + linkType: hard + "negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" @@ -3523,6 +3991,13 @@ __metadata: languageName: node linkType: hard +"obug@npm:^2.1.1": + version: 2.1.3 + resolution: "obug@npm:2.1.3" + checksum: 10c0/cb8187fed0a5fc8445507c950e89f3c1bd43895658c398b5803f6b7804dfa0c562975ecce1e67f3d9247d521452a5bfade9e0e951cc0326b7444272f7c24d25f + languageName: node + linkType: hard + "octokit@npm:4.1.4": version: 4.1.4 resolution: "octokit@npm:4.1.4" @@ -3808,6 +4283,13 @@ __metadata: languageName: node linkType: hard +"pathe@npm:^2.0.3": + version: 2.0.3 + resolution: "pathe@npm:2.0.3" + checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1 + languageName: node + linkType: hard + "pend@npm:~1.2.0": version: 1.2.0 resolution: "pend@npm:1.2.0" @@ -3910,6 +4392,13 @@ __metadata: languageName: node linkType: hard +"picocolors@npm:^1.1.1": + version: 1.1.1 + resolution: "picocolors@npm:1.1.1" + checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 + languageName: node + linkType: hard + "picomatch@npm:^2.3.1": version: 2.3.1 resolution: "picomatch@npm:2.3.1" @@ -3917,6 +4406,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + "pify@npm:^2.3.0": version: 2.3.0 resolution: "pify@npm:2.3.0" @@ -3978,6 +4474,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.5.15": + version: 8.5.15 + resolution: "postcss@npm:8.5.15" + dependencies: + nanoid: "npm:^3.3.12" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/7f2e63ae22fbe43aace1bf652bd99da4e90737c64194d49e51ddc9cd0f9e51ff2861a7d734379b494deffa03a880a5c65eec70bc29ee9ebaa7136dde3eee8f31 + languageName: node + linkType: hard + "postgres-array@npm:~2.0.0": version: 2.0.0 resolution: "postgres-array@npm:2.0.0" @@ -4169,6 +4676,64 @@ __metadata: languageName: node linkType: hard +"rolldown@npm:~1.1.2": + version: 1.1.3 + resolution: "rolldown@npm:1.1.3" + dependencies: + "@oxc-project/types": "npm:=0.137.0" + "@rolldown/binding-android-arm64": "npm:1.1.3" + "@rolldown/binding-darwin-arm64": "npm:1.1.3" + "@rolldown/binding-darwin-x64": "npm:1.1.3" + "@rolldown/binding-freebsd-x64": "npm:1.1.3" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.1.3" + "@rolldown/binding-linux-arm64-gnu": "npm:1.1.3" + "@rolldown/binding-linux-arm64-musl": "npm:1.1.3" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.1.3" + "@rolldown/binding-linux-s390x-gnu": "npm:1.1.3" + "@rolldown/binding-linux-x64-gnu": "npm:1.1.3" + "@rolldown/binding-linux-x64-musl": "npm:1.1.3" + "@rolldown/binding-openharmony-arm64": "npm:1.1.3" + "@rolldown/binding-wasm32-wasi": "npm:1.1.3" + "@rolldown/binding-win32-arm64-msvc": "npm:1.1.3" + "@rolldown/binding-win32-x64-msvc": "npm:1.1.3" + "@rolldown/pluginutils": "npm:^1.0.0" + dependenciesMeta: + "@rolldown/binding-android-arm64": + optional: true + "@rolldown/binding-darwin-arm64": + optional: true + "@rolldown/binding-darwin-x64": + optional: true + "@rolldown/binding-freebsd-x64": + optional: true + "@rolldown/binding-linux-arm-gnueabihf": + optional: true + "@rolldown/binding-linux-arm64-gnu": + optional: true + "@rolldown/binding-linux-arm64-musl": + optional: true + "@rolldown/binding-linux-ppc64-gnu": + optional: true + "@rolldown/binding-linux-s390x-gnu": + optional: true + "@rolldown/binding-linux-x64-gnu": + optional: true + "@rolldown/binding-linux-x64-musl": + optional: true + "@rolldown/binding-openharmony-arm64": + optional: true + "@rolldown/binding-wasm32-wasi": + optional: true + "@rolldown/binding-win32-arm64-msvc": + optional: true + "@rolldown/binding-win32-x64-msvc": + optional: true + bin: + rolldown: ./bin/cli.mjs + checksum: 10c0/6dae11bee45c56d000d5d2608ac78b2c7125b7f10337e0b0bbdee7290c352104f1f76072f8c0e6ccad331f51f1a131fc37faa179d9c4a10cc16abc87f85f6e86 + languageName: node + linkType: hard + "run-applescript@npm:^7.0.0": version: 7.0.0 resolution: "run-applescript@npm:7.0.0" @@ -4295,6 +4860,13 @@ __metadata: languageName: node linkType: hard +"siginfo@npm:^2.0.0": + version: 2.0.0 + resolution: "siginfo@npm:2.0.0" + checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34 + languageName: node + linkType: hard + "signal-exit@npm:^4.0.1": version: 4.1.0 resolution: "signal-exit@npm:4.1.0" @@ -4339,6 +4911,13 @@ __metadata: languageName: node linkType: hard +"source-map-js@npm:^1.2.1": + version: 1.2.1 + resolution: "source-map-js@npm:1.2.1" + checksum: 10c0/7bda1fc4c197e3c6ff17de1b8b2c20e60af81b63a52cb32ec5a5d67a20a7d42651e2cb34ebe93833c5a2a084377e17455854fee3e21e7925c64a51b6a52b0faf + languageName: node + linkType: hard + "split2@npm:^4.1.0": version: 4.2.0 resolution: "split2@npm:4.2.0" @@ -4390,6 +4969,13 @@ __metadata: languageName: node linkType: hard +"stackback@npm:0.0.2": + version: 0.0.2 + resolution: "stackback@npm:0.0.2" + checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983 + languageName: node + linkType: hard + "standard-as-callback@npm:2.1.0": version: 2.1.0 resolution: "standard-as-callback@npm:2.1.0" @@ -4397,6 +4983,13 @@ __metadata: languageName: node linkType: hard +"std-env@npm:^4.0.0-rc.1": + version: 4.1.0 + resolution: "std-env@npm:4.1.0" + checksum: 10c0/2e14b6b490db34cb969a48d9cf7c35bca4a47653914aac2814221baae7b867a5b15940d133625c391621971f98cd2266a5dc7036669960e883f1081db2a56558 + languageName: node + linkType: hard + "stream-buffers@npm:^3.0.2": version: 3.0.2 resolution: "stream-buffers@npm:3.0.2" @@ -4594,6 +5187,30 @@ __metadata: languageName: node linkType: hard +"tinybench@npm:^2.9.0": + version: 2.9.0 + resolution: "tinybench@npm:2.9.0" + checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c + languageName: node + linkType: hard + +"tinyexec@npm:^1.0.2": + version: 1.2.4 + resolution: "tinyexec@npm:1.2.4" + checksum: 10c0/153b8db6b080194b558ff145b9cffc36b80a6e07babd644dcfbe49c807eee668c876049d28bdee90b96304476f883352f2dad91b3f86bc23832532f4363e66ff + languageName: node + linkType: hard + +"tinyglobby@npm:^0.2.15, tinyglobby@npm:^0.2.17": + version: 0.2.17 + resolution: "tinyglobby@npm:0.2.17" + dependencies: + fdir: "npm:^6.5.0" + picomatch: "npm:^4.0.4" + checksum: 10c0/7f7bb0f197c88bc4b20c231e0deca4240ca3bf313a88f5a7fee93a872b84966a4d50220947c0455ad07a60b3b360961c5b7fd979222aeb716a9f99b412002e4c + languageName: node + linkType: hard + "tinypool@npm:2.1.0": version: 2.1.0 resolution: "tinypool@npm:2.1.0" @@ -4601,6 +5218,13 @@ __metadata: languageName: node linkType: hard +"tinyrainbow@npm:^3.1.0": + version: 3.1.0 + resolution: "tinyrainbow@npm:3.1.0" + checksum: 10c0/f11cf387a26c5c9255bec141a90ac511b26172981b10c3e50053bc6700ea7d2336edcc4a3a21dbb8412fe7c013477d2ba4d7e4877800f3f8107be5105aad6511 + languageName: node + linkType: hard + "to-buffer@npm:^1.1.1": version: 1.2.2 resolution: "to-buffer@npm:1.2.2" @@ -4663,7 +5287,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:2.8.1, tslib@npm:^2.2.0, tslib@npm:^2.4.1, tslib@npm:^2.6.2, tslib@npm:^2.8.1": +"tslib@npm:2.8.1, tslib@npm:^2.2.0, tslib@npm:^2.4.0, tslib@npm:^2.4.1, tslib@npm:^2.6.2, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 @@ -4855,6 +5479,131 @@ __metadata: languageName: node linkType: hard +"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0": + version: 8.1.0 + resolution: "vite@npm:8.1.0" + dependencies: + fsevents: "npm:~2.3.3" + lightningcss: "npm:^1.32.0" + picomatch: "npm:^4.0.4" + postcss: "npm:^8.5.15" + rolldown: "npm:~1.1.2" + tinyglobby: "npm:^0.2.17" + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + "@vitejs/devtools": ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: ">=1.21.0" + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + dependenciesMeta: + fsevents: + optional: true + peerDependenciesMeta: + "@types/node": + optional: true + "@vitejs/devtools": + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + bin: + vite: bin/vite.js + checksum: 10c0/d7e2da70169a7d93c68f5d0e246bdf2fb35d8835170663a28f01191d73e16b80322b5f229973ce754de80136be843481b0afa616bb8530b51e7454e4887c4b45 + languageName: node + linkType: hard + +"vitest@npm:^4.1.9": + version: 4.1.9 + resolution: "vitest@npm:4.1.9" + dependencies: + "@vitest/expect": "npm:4.1.9" + "@vitest/mocker": "npm:4.1.9" + "@vitest/pretty-format": "npm:4.1.9" + "@vitest/runner": "npm:4.1.9" + "@vitest/snapshot": "npm:4.1.9" + "@vitest/spy": "npm:4.1.9" + "@vitest/utils": "npm:4.1.9" + es-module-lexer: "npm:^2.0.0" + expect-type: "npm:^1.3.0" + magic-string: "npm:^0.30.21" + obug: "npm:^2.1.1" + pathe: "npm:^2.0.3" + picomatch: "npm:^4.0.3" + std-env: "npm:^4.0.0-rc.1" + tinybench: "npm:^2.9.0" + tinyexec: "npm:^1.0.2" + tinyglobby: "npm:^0.2.15" + tinyrainbow: "npm:^3.1.0" + vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0" + why-is-node-running: "npm:^2.3.0" + peerDependencies: + "@edge-runtime/vm": "*" + "@opentelemetry/api": ^1.9.0 + "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0 + "@vitest/browser-playwright": 4.1.9 + "@vitest/browser-preview": 4.1.9 + "@vitest/browser-webdriverio": 4.1.9 + "@vitest/coverage-istanbul": 4.1.9 + "@vitest/coverage-v8": 4.1.9 + "@vitest/ui": 4.1.9 + happy-dom: "*" + jsdom: "*" + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@opentelemetry/api": + optional: true + "@types/node": + optional: true + "@vitest/browser-playwright": + optional: true + "@vitest/browser-preview": + optional: true + "@vitest/browser-webdriverio": + optional: true + "@vitest/coverage-istanbul": + optional: true + "@vitest/coverage-v8": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vite: + optional: false + bin: + vitest: ./vitest.mjs + checksum: 10c0/1ac80ef4991be82822a52aea48415f1bc64ddf8fd88ee24c172ec368f1d480fefacbde622c3c951982f7961a1d07313e18deaafc774d29e42ad6f6ffa63334a7 + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1" @@ -4916,6 +5665,18 @@ __metadata: languageName: node linkType: hard +"why-is-node-running@npm:^2.3.0": + version: 2.3.0 + resolution: "why-is-node-running@npm:2.3.0" + dependencies: + siginfo: "npm:^2.0.0" + stackback: "npm:0.0.2" + bin: + why-is-node-running: cli.js + checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054 + languageName: node + linkType: hard + "winston-transport@npm:^4.7.0": version: 4.7.0 resolution: "winston-transport@npm:4.7.0"