diff --git a/.github/workflows/e2e-tests-lint.yaml b/.github/workflows/e2e-tests-lint.yaml index f8d6dbdaaa..6b81f1f409 100644 --- a/.github/workflows/e2e-tests-lint.yaml +++ b/.github/workflows/e2e-tests-lint.yaml @@ -41,6 +41,10 @@ jobs: working-directory: ./e2e-tests run: yarn test:list + - name: Run unit tests + working-directory: ./e2e-tests + run: yarn test:unit + - name: Run ShellCheck working-directory: ./e2e-tests run: yarn shellcheck diff --git a/docs/e2e-tests/CONTRIBUTING.MD b/docs/e2e-tests/CONTRIBUTING.MD index 7353956d4a..46e71e6d3d 100644 --- a/docs/e2e-tests/CONTRIBUTING.MD +++ b/docs/e2e-tests/CONTRIBUTING.MD @@ -54,6 +54,11 @@ These principles are valid for new contributions. Some parts of the codebase may We follow Playwright best practices, including the use of fixtures. Adhering to these practices ensures that our tests are reliable, efficient, and maintainable. Please refer to the [Playwright Best Practices](https://playwright.dev/docs/best-practices) and [Fixtures](https://playwright.dev/docs/test-fixtures) documentation for guidance. + - Prefer the shared fixtures from `@support/coverage/test` instead of re-implementing login/setup in each spec: + - Use `guestPage` for ordinary specs that need a fresh guest login per test. + - Use `rhdhGuestPage` only when a describe block intentionally shares one browser context/page across tests. + - If you use `rhdhPage`, `rhdhGuestPage`, `rhdhContext`, or other worker-scoped fixtures across multiple tests in one file, make the suite serial so state sharing is explicit. + 3. **Avoid Using `uiHelper` in Spec Files** - The `uiHelper` utility should not be used directly in spec files. The reason for that is that some methods in this class are too generic and sometimes it is difficult to point what they are intended. Idellay, they shall be called from inside a POM that states what thay are looking for. @@ -61,7 +66,12 @@ These principles are valid for new contributions. Some parts of the codebase may - When working with tests that directly use `uiHelper` in spec files, refactor them to move `uiHelper` usage into POM classes. - This ensures that all UI interactions are encapsulated within page objects, promoting cleaner and more maintainable test code. -4. **Use External Sources for Validation** +4. **Blocked Flows** + + - If a flow is blocked by a known product issue, keep it out of the default `*.spec.ts` Playwright discovery path until it can run deterministically again. + - Preserve the Jira or bug reference in the filename comments and/or documentation so the test can be restored once the blocker is fixed. + +5. **Use External Sources for Validation** - **Avoid Hardcoded Data in Tests** 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 94c5e94596..2e0b96d396 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", @@ -23,6 +24,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", @@ -49,7 +51,6 @@ "@playwright/test": "1.59.1", "@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", @@ -60,7 +61,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 2a465a07a3..0c4964c14b 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -34,14 +34,15 @@ 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 */ retries: process.env.CI !== undefined && process.env.CI !== "" ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: 3, + /* Keep a small shared worker pool; stateful projects override this to 1. */ + workers: process.env.CI !== undefined && process.env.CI !== "" ? 3 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ // Coverage reporter (RHIDP-13243) is appended only when COLLECT_COVERAGE=true; // otherwise it is not registered at all and the default reporters run alone. @@ -58,14 +59,13 @@ export default defineConfig({ locale: process.env.LOCALE ?? "en", baseURL: process.env.BASE_URL, ignoreHTTPSErrors: true, - trace: "on", + trace: "on-first-retry", screenshot: "on", ...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 @@ -173,6 +174,7 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_RUNTIME_DB, + timeout: 600 * 1000, workers: 1, testMatch: [ "**/playwright/e2e/external-database/verify-tls-config-with-external-rds.spec.ts", @@ -181,6 +183,7 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_RUNTIME, + timeout: 900 * 1000, workers: 1, dependencies: [PW_PROJECT.SHOWCASE_RUNTIME_DB], testMatch: [ @@ -210,6 +213,7 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_DE, + dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "de", }, @@ -221,6 +225,7 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_ES, + dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "es", }, @@ -232,6 +237,7 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_FR, + dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "fr", }, @@ -243,6 +249,7 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_IT, + dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "it", }, @@ -254,6 +261,7 @@ export default defineConfig({ }, { name: PW_PROJECT.SHOWCASE_LOCALIZATION_JA, + dependencies: [PW_PROJECT.SMOKE_TEST], use: { locale: "ja", }, diff --git a/e2e-tests/playwright/blocked/github-happy-path.blocked.ts b/e2e-tests/playwright/blocked/github-happy-path.blocked.ts new file mode 100644 index 0000000000..bb6627757c --- /dev/null +++ b/e2e-tests/playwright/blocked/github-happy-path.blocked.ts @@ -0,0 +1,10 @@ +/** + * Historical GitHub happy-path coverage retained outside the default E2E suite. + * + * RHDHBUGS-2099 blocks the flow today, so this file intentionally does not use + * the `*.spec.ts` suffix and will not be picked up by Playwright discovery. + * Restore it as an executable spec once the underlying catalog/entity issues are + * fixed and the flow can be made deterministic again. + */ + +export const GITHUB_HAPPY_PATH_BLOCKER = "RHDHBUGS-2099"; 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..840d5509a6 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,10 @@ 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,8 +26,7 @@ async function ensureEntityDoesNotExist() { } test.describe.serial("Audit Log check for Catalog Plugin", () => { - let uiHelper: UIhelper; - let common: Common; + let selfServicePage: SelfServicePage; let catalogImport: CatalogImport; test.beforeAll(() => { @@ -37,17 +36,15 @@ test.describe.serial("Audit Log check for Catalog Plugin", () => { }); }); - test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); - common = new Common(page); - catalogImport = new CatalogImport(page); - await common.loginAsGuest(); - await uiHelper.goToSelfServicePage(); + test.beforeEach(async ({ guestPage }) => { + selfServicePage = new SelfServicePage(guestPage); + catalogImport = new CatalogImport(guestPage); + 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 +61,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..a4b80a2c7d 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 { AuthProviderSession } from "../../support/auth/provider-auth"; import { RBAC_API, ROLE_NAME, @@ -17,7 +17,7 @@ import { const auditStatus = (ok: boolean): "succeeded" | "failed" => (ok ? "succeeded" : "failed"); -let common: Common; +let authSession: AuthProviderSession; let rbacApi: RhdhRbacApi; /* ======================================================================== */ @@ -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, rhdhAuthSession }) => { 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); - await common.loginAsKeycloakUser(); - rbacApi = await RhdhRbacApi.buildRbacApi(page); + authSession = rhdhAuthSession; + await authSession.loginWithKeycloak(process.env.GH_USER_ID ?? "", process.env.GH_USER_PASS ?? ""); + 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 bec084c7a0..52858b2895 100644 --- a/e2e-tests/playwright/e2e/audit-log/log-utils.ts +++ b/e2e-tests/playwright/e2e/audit-log/log-utils.ts @@ -4,6 +4,7 @@ import { type JsonObject } from "@backstage/types"; import { expect } from "@playwright/test"; import { getBackstageDeploySelector } from "../../utils/helper"; +import { sleep } from "../../utils/poll-until"; import { Log, type LogRequest, type EventStatus, type EventSeverityLevel } from "./logs"; function formatError(error: unknown): string { @@ -46,7 +47,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; @@ -55,47 +56,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 = { @@ -238,9 +209,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); } } @@ -302,7 +271,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..6e87d44e01 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 { test, expect, type Page, type BrowserContext } from "@support/coverage/test"; -import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; -import { Common, setupBrowser } from "../../utils/common"; +import { AuthProviderSession } from "../../support/auth/provider-auth"; +import { AuthProviderHarness } from "../../support/fixtures/auth-provider-harness"; +import { SettingsPage } from "../../support/pages/settings-page"; 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,311 +13,225 @@ GITHUB: [x] emailLocalPartMatchingUserEntityName */ -// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL -test.describe("Configure Github Provider", async () => { - let common: Common; - let uiHelper: UIhelper; +const harness = AuthProviderHarness.create("albarbaro-test-namespace-github"); - const namespace = "albarbaro-test-namespace-github"; - const appConfigMap = "app-config-rhdh"; - const rbacConfigMap = "rbac-policy"; - const dynamicPluginsConfigMap = "dynamic-plugins"; - const secretName = "rhdh-secrets"; +test.describe("Configure Github Provider", () => { + test.use({ baseURL: harness.backstageUrl }); - // set deployment instance - const deployment: RHDHDeployment = new RHDHDeployment( - namespace, - appConfigMap, - rbacConfigMap, - dynamicPluginsConfigMap, - secretName, - ); - deployment.instanceName = "rhdh"; + let authSession: AuthProviderSession; + let settingsPage: SettingsPage; + let page: Page; + let context: BrowserContext; - // compute backstage baseurl - const backstageUrl = await deployment.computeBackstageUrl(); - const backstageBackendUrl = await deployment.computeBackstageBackendUrl(); - console.log(`Backstage BaseURL is: ${backstageUrl}`); + async function clearSession(): Promise { + await authSession.clearAuthState(context); + } - test.use({ baseURL: backstageUrl }); + function loginAsGithubAdmin(): Promise { + return authSession.loginWithGitHub( + "rhdhqeauthadmin", + process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, + process.env.AUTH_PROVIDERS_GH_ADMIN_2FA!, + ); + } - test.beforeAll(async ({ browser }, testInfo) => { + function loginAsGithubUser(): Promise { + return authSession.loginWithGitHub( + "rhdhqeauth1", + process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, + process.env.AUTH_PROVIDERS_GH_USER_2FA!, + ); + } + + test.beforeAll(async ({ rhdhPage, rhdhContext, rhdhAuthSession }) => { 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( - "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_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(); - - // enable github login with ingestion - console.log("[TEST] Enabling GitHub login with ingestion..."); - await deployment.enableGithubLoginWithIngestion(); - await 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(); + page = rhdhPage; + context = rhdhContext; + authSession = rhdhAuthSession; + settingsPage = new SettingsPage(rhdhPage); + + await harness.prepareProvider({ + requiredEnvVars: [ + "AUTH_PROVIDERS_GH_ORG_NAME", + "AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET", + "AUTH_PROVIDERS_GH_ORG_CLIENT_ID", + "AUTH_PROVIDERS_GH_USER_PASSWORD", + "AUTH_PROVIDERS_GH_USER_2FA", + "AUTH_PROVIDERS_GH_ADMIN_2FA", + "AUTH_PROVIDERS_GH_ORG_APP_ID", + "AUTH_PROVIDERS_GH_ORG1_PRIVATE_KEY", + "AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET", + ], + envSecrets: { + 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", + }, + enableProvider: async (deployment) => { + console.log("[TEST] Enabling GitHub login with ingestion..."); + await deployment.enableGithubLoginWithIngestion(); + console.log("[TEST] GitHub login with ingestion enabled successfully"); + }, + }); }); test.beforeEach(() => { - test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with Github default resolver", async () => { - const login = await common.githubLogin( - "rhdhqeauthadmin", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, - process.env.AUTH_PROVIDERS_GH_ADMIN_2FA!, - ); - expect(login).toBe("Login successful"); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("RHDH QE Admin"); - await common.signOut(); - await context.clearCookies(); + await harness.runLoginCase({ + login: loginAsGithubAdmin, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("RHDH QE Admin"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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(); - - const login = await common.githubLogin( - "rhdhqeauthadmin", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, - process.env.AUTH_PROVIDERS_GH_ADMIN_2FA!, - ); - expect(login).toBe("Login successful"); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("RHDH QE Admin"); - await common.signOut(); - await context.clearCookies(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setGithubResolver("usernameMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsGithubAdmin, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("RHDH QE Admin"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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(); - - const login = await common.githubLogin( - "rhdhqeauth1", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, - process.env.AUTH_PROVIDERS_GH_USER_2FA!, - ); - expect(login).toBe("Login successful"); - - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); - await context.clearCookies(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setGithubResolver("emailMatchingUserEntityProfileEmail", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsGithubUser, + assert: async () => { + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + }, + cleanup: clearSession, + }); }); 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(); - - const login = await common.githubLogin( - "rhdhqeauth1", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, - process.env.AUTH_PROVIDERS_GH_USER_2FA!, - ); - - // Login failed; caused by Error: Login failed, user profile does not contain an email - - expect(login).toBe("Login successful"); - - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); - await context.clearCookies(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setGithubResolver("emailLocalPartMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsGithubUser, + assert: async () => { + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + }, + cleanup: clearSession, + }); }); 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(); - - const login = await common.githubLogin( - "rhdhqeauthadmin", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, - process.env.AUTH_PROVIDERS_GH_ADMIN_2FA!, - ); - expect(login).toBe("Login successful"); - - await page.reload(); - - const cookies = await context.cookies(); - const authCookie = cookies.find((cookie) => cookie.name === "github-refresh-token"); - expect(authCookie).toBeDefined(); - - // expected duration of 3 days in ms - const threeDays = 3 * 24 * 60 * 60 * 1000; - // allow for 3 minutes tolerance - const tolerance = 3 * 60 * 1000; - - const actualDuration = authCookie!.expires * 1000 - Date.now(); - - expect(actualDuration).toBeGreaterThan(threeDays - tolerance); - expect(actualDuration).toBeLessThan(threeDays + tolerance); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("RHDH QE Admin"); - await common.signOut(); - await context.clearCookies(); + await harness.runLoginCase({ + configure: async () => { + harness.deployment.setAppConfigProperty( + "auth.providers.github.production.sessionDuration", + "3days", + ); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsGithubAdmin, + assert: async () => { + await page.reload(); + + const cookies = await context.cookies(); + const authCookie = cookies.find((cookie) => cookie.name === "github-refresh-token"); + expect(authCookie).toBeDefined(); + + const threeDays = 3 * 24 * 60 * 60 * 1000; + const tolerance = 3 * 60 * 1000; + const actualDuration = authCookie!.expires * 1000 - Date.now(); + + expect(actualDuration).toBeGreaterThan(threeDays - tolerance); + expect(actualDuration).toBeLessThan(threeDays + tolerance); + + await settingsPage.open(); + await settingsPage.verifyProfileHeading("RHDH QE Admin"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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( - "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(); - - const login = await common.githubLogin( - "rhdhqeauth1", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, - process.env.AUTH_PROVIDERS_GH_USER_2FA!, - ); - - expect(login).toBe("Login successful"); - - await uiHelper.verifyAlertErrorMessage( - /Login failed; caused by Error: The GitHub provider is not configured to support sign-in/u, - ); - await context.clearCookies(); + await harness.runLoginCase({ + configure: async () => { + harness.deployment.setAppConfigProperty( + "auth.providers.github.production.disableIdentityResolution", + "true", + ); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsGithubUser, + assert: async () => { + await settingsPage.verifySignInError( + /Login failed; caused by Error: The GitHub provider is not configured to support sign-in/u, + ); + }, + cleanup: clearSession, + }); }); 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..be62518848 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, type BrowserContext } from "@support/coverage/test"; +import { AuthProviderSession } from "../../support/auth/provider-auth"; +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; /* SUPORTED RESOLVERS GITLAB: @@ -15,152 +13,108 @@ GITLAB: [x] emailLocalPartMatchingUserEntityName */ -// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL -test.describe("Configure GitLab Provider", async () => { - let common: Common; - let uiHelper: UIhelper; +const harness = AuthProviderHarness.create("albarbaro-test-namespace-gitlab"); + +test.describe("Configure GitLab Provider", () => { + test.use({ baseURL: harness.backstageUrl }); + + let authSession: AuthProviderSession; + let settingsPage: SettingsPage; + let context: BrowserContext; let gitlabHelper: GitLabHelper; let oauthAppId: number | null = null; + let oauthClientId = ""; + let oauthClientSecret = ""; - 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) => { + async function clearSession(): Promise { + await authSession.clearAuthState(context); + } + + test.beforeAll(async ({ rhdhPage, rhdhContext, rhdhAuthSession }) => { 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); + context = rhdhContext; + authSession = rhdhAuthSession; + settingsPage = new SettingsPage(rhdhPage); - // 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(); - - // 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 oauthAppName = `rhdh-test-${Date.now()}`; - console.log(`[TEST] Creating GitLab OAuth application: ${oauthAppName}`); - const oauthApp = await gitlabHelper.createOAuthApplication( - oauthAppName, - callbackUrl, - "api read_user write_repository sudo", - // trusted = true to skip UI confirmation - true, - ); - 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 deployment.createSecret(); - - // enable gitlab login with ingestion - console.log("[TEST] Enabling GitLab login with ingestion..."); - await deployment.enableGitlabLoginWithIngestion(); - await 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.prepareProvider({ + requiredEnvVars: [ + "AUTH_PROVIDERS_GITLAB_HOST", + "AUTH_PROVIDERS_GITLAB_TOKEN", + "AUTH_PROVIDERS_GITLAB_PARENT_ORG", + "DEFAULT_USER_PASSWORD", + ], + beforeSecrets: async () => { + 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( + oauthAppName, + callbackUrl, + "api read_user write_repository sudo", + true, + ); + oauthAppId = oauthApp.id; + oauthClientId = oauthApp.application_id; + oauthClientSecret = oauthApp.secret; + console.log(`[TEST] GitLab OAuth application created - ID: ${oauthApp.application_id}`); + }, + envSecrets: { + 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", + }, + extraSecrets: () => ({ + AUTH_PROVIDERS_GITLAB_CLIENT_ID: oauthClientId, + AUTH_PROVIDERS_GITLAB_CLIENT_SECRET: oauthClientSecret, + }), + enableProvider: async (deployment) => { + console.log("[TEST] Enabling GitLab login with ingestion..."); + await deployment.enableGitlabLoginWithIngestion(); + console.log("[TEST] GitLab login with ingestion enabled successfully"); + }, + }); }); test.beforeEach(() => { - test.info().setTimeout(60 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with GitLab default resolver", 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 common.signOut(); - await context.clearCookies(); + await harness.runLoginCase({ + login: () => authSession.loginWithGitLab("user1", process.env.DEFAULT_USER_PASSWORD!), + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("user1"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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 +123,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 +170,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..93aa3f8f62 100644 --- a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts @@ -1,197 +1,137 @@ -import { test, expect, Page, BrowserContext } from "@support/coverage/test"; +import { test, expect } from "@support/coverage/test"; +import { AuthProviderSession } from "../../support/auth/provider-auth"; +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; /* 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 = 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 authSession: AuthProviderSession; + let settingsPage: SettingsPage; + let clearSession: (() => Promise) | undefined; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(async ({ rhdhPage, rhdhContext, rhdhAuthSession }) => { 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!); - - 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.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 deployment.createSecret(); - - // 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!, - process.env.AUTH_PROVIDERS_ARM_CLIENT_SECRET!, - process.env.AUTH_PROVIDERS_ARM_TENANT_ID!, - process.env.AUTH_PROVIDERS_ARM_SUBSCRIPTION_ID, - ); - - // Allow public IP in NSG for E2E testing - try { - const nsgConfig = await graphClient.allowPublicIpInNSG( - "ldap-test", - "ldap-test-nsg", - "AllowE2EJobs", - ); - console.log(`[TEST] NSG access configured successfully`); - console.log(`[TEST] Rule created: ${nsgConfig.ruleName} for IP: ${nsgConfig.publicIp}`); - - // Store cleanup function for afterAll - nsgCleanup = nsgConfig.cleanup; - } catch (error) { - console.error("[TEST] Failed to configure NSG access:", error); - // 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(); + authSession = rhdhAuthSession; + settingsPage = new SettingsPage(rhdhPage); + clearSession = async () => { + await authSession.clearAuthState(rhdhContext); + }; + + await harness.prepareProvider({ + requiredEnvVars: [ + "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", + ], + envSecrets: { + 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", + }, + extraSecrets: { + LDAP_GROUPS_DN: "OU=Groups,OU=RHDH Local,DC=rhdh,DC=test", + LDAP_USERS_DN: "OU=Users,OU=RHDH Local,DC=rhdh,DC=test", + }, + enableProvider: async (deployment) => { + await deployment.enableLDAPLoginWithIngestion(); + await deployment.setOIDCResolver("oidcLdapUuidMatchingAnnotation"); + }, + beforeDeploy: async () => { + console.log("[TEST] Configuring Microsoft Azure App Registration..."); + const graphClient = new MSClient( + process.env.AUTH_PROVIDERS_ARM_CLIENT_ID!, + process.env.AUTH_PROVIDERS_ARM_CLIENT_SECRET!, + process.env.AUTH_PROVIDERS_ARM_TENANT_ID!, + process.env.AUTH_PROVIDERS_ARM_SUBSCRIPTION_ID, + ); + + try { + const nsgConfig = await graphClient.allowPublicIpInNSG( + "ldap-test", + "ldap-test-nsg", + "AllowE2EJobs", + ); + console.log(`[TEST] NSG access configured successfully`); + console.log(`[TEST] Rule created: ${nsgConfig.ruleName} for IP: ${nsgConfig.publicIp}`); + nsgCleanup = nsgConfig.cleanup; + } catch (error) { + console.error("[TEST] Failed to configure NSG access:", error); + } + }, + }); }); test.beforeEach(() => { - test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with LDAP oidcLdapUuidMatchingAnnotation resolver", async () => { - const login = await common.keycloakLogin( - "user1@rhdh.test", - process.env.RHBK_LDAP_USER_PASSWORD!, - ); - expect(login).toBe("Login successful"); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("User 1"); - await common.signOut(); + await harness.runLoginCase({ + login: () => authSession.loginWithKeycloak("user1@rhdh.test", process.env.RHBK_LDAP_USER_PASSWORD!), + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("User 1"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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,69 +140,63 @@ 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(); - - 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 common.signOut(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.enablePingFederateOIDCLogin(); + await harness.reconcileAfterConfigChange(); + }, + login: () => authSession.loginWithPingFederate("user1", process.env.RHBK_LDAP_USER_PASSWORD!), + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("User 1"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); test("Login with PingFederate OIDC (with LDAP catalog) with sub as ldap_uuid", async () => { - await deployment.enablePingFederateOIDCLogin(); - - deployment.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ - { - resolver: "oidcLdapUuidMatchingAnnotation", - // match sub claim as required by OIDC spec - ldapUuidKey: "sub", + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.enablePingFederateOIDCLogin(); + harness.deployment.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ + { + resolver: "oidcLdapUuidMatchingAnnotation", + ldapUuidKey: "sub", + }, + ]); + await harness.reconcileAfterConfigChange(); }, - ]); - - 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(); - - 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 common.signOut(); + login: () => authSession.loginWithPingFederate("user1", process.env.RHBK_LDAP_USER_PASSWORD!), + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("User 1"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); test.afterAll(async () => { - console.log("[TEST] Starting cleanup..."); - - // Clean up NSG rule try { if (nsgCleanup) { console.log("[TEST] Cleaning up NSG rule..."); @@ -275,5 +209,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..9c367f35a8 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 { test, expect, type Page, type BrowserContext } from "@support/coverage/test"; +import { AuthProviderSession } from "../../support/auth/provider-auth"; +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 { 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,279 +14,205 @@ MICOROSFT: [-] emailLocalPartMatchingUserEntityName */ -// oxlint-disable-next-line eslint/require-await -- top-level await configures test.use baseURL -test.describe("Configure Microsoft Provider", async () => { - let common: Common; - let uiHelper: UIhelper; +const harness = AuthProviderHarness.create("albarbaro-test-namespace-msgraph"); - const namespace = "albarbaro-test-namespace-msgraph"; - const appConfigMap = "app-config-rhdh"; - const rbacConfigMap = "rbac-policy"; - const dynamicPluginsConfigMap = "dynamic-plugins"; - const secretName = "rhdh-secrets"; +test.describe("Configure Microsoft Provider", () => { + test.use({ baseURL: harness.backstageUrl }); - // set deployment instance - const deployment: RHDHDeployment = new RHDHDeployment( - namespace, - appConfigMap, - rbacConfigMap, - dynamicPluginsConfigMap, - secretName, - ); - deployment.instanceName = "rhdh"; + let authSession: AuthProviderSession; + let settingsPage: SettingsPage; + let page: Page; + let context: BrowserContext; - // compute backstage baseurl - const backstageUrl = await deployment.computeBackstageUrl(); - const backstageBackendUrl = await deployment.computeBackstageBackendUrl(); - console.log(`Backstage BaseURL is: ${backstageUrl}`); + async function clearSession(): Promise { + await authSession.clearAuthState(context); + } - test.use({ baseURL: backstageUrl }); - - test.beforeAll(async ({ browser }, testInfo) => { - 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( - "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!, + function loginAsZeus(): Promise { + return authSession.loginWithMicrosoftAzure( + "zeus@rhdhtesting.onmicrosoft.com", + process.env.DEFAULT_USER_PASSWORD_2!, ); + } - await deployment.createSecret(); - - // enable keycloak login with ingestion - await deployment.enableMicrosoftLoginWithIngestion(); - 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_AZURE_CLIENT_ID!, - process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!, - process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, + function loginAsAtena(): Promise { + return authSession.loginWithMicrosoftAzure( + "atena@rhdhtesting.onmicrosoft.com", + process.env.DEFAULT_USER_PASSWORD_2!, ); + } - const redirectUrl = `${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"); + function loginAsTyke(): Promise { + return authSession.loginWithMicrosoftAzure( + "tyke@rhdhtesting.onmicrosoft.com", + process.env.DEFAULT_USER_PASSWORD_2!, + ); + } - // create backstage deployment and wait for it to be ready - await deployment.createBackstageDeployment(); - await deployment.waitForDeploymentReady(); + test.beforeAll(async ({ rhdhPage, rhdhContext, rhdhAuthSession }) => { + test.info().annotations.push({ + type: "component", + description: "authentication", + }); - // wait for rhdh first sync and portal to be reachable - await deployment.waitForSynced(); + page = rhdhPage; + context = rhdhContext; + authSession = rhdhAuthSession; + settingsPage = new SettingsPage(rhdhPage); + + await harness.prepareProvider({ + requiredEnvVars: [ + "DEFAULT_USER_PASSWORD_2", + "AUTH_PROVIDERS_AZURE_CLIENT_ID", + "AUTH_PROVIDERS_AZURE_CLIENT_SECRET", + "AUTH_PROVIDERS_AZURE_TENANT_ID", + ], + envSecrets: { + 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", + }, + enableProvider: async (deployment) => { + await deployment.enableMicrosoftLoginWithIngestion(); + }, + beforeDeploy: async () => { + console.log("[TEST] Configuring Microsoft Azure App Registration..."); + const graphClient = new MSClient( + process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, + process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!, + process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, + ); + 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"); + }, + }); }); test.beforeEach(() => { - test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with Microsoft default resolver", async () => { - const login = await common.MicrosoftAzureLogin( - "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2!, - ); - expect(login).toBe("Login successful"); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); - await common.signOut(); - await context.clearCookies(); + await harness.runLoginCase({ + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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(); - - const login = await common.MicrosoftAzureLogin( - "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2!, - ); - expect(login).toBe("Login successful"); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); - await common.signOut(); - await context.clearCookies(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setMicrosoftResolver("emailMatchingUserEntityAnnotation", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); - const login2 = await common.MicrosoftAzureLogin( - "atena@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2!, - ); - expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); - await context.clearCookies(); + await harness.runLoginCase({ + login: loginAsAtena, + assert: async () => { + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + }, + cleanup: clearSession, + }); }); 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(); - - const login = await common.MicrosoftAzureLogin( - "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2!, - ); - expect(login).toBe("Login successful"); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); - await common.signOut(); - await context.clearCookies(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setMicrosoftResolver("emailMatchingUserEntityProfileEmail", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); // 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(); - - const login = await common.MicrosoftAzureLogin( - "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2!, - ); - expect(login).toBe("Login successful"); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); - await common.signOut(); - await context.clearCookies(); - - const login2 = await common.MicrosoftAzureLogin( - "tyke@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2!, - ); - expect(login2).toBe("Login successful"); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setMicrosoftResolver("emailLocalPartMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await harness.runLoginCase({ + login: loginAsTyke, + assert: async () => { + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + }, + cleanup: clearSession, + }); }); 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(); - - const login = await common.MicrosoftAzureLogin( - "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2!, - ); - expect(login).toBe("Login successful"); - - await page.reload(); - - const cookies = await context.cookies(); - const authCookie = cookies.find((cookie) => cookie.name === "microsoft-refresh-token"); - expect(authCookie).toBeDefined(); - - // expected duration of 3 days in ms - const threeDays = 3 * 24 * 60 * 60 * 1000; - // allow for 3 minutes tolerance - const tolerance = 3 * 60 * 1000; - - const actualDuration = authCookie!.expires * 1000 - Date.now(); - - expect(actualDuration).toBeGreaterThan(threeDays - tolerance); - expect(actualDuration).toBeLessThan(threeDays + tolerance); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("TEST Zeus"); - await common.signOut(); + await harness.runLoginCase({ + configure: async () => { + harness.deployment.setAppConfigProperty( + "auth.providers.microsoft.production.sessionDuration", + "3days", + ); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await page.reload(); + + const cookies = await context.cookies(); + const authCookie = cookies.find((cookie) => cookie.name === "microsoft-refresh-token"); + expect(authCookie).toBeDefined(); + + const threeDays = 3 * 24 * 60 * 60 * 1000; + const tolerance = 3 * 60 * 1000; + const actualDuration = authCookie!.expires * 1000 - Date.now(); + + expect(actualDuration).toBeGreaterThan(threeDays - tolerance); + expect(actualDuration).toBeLessThan(threeDays + tolerance); + + await settingsPage.open(); + await settingsPage.verifyProfileHeading("TEST Zeus"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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 +223,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 +231,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 +282,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 +290,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..68eb38a2b1 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 { test, expect, type Page, type BrowserContext } from "@support/coverage/test"; +import { AuthProviderSession } from "../../support/auth/provider-auth"; +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 { 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,283 +17,227 @@ 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 () => { - 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!, - }); +const harness = 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 authSession: AuthProviderSession; + let settingsPage: SettingsPage; + let page: Page; + let context: BrowserContext; + + async function clearSession(): Promise { + await authSession.clearAuthState(context); + } - // 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) => { + function loginAsZeus(): Promise { + return authSession.loginWithKeycloak("zeus", process.env.DEFAULT_USER_PASSWORD!); + } + + function loginAsAtena(): Promise { + return authSession.loginWithKeycloak("atena", process.env.DEFAULT_USER_PASSWORD!); + } + + test.beforeAll(async ({ rhdhPage, rhdhContext, rhdhAuthSession }) => { 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; + authSession = rhdhAuthSession; + settingsPage = new SettingsPage(rhdhPage); - // 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(); - - // create initial deployment - // enable keycloak login with ingestion - console.log("[TEST] Enabling OIDC login with ingestion..."); - await deployment.enableOIDCLoginWithIngestion(); - await 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.prepareProvider({ + requiredEnvVars: [ + "DEFAULT_USER_PASSWORD", + "RHBK_BASE_URL", + "RHBK_REALM", + "RHBK_CLIENT_ID", + "RHBK_CLIENT_SECRET", + ], + envSecrets: { + 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", + }, + enableProvider: async (deployment) => { + console.log("[TEST] Enabling OIDC login with ingestion..."); + await deployment.enableOIDCLoginWithIngestion(); + console.log("[TEST] OIDC login with ingestion enabled successfully"); + }, + }); }); test.beforeEach(() => { - test.info().setTimeout(600 * 1000); console.log(`Running test case ${test.info().title} - Attempt #${test.info().retry}`); }); test("Login with OIDC default resolver", 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 uiHelper.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 common.signOut(); + await harness.runLoginCase({ + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + await settingsPage.hideQuickstartIfVisible(); + await settingsPage.verifyRhdhMetadata(); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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(); - - 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 common.signOut(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.enableOIDCLoginWithIngestion(); + await harness.deployment.setOIDCResolver("oidcSubClaimMatchingKeycloakUserId", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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(); - - 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 common.signOut(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setOIDCResolver("emailMatchingUserEntityProfileEmail", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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(); - - 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 common.signOut(); - - const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); - expect(login2).toBe("Login successful"); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); - await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); - await keycloakHelper.initialize(); - await keycloakHelper.clearUserSessions("atena"); + await harness.runLoginCase({ + login: loginAsAtena, + assert: async () => { + await settingsPage.verifySignInError(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); + await keycloakHelper.initialize(); + await keycloakHelper.clearUserSessions("atena"); + }, + cleanup: clearSession, + }); }); 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(); - - 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 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 common.signOut(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", true); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); + + await harness.runLoginCase({ + login: loginAsAtena, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Atena Minerva"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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(); - - 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 common.signOut(); + await harness.runLoginCase({ + configure: async () => { + await harness.deployment.setOIDCResolver("preferredUsernameMatchingUserEntityName", false); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsAtena, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Atena Minerva"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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(); - - const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); - expect(login).toBe("Login successful"); - - await page.reload(); - - const cookies = await context.cookies(); - const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); - expect(authCookie).toBeDefined(); + await harness.runLoginCase({ + configure: async () => { + harness.deployment.setAppConfigProperty("auth.providers.oidc.production.sessionDuration", "3days"); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await page.reload(); - // expected duration of 3 days in ms - const threeDays = 3 * 24 * 60 * 60 * 1000; - // allow for 3 minutes tolerance - const tolerance = 3 * 60 * 1000; + const cookies = await context.cookies(); + const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); + expect(authCookie).toBeDefined(); - const actualDuration = authCookie!.expires * 1000 - Date.now(); + const threeDays = 3 * 24 * 60 * 60 * 1000; + const tolerance = 3 * 60 * 1000; + const actualDuration = authCookie!.expires * 1000 - Date.now(); - expect(actualDuration).toBeGreaterThan(threeDays - tolerance); - expect(actualDuration).toBeLessThan(threeDays + tolerance); + expect(actualDuration).toBeGreaterThan(threeDays - tolerance); + expect(actualDuration).toBeLessThan(threeDays + tolerance); - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); - await common.signOut(); + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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,140 +245,123 @@ 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 () => { - const oidcLogin = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); - - expect(oidcLogin).toBe("Login successful"); - - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("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", { - production: { - clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", - clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", - callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", + await harness.runLoginCase({ + configure: async () => { + await Promise.resolve(); + expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!).toBeDefined(); + expect(process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID!).toBeDefined(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + + harness.deployment.setAppConfigProperty("auth.providers.github", { + production: { + clientId: "${AUTH_PROVIDERS_GH_ORG_CLIENT_ID}", + clientSecret: "${AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET}", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", + }, + }); + harness.deployment.setAppConfigProperty( + "auth.providers.github.production.disableIdentityResolution", + "true", + ); + await harness.reconcileAfterConfigChange(); + + await settingsPage.hideQuickstartIfVisible(); + + const ghLogin = await authSession.loginWithGitHubFromSettingsPage( + "rhdhqeauth1", + process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, + process.env.AUTH_PROVIDERS_GH_USER_2FA!, + ); + expect(ghLogin).toBe("Login successful"); + await page.getByTitle("Sign out from GitHub").click(); + + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + await settingsPage.signOut(); }, + cleanup: clearSession, }); - - deployment.setAppConfigProperty( - "auth.providers.github.production.disableIdentityResolution", - "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 uiHelper.hideQuickstartIfVisible(); - - const ghLogin = await common.githubLoginFromSettingsPage( - "rhdhqeauth1", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, - process.env.AUTH_PROVIDERS_GH_USER_2FA!, - ); - expect(ghLogin).toBe("Login successful"); - // Sign out for GitHub - await page.getByTitle("Sign out from GitHub").click(); - - // Sign out for OIDC - await uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("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"); - // 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(); - - 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 harness.runLoginCase({ + configure: async () => { + harness.deployment.setAppConfigProperty("auth.autologout.enabled", "true"); + harness.deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); + harness.deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.verifyTextVisible("Logging out due to inactivity", false, 60000); + await settingsPage.verifyInactivityLogoutMessageHidden(); - await page.reload(); + await page.reload(); - const cookies = await context.cookies(); - const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); - expect(authCookie).toBeUndefined(); + const cookies = await context.cookies(); + const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); + expect(authCookie).toBeUndefined(); + }, + cleanup: clearSession, + }); }); test(`Enable autologout and user stays logged in after clicking "Don't log me out"`, async () => { - 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(); - - 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 uiHelper.goToSettingsPage(); - await uiHelper.verifyHeading("Zeus Giove"); - await common.signOut(); + await harness.runLoginCase({ + configure: async () => { + harness.deployment.setAppConfigProperty("auth.autologout.enabled", "true"); + harness.deployment.setAppConfigProperty("auth.autologout.idleTimeoutMinutes", 0.5); + harness.deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); + await harness.reconcileAfterConfigChange(); + }, + login: loginAsZeus, + assert: async () => { + await settingsPage.clickButtonByText("Don't log me out", { + timeout: 60000, + }); + + await settingsPage.open(); + await settingsPage.verifyProfileHeading("Zeus Giove"); + await settingsPage.signOut(); + }, + cleanup: clearSession, + }); }); 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..d688988fda 100644 --- a/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts +++ b/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts @@ -1,97 +1,67 @@ -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 { JOB_NAME_PATTERNS } from "../utils/constants"; +import { skipIfJobName } from "../utils/helper"; 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"), + () => skipIfJobName(JOB_NAME_PATTERNS.OSD_GCP), "skipping on OSD-GCP cluster due to RHDHBUGS-555", ); - let uiHelper: UIhelper; - let common: Common; + let catalogBrowsePage: CatalogBrowsePage; + let selfServicePage: SelfServicePage; 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.describe.configure({ mode: "serial" }); + + test.beforeAll(({ rhdhGuestPage }) => { 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); - - await common.loginAsGuest(); + catalogBrowsePage = new CatalogBrowsePage(rhdhGuestPage); + selfServicePage = new SelfServicePage(rhdhGuestPage); + catalogImport = new CatalogImport(rhdhGuestPage); }); 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 8d66c6c496..6243ea6386 100644 --- a/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts +++ b/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts @@ -1,8 +1,8 @@ import { test, expect } from "@support/coverage/test"; -import { Common } from "../../utils/common"; -import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; -import { UIhelper } from "../../utils/ui-helper"; +import { signInAsGuest } from "../../support/auth/guest-auth"; +import { RuntimeHarness } from "../../support/harnesses/runtime-harness"; +import { HomePage } from "../../support/pages/home-page"; test.describe("Change app-config at e2e test runtime", () => { test.beforeAll(() => { @@ -19,28 +19,21 @@ test.describe("Change app-config at e2e test runtime", () => { }); test("Verify title change after ConfigMap modification", async ({ page }) => { - test.setTimeout(300000); - const configMapName = "app-config-rhdh"; - const namespace = process.env.NAME_SPACE_RUNTIME ?? "showcase-runtime"; - const deploymentName = getRhdhDeploymentName(); - - const kubeUtils = new KubeClient(); + const runtimeHarness = new RuntimeHarness(namespace); const dynamicTitle = generateDynamicTitle(); try { console.log(`Updating ConfigMap '${configMapName}' with new title.`); - await kubeUtils.updateConfigMapTitle(configMapName, namespace, dynamicTitle); - - console.log(`Restarting deployment '${deploymentName}' to apply ConfigMap changes.`); - await kubeUtils.restartDeployment(deploymentName, namespace); + await runtimeHarness.updateConfigMapTitle(configMapName, dynamicTitle); + console.log("Restarting deployment to apply ConfigMap changes."); + await runtimeHarness.restartDeploymentWithRetry(); - const common = new Common(page); await page.context().clearCookies(); await page.context().clearPermissions(); await page.reload({ waitUntil: "domcontentloaded" }); - await common.loginAsGuest(); - await new UIhelper(page).openSidebar("Home"); + await signInAsGuest(page); + await new HomePage(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 51f0ed6576..2ededc06a9 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,14 +1,9 @@ -import { test } from "@support/coverage/test"; +import { expect, test } from "@support/coverage/test"; -import { Common } from "../../utils/common"; -import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; -import { - readCertificateFile, - configurePostgresCertificate, - configurePostgresCredentials, - clearDatabase, -} from "../../utils/postgres-config"; -import { UIhelper } from "../../utils/ui-helper"; +import { signInAsGuest } from "../../support/auth/guest-auth"; +import { RuntimeHarness } from "../../support/harnesses/runtime-harness"; +import { HomePage } from "../../support/pages/home-page"; +import { clearDatabase, readCertificateFile } from "../../utils/postgres-config"; interface AzureDbConfig { name: string; @@ -17,7 +12,7 @@ interface AzureDbConfig { test.describe("Verify TLS configuration with Azure Database for PostgreSQL health check", () => { const namespace = process.env.NAME_SPACE_RUNTIME! || "showcase-runtime"; - const deploymentName = getRhdhDeploymentName(); + const runtimeHarness = new RuntimeHarness(namespace); // Azure DB configuration from environment const azureUser = process.env.AZURE_DB_USER!; @@ -56,17 +51,14 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt throw new Error("AZURE_DB_USER and AZURE_DB_PASSWORD environment variables must be set"); } - const kubeClient = new KubeClient(); - // Create/update the postgres-crt secret with Azure certificates console.log("Configuring Azure Database for PostgreSQL TLS certificates..."); - await configurePostgresCertificate(kubeClient, namespace, azureCerts); + await runtimeHarness.configurePostgresCertificate(azureCerts); }); for (const config of azureConfigurations) { test.describe.serial(`Azure DB ${config.name} PostgreSQL version`, () => { test.beforeAll(async () => { - test.setTimeout(180000); test.info().annotations.push({ type: "database", description: config.host?.split(".")[0] || "unknown", @@ -80,21 +72,20 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt }); test("Configure and restart deployment", async () => { - const kubeClient = new KubeClient(); - test.setTimeout(600000); - await configurePostgresCredentials(kubeClient, namespace, { - host: config.host, - user: azureUser, - password: azurePassword, + await runtimeHarness.configureExternalPostgres({ + credentials: { + host: config.host, + user: azureUser, + password: azurePassword, + }, }); - await kubeClient.restartDeployment(deploymentName, namespace); + expect(config.host).toBeTruthy(); }); test("Verify successful DB connection", async ({ page }) => { - const uiHelper = new UIhelper(page); - const common = new Common(page); - await common.loginAsGuest(); - await uiHelper.verifyHeading("Welcome back!"); + const homePage = new HomePage(page); + await signInAsGuest(page); + await homePage.verifyWelcomeHeading(); }); }); } 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..9bbc800d03 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,7 @@ import { test, expect } from "@support/coverage/test"; -import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; +import { CatalogBrowsePage } from "../../support/pages/catalog-browse-page"; +import { HomePage } from "../../support/pages/home-page"; test.describe("Verify TLS configuration with external Crunchy Postgres DB", () => { test.beforeAll(() => { @@ -17,16 +17,16 @@ test.describe("Verify TLS configuration with external Crunchy Postgres DB", () = ); }); - test("Verify successful DB connection", async ({ page }) => { - const uiHelper = new UIhelper(page); - const common = new Common(page); - await common.loginAsKeycloakUser(process.env.GH_USER2_ID, process.env.GH_USER2_PASS); - await uiHelper.verifyHeading("Welcome back!"); + test("Verify successful DB connection", async ({ page, authSession }) => { + const homePage = new HomePage(page); + const catalogBrowsePage = new CatalogBrowsePage(page); + await authSession.loginWithKeycloak(process.env.GH_USER2_ID ?? "", process.env.GH_USER2_PASS ?? ""); + await homePage.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 4e822b4c99..a0e8b25ad1 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,14 +1,9 @@ -import { test } from "@support/coverage/test"; +import { expect, test } from "@support/coverage/test"; -import { Common } from "../../utils/common"; -import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; -import { - readCertificateFile, - configurePostgresCertificate, - configurePostgresCredentials, - clearDatabase, -} from "../../utils/postgres-config"; -import { UIhelper } from "../../utils/ui-helper"; +import { signInAsGuest } from "../../support/auth/guest-auth"; +import { RuntimeHarness } from "../../support/harnesses/runtime-harness"; +import { HomePage } from "../../support/pages/home-page"; +import { clearDatabase, readCertificateFile } from "../../utils/postgres-config"; interface RdsConfig { name: string; @@ -17,7 +12,7 @@ interface RdsConfig { test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => { const namespace = process.env.NAME_SPACE_RUNTIME! || "showcase-runtime"; - const deploymentName = getRhdhDeploymentName(); + const runtimeHarness = new RuntimeHarness(namespace); // RDS configuration from environment const rdsUser = process.env.RDS_USER!; @@ -56,17 +51,14 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => throw new Error("RDS_USER and RDS_PASSWORD environment variables must be set"); } - const kubeClient = new KubeClient(); - // Create/update the postgres-crt secret with RDS certificates console.log("Configuring RDS TLS certificates..."); - await configurePostgresCertificate(kubeClient, namespace, rdsCerts); + await runtimeHarness.configurePostgresCertificate(rdsCerts); }); for (const config of rdsConfigurations) { test.describe.serial(`RDS ${config.name} PostgreSQL version`, () => { test.beforeAll(async () => { - test.setTimeout(135000); test.info().annotations.push({ type: "database", description: config.host?.split(".")[0] || "unknown", @@ -80,21 +72,20 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => }); test("Configure and restart deployment", async () => { - const kubeClient = new KubeClient(); - test.setTimeout(600000); - await configurePostgresCredentials(kubeClient, namespace, { - host: config.host, - user: rdsUser, - password: rdsPassword, + await runtimeHarness.configureExternalPostgres({ + credentials: { + host: config.host, + user: rdsUser, + password: rdsPassword, + }, }); - await kubeClient.restartDeployment(deploymentName, namespace); + expect(config.host).toBeTruthy(); }); test("Verify successful DB connection", async ({ page }) => { - const uiHelper = new UIhelper(page); - const common = new Common(page); - await common.loginAsGuest(); - await uiHelper.verifyHeading("Welcome back!"); + const homePage = new HomePage(page); + await signInAsGuest(page); + await homePage.verifyWelcomeHeading(); }); }); } diff --git a/e2e-tests/playwright/e2e/github-happy-path.spec.ts b/e2e-tests/playwright/e2e/github-happy-path.spec.ts deleted file mode 100644 index 015fd84d70..0000000000 --- a/e2e-tests/playwright/e2e/github-happy-path.spec.ts +++ /dev/null @@ -1,234 +0,0 @@ -import { test, expect, Page, BrowserContext } from "@support/coverage/test"; - -import { BackstageShowcase, CatalogImport } from "../support/pages/catalog-import"; -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"; - -type GithubPullRequest = { title: string; number: string }; - -function parseGithubPullRequests(data: unknown): GithubPullRequest[] { - if (!Array.isArray(data)) { - throw new TypeError(`Expected GitHub PR array, got ${typeof data}`); - } - - return data.map((entry, index) => { - if (typeof entry !== "object" || entry === null) { - throw new TypeError(`Invalid PR entry at index ${index}`); - } - - const title: unknown = Reflect.get(entry, "title"); - const numberValue: unknown = Reflect.get(entry, "number"); - - if (typeof title !== "string") { - throw new TypeError(`PR at index ${index} is missing a string title`); - } - - const number = - typeof numberValue === "string" - ? numberValue - : typeof numberValue === "number" - ? String(numberValue) - : ""; - - return { title, number }; - }); -} - -async function getShowcasePullRequests( - state: "open" | "closed" | "all", - paginated = false, -): Promise { - const data: unknown = await BackstageShowcase.getShowcasePRs(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", () => { - let common: Common; - let uiHelper: UIhelper; - let catalogImport: CatalogImport; - let backstageShowcase: BackstageShowcase; - - const component = "https://github.com/redhat-developer/rhdh/blob/main/catalog-entities/all.yaml"; - - test.beforeAll(async ({ browser }, testInfo) => { - 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); - }); - - test("Login as a Github user from Settings page.", async () => { - await common.loginAsKeycloakUser(process.env.GH_USER2_ID, process.env.GH_USER2_PASS); - const ghLogin = await common.githubLoginFromSettingsPage( - process.env.GH_USER2_ID!, - process.env.GH_USER2_PASS!, - process.env.GH_USER2_2FA_SECRET!, - ); - expect(ghLogin).toBe("Login successful"); - }); - - 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!}`); - }); - - 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 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 uiHelper.verifyComponentInCatalog("API", ["Petstore"]); - await uiHelper.verifyComponentInCatalog("Component", ["Red Hat Developer Hub"]); - - await uiHelper.selectMuiBox("Kind", "Resource"); - await uiHelper.verifyRowsInTable([ - "ArgoCD", - "GitHub Showcase repository", - "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"]); - }); - - test("Verify all 12 Software Templates appear in the Create page", async () => { - await uiHelper.goToSelfServicePage(); - await uiHelper.verifyHeading("Templates"); - - for (const template of TEMPLATES) { - await uiHelper.waitForTitle(template, 4); - await uiHelper.verifyHeading(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"); - - 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 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(); - }); - - 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); - }); - - 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 common.waitForLoad(); - await backstageShowcase.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); - - 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); - - console.log("Clicking on Next Page button"); - await backstageShowcase.clickNextPage(); - await backstageShowcase.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); - - console.log("Clicking on Previous Page button"); - await backstageShowcase.clickPreviousPage(); - await common.waitForLoad(); - await backstageShowcase.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 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); - }); - - // 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"); - for (const resource of RESOURCES) { - const resourceElement = page.locator(`#workspace:has-text("${resource}")`); - await resourceElement.scrollIntoViewIfNeeded(); - await expect(resourceElement).toBeVisible(); - } - }); - - // 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(); - await common.signOut(); - await browserContext.clearCookies(); - await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); - }); - - test.afterAll(async ({}, testInfo) => { - await teardownBrowser(page, testInfo); - }); -}); 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..8c14370bba 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,7 @@ import { test } from "@support/coverage/test"; import { HomePage } from "../support/pages/home-page"; -import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; -import { getTranslations, getCurrentLanguage } from "./localization/locale"; - -const t = getTranslations(); -const lang = getCurrentLanguage(); +import { SettingsPage } from "../support/pages/settings-page"; test.describe("Guest Signing Happy path", () => { test.beforeAll(() => { @@ -16,32 +11,28 @@ test.describe("Guest Signing Happy path", () => { }); }); - let uiHelper: UIhelper; let homePage: HomePage; - let common: Common; + let settingsPage: SettingsPage; - test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); - homePage = new HomePage(page); - common = new Common(page); - await common.loginAsGuest(); + test.beforeEach(({ guestPage }) => { + homePage = new HomePage(guestPage); + settingsPage = new SettingsPage(guestPage); }); test("Verify the Homepage renders with Search Bar, Quick Access and Starred Entities", async () => { - await uiHelper.verifyHeading("Welcome back!"); - await uiHelper.openSidebar("Home"); + await homePage.verifyWelcomeHeading(); + await homePage.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 common.signOut(); - await uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); + await settingsPage.open(); + await settingsPage.signOut(); + 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..1df82315a5 100644 --- a/e2e-tests/playwright/e2e/home-page-customization.spec.ts +++ b/e2e-tests/playwright/e2e/home-page-customization.spec.ts @@ -2,12 +2,8 @@ import { test } from "@support/coverage/test"; import { HomePage } from "../support/pages/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 homePage: HomePage; test.beforeAll(() => { @@ -17,39 +13,36 @@ test.describe("Home page customization", () => { }); }); - test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); - common = new Common(page); - homePage = new HomePage(page); - await common.loginAsGuest(); + test.beforeEach(({ guestPage }) => { + homePage = new HomePage(guestPage); }); test("Verify that home page is customized", async ({ page }, testInfo) => { - await uiHelper.verifyTextinCard("Quick Access", "Quick Access"); + await homePage.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 homePage.verifyTextInCard("Your Starred Entities", "Your Starred Entities"); + await homePage.verifyHeading("Placeholder tests"); + await homePage.verifyDivHasText("Home page customization test 1"); + await homePage.verifyDivHasText("Home page customization test 2"); + await homePage.verifyDivHasText("Home page customization test 3"); + await homePage.verifyHeading("Markdown tests"); + await homePage.verifyTextInCard("Company links", "Company links"); + await homePage.verifyHeading("Important company links"); + await homePage.verifyHeading("RHDH"); + await homePage.verifyTextInCard("Featured Docs", "Featured Docs"); + await homePage.verifyTextInCard("Random Joke", "Random Joke"); + await homePage.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 homePage.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 homePage.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..48d5c314de 100644 --- a/e2e-tests/playwright/e2e/learning-path-page.spec.ts +++ b/e2e-tests/playwright/e2e/learning-path-page.spec.ts @@ -1,8 +1,7 @@ -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(() => { @@ -12,29 +11,17 @@ test.describe("Learning Paths", { tag: "@layer3-equivalent" }, () => { }); }); - let common: Common; - let uiHelper: UIhelper; + let sidebarPage: SidebarPage; - test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); - common = new Common(page); - await common.loginAsGuest(); + test.beforeEach(({ guestPage }) => { + sidebarPage = new SidebarPage(guestPage); }); 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/plugin-division-mode-schema/schema-mode-db.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts index efccc37d54..fe9b869221 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-db.ts @@ -6,6 +6,8 @@ import { expect } from "@playwright/test"; import { Client } from "pg"; import type { ClientConfig } from "pg"; +import { sleep } from "../../utils/poll-until"; + export interface SchemaModeEnv { dbHost: string; dbAdminUser: string; @@ -73,11 +75,7 @@ async function connectWithRetry(config: ClientConfig): Promise { } const delay = Math.min(2000 * attempt, 10000); - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, delay); - }); + await sleep(delay); } } } diff --git a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts index a68d79259a..68d48ec683 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/schema-mode-setup.ts @@ -5,6 +5,7 @@ import * as yaml from "js-yaml"; +import { RuntimeHarness } from "../../support/harnesses/runtime-harness"; import { KubeClient } from "../../utils/kube-client"; import { getSchemaModeEnv, @@ -39,6 +40,7 @@ export class SchemaModeTestSetup { private installMethod: "helm" | "operator"; private env: ReturnType; private kubeClient: KubeClient; + private runtimeHarness: RuntimeHarness; constructor(namespace: string, releaseName: string, installMethod: "helm" | "operator") { this.namespace = namespace; @@ -46,6 +48,7 @@ export class SchemaModeTestSetup { this.installMethod = installMethod; this.env = getSchemaModeEnv(); this.kubeClient = new KubeClient(); + this.runtimeHarness = new RuntimeHarness(namespace, this.getDeploymentName(), this.kubeClient); } getDeploymentName(): string { @@ -146,27 +149,9 @@ export class SchemaModeTestSetup { // 3. Update app-config ConfigMap for schema mode await this.updateAppConfigForSchemaMode(); - // 4. Restart to apply changes (retry up to 3 times for slow ephemeral volume PVC creation) - const maxRestartAttempts = 3; - for (let attempt = 1; attempt <= maxRestartAttempts; attempt++) { - try { - console.log( - `Restarting RHDH to apply schema mode configuration (attempt ${attempt}/${maxRestartAttempts})...`, - ); - await this.kubeClient.restartDeployment(deploymentName, this.namespace); - console.log("RHDH restart completed"); - break; - } catch (restartError) { - if (attempt === maxRestartAttempts) throw restartError; - const msg = restartError instanceof Error ? restartError.message : String(restartError); - console.warn(`Restart attempt ${attempt} failed (${msg}), retrying in 30s...`); - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, 30000); - }); - } - } + console.log("Restarting RHDH to apply schema mode configuration..."); + await this.runtimeHarness.restartDeploymentWithRetry(120_000, 15_000); + console.log("RHDH restart completed"); } private async ensureDeploymentEnvVars(deploymentName: string, secretName: string): Promise { diff --git a/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts b/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts index 50698c9eb5..ae187d9097 100644 --- a/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts +++ b/e2e-tests/playwright/e2e/plugin-division-mode-schema/verify-schema-mode.spec.ts @@ -7,81 +7,21 @@ * Tests are opt-in - they skip when SCHEMA_MODE_* environment variables are not set. */ -import { ChildProcessWithoutNullStreams, spawn } from "child_process"; - import { test, expect } from "@support/coverage/test"; -import { Common } from "../../utils/common"; +import { signInAsGuest } from "../../support/auth/guest-auth"; +import { HomePage } from "../../support/pages/home-page"; import { KubeClient } from "../../utils/kube-client"; +import { PortForwardSession } from "../../utils/port-forward"; import { setPortForwardRestarter } from "./schema-mode-db"; import { SchemaModeTestSetup } from "./schema-mode-setup"; -function streamDataToString(data: Buffer | string): string { - return typeof data === "string" ? data : data.toString(); -} - -function startPortForward( - pfNamespace: string, - pfResource: string, -): Promise { - return new Promise((resolve, reject) => { - const proc = spawn("oc", ["port-forward", "-n", pfNamespace, pfResource, "5432:5432"]); - - const timeout = setTimeout(() => { - proc.kill("SIGTERM"); - reject(new Error("Port-forward timeout after 30 seconds")); - }, 30000); - - let ready = false; - proc.stdout.on("data", (data: Buffer | string) => { - if (ready) return; - if (streamDataToString(data).includes("Forwarding from")) { - ready = true; - clearTimeout(timeout); - resolve(proc); - } - }); - - proc.stderr.on("data", (data: Buffer | string) => { - const msg = streamDataToString(data).trim(); - if (msg) console.error(`Port-forward stderr: ${msg}`); - }); - - proc.on("error", (err) => { - clearTimeout(timeout); - reject(err); - }); - }); -} - -function killPortForward(proc: ChildProcessWithoutNullStreams | undefined): Promise { - if (!proc || proc.exitCode !== null) return Promise.resolve(); - - return new Promise((resolve) => { - proc.once("close", () => { - resolve(); - }); - - proc.kill("SIGTERM"); - - setTimeout(() => { - if (proc.exitCode === null) { - try { - proc.kill("SIGKILL"); - } catch { - // already dead - } - } - }, 5000); - }); -} - test.describe("Verify pluginDivisionMode: schema", () => { const namespace = process.env.NAME_SPACE_RUNTIME ?? "showcase-runtime"; const releaseName = process.env.RELEASE_NAME ?? "developer-hub"; const installMethod = process.env.INSTALL_METHOD === "operator" ? "operator" : "helm"; - let portForwardProcess: ChildProcessWithoutNullStreams | undefined; + let portForwardSession: PortForwardSession | null = null; let testSetup: SchemaModeTestSetup; test.beforeAll(async ({}, testInfo) => { @@ -122,14 +62,23 @@ test.describe("Verify pluginDivisionMode: schema", () => { if (hasPortForwardMeta) { console.log(`Starting port-forward: ${pfResource} in ${pfNamespace} -> localhost:5432`); - portForwardProcess = await startPortForward(pfNamespace, pfResource); + portForwardSession = new PortForwardSession( + { + command: "oc", + args: ["port-forward", "-n", pfNamespace, pfResource, "5432:5432"], + }, + { + readyPattern: /Forwarding from/u, + readyTimeoutMs: 30_000, + }, + ); + await portForwardSession.start(); console.log("Port-forward established"); process.env.SCHEMA_MODE_DB_HOST = "localhost"; setPortForwardRestarter(async () => { - await killPortForward(portForwardProcess); console.log("Restarting port-forward..."); - portForwardProcess = await startPortForward(pfNamespace, pfResource); + await portForwardSession?.restart(); console.log("Port-forward re-established"); }); } @@ -147,7 +96,7 @@ test.describe("Verify pluginDivisionMode: schema", () => { test.afterAll(async () => { setPortForwardRestarter(null); - await killPortForward(portForwardProcess); + await portForwardSession?.stop(); }); test("Verify database user has restricted permissions", async () => { @@ -174,10 +123,10 @@ test.describe("Verify pluginDivisionMode: schema", () => { console.warn("Could not check deployment readiness:", error); } - const common = new Common(page); - await common.loginAsGuest(); + await signInAsGuest(page); - await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + const homePage = new HomePage(page); + await homePage.verifyMainHeadingVisible(); console.log("RHDH is accessible - plugins successfully created schemas in schema mode"); }); diff --git a/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts b/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts index 74621a494e..d2f132b00d 100644 --- a/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts @@ -1,7 +1,6 @@ import { expect, test } from "@support/coverage/test"; -import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; +import { CatalogBrowsePage } from "../../support/pages/catalog-browse-page"; test.describe("Test ApplicationListener", () => { test.beforeAll(() => { @@ -11,12 +10,10 @@ test.describe("Test ApplicationListener", () => { }); }); - let uiHelper: UIhelper; + let catalogBrowsePage: CatalogBrowsePage; - test.beforeEach(async ({ page }) => { - const common = new Common(page); - uiHelper = new UIhelper(page); - await common.loginAsGuest(); + test.beforeEach(({ guestPage }) => { + catalogBrowsePage = new CatalogBrowsePage(guestPage); }); test("Verify that the LocationListener logs the current location", async ({ page }) => { @@ -28,7 +25,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..1db43ab970 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 { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; +import { waitForLoadingToSettle } from "../../support/auth/app-shell"; +import { ApplicationProviderTestPage } from "../../support/pages/application-provider-test-page"; test.describe("Test ApplicationProvider", () => { test.beforeAll(() => { @@ -11,57 +11,21 @@ test.describe("Test ApplicationProvider", () => { }); }); - let uiHelper: UIhelper; - let common: Common; + let applicationProviderPage: ApplicationProviderTestPage; - test.beforeEach(async ({ page }) => { - common = new Common(page); - uiHelper = new UIhelper(page); - await common.loginAsGuest(); + test.beforeEach(({ guestPage }) => { + applicationProviderPage = new ApplicationProviderTestPage(guestPage); }); - test("Verify that the TestPage is rendered", async ({ page }) => { - await uiHelper.goToPageUrl("/application-provider-test-page"); - 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(); + test("Verify that the TestPage is rendered", async ({ guestPage }) => { + await applicationProviderPage.open(); + await waitForLoadingToSettle(guestPage); + 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..2fcf0d3dfb 100644 --- a/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts @@ -1,65 +1,44 @@ -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 { 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 common: Common; + let sidebarPage: SidebarPage; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(({ rhdhGuestPage }) => { test.info().annotations.push({ type: "component", description: "plugins", }); - page = (await setupBrowser(browser, testInfo)).page; - uiHelper = new UIhelper(page); - common = new Common(page); - - await common.loginAsGuest(); + sidebarPage = new SidebarPage(rhdhGuestPage); }); 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.openSidebarButton("Favorites"); + await sidebarPage.openSidebar(t["rhdh"][lang]["menuItem.docs"]); + + 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..dc2f35680b 100644 --- a/e2e-tests/playwright/e2e/plugins/http-request.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/http-request.spec.ts @@ -1,19 +1,21 @@ import { test } from "@support/coverage/test"; import { CatalogImport } from "../../support/pages/catalog-import"; -import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; +import { ScaffolderFlowPage } from "../../support/pages/scaffolder-flow-page"; +import { SelfServicePage } from "../../support/pages/self-service-page"; +import { JOB_NAME_PATTERNS } from "../../utils/constants"; +import { skipIfJobName } from "../../utils/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 // Pre-req: Enable janus-idp-backstage-plugin-quay plugin test.describe("Testing scaffolder-backend-module-http-request to invoke an external request", () => { test.skip( - () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), + () => skipIfJobName(JOB_NAME_PATTERNS.OSD_GCP), "skipping due to RHDHBUGS-555 on OSD Env", ); - let uiHelper: UIhelper; - let common: Common; + let selfServicePage: SelfServicePage; + let scaffolderFlowPage: ScaffolderFlowPage; let catalogImport: CatalogImport; const template = "https://github.com/janus-qe/software-template/blob/main/test-http-request.yaml"; @@ -24,29 +26,18 @@ test.describe("Testing scaffolder-backend-module-http-request to invoke an exter }); }); - test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); - common = new Common(page); - await common.loginAsGuest(); - catalogImport = new CatalogImport(page); + test.beforeEach(({ guestPage }) => { + selfServicePage = new SelfServicePage(guestPage); + scaffolderFlowPage = new ScaffolderFlowPage(guestPage); + catalogImport = new CatalogImport(guestPage); }); 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..abf883f23c 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,8 +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 { Common } from "../../../utils/common"; +import { CatalogBrowsePage } from "../../../support/pages/catalog-browse-page"; interface HealthResponse { status: string; @@ -47,7 +46,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({ @@ -61,14 +60,12 @@ test.describe("Test licensed users info backend plugin", () => { const baseRHDHURL: string = playwrightConfig.use?.baseURL ?? ""; const pluginAPIURL: string = "api/licensed-users-info/"; - test.beforeEach(async ({ page }) => { - common = new Common(page); - await common.loginAsGuest(); - await CatalogUsersPO.visitBaseURL(page); + test.beforeEach(async ({ guestPage }) => { + catalogBrowsePage = new CatalogBrowsePage(guestPage); + await catalogBrowsePage.openLicensedUsersCatalog(); - // Get the api token const hacker: RhdhAuthUiHack = RhdhAuthUiHack.getInstance(); - apiToken = await hacker.getApiToken(page); + apiToken = await hacker.getApiToken(guestPage); }); test("Test plugin health check endpoint", async () => { 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 595c4d1778..aa79c9966f 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,22 +1,22 @@ -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 { UIhelper } from "../../../utils/ui-helper"; - -let page: Page; +import { JOB_NAME_PATTERNS } from "../../../utils/constants"; +import { skipIfJobName } from "../../../utils/helper"; test.describe.serial("Test Scaffolder Backend Module Annotator", () => { test.skip( - () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), + () => skipIfJobName(JOB_NAME_PATTERNS.OSD_GCP), "skipping due to RHDHBUGS-555 on OSD Env", ); - let uiHelper: UIhelper; - let common: Common; + let scaffolderFlowPage: ScaffolderFlowPage; + let catalogBrowsePage: CatalogBrowsePage; let catalogImport: CatalogImport; const template = @@ -32,77 +32,42 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { repoOwner: Buffer.from(process.env.GITHUB_ORG ?? "amFudXMtcWU=", "base64").toString("utf8"), }; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(({ rhdhGuestPage }) => { 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); - - await common.loginAsGuest(); + scaffolderFlowPage = new ScaffolderFlowPage(rhdhGuestPage); + catalogBrowsePage = new CatalogBrowsePage(rhdhGuestPage); + catalogImport = new CatalogImport(rhdhGuestPage); }); - 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`, @@ -110,11 +75,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}`, @@ -122,31 +83,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 ee741ce7cc..c0e9a078bc 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,21 +1,21 @@ -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 { UIhelper } from "../../../utils/ui-helper"; - -let page: Page; +import { JOB_NAME_PATTERNS } from "../../../utils/constants"; +import { skipIfJobName } from "../../../utils/helper"; test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { test.skip( - () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), + () => skipIfJobName(JOB_NAME_PATTERNS.OSD_GCP), "skipping due to RHDHBUGS-555 on OSD Env", ); - let uiHelper: UIhelper; - let common: Common; + let scaffolderFlowPage: ScaffolderFlowPage; + let catalogBrowsePage: CatalogBrowsePage; let catalogImport: CatalogImport; const template = @@ -32,66 +32,38 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { repoOwner: Buffer.from(process.env.GITHUB_ORG ?? "amFudXMtcWU=", "base64").toString("utf8"), }; - test.beforeAll(async ({ browser }, testInfo) => { + test.beforeAll(({ rhdhGuestPage }) => { 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); - - await common.loginAsGuest(); + scaffolderFlowPage = new ScaffolderFlowPage(rhdhGuestPage); + catalogBrowsePage = new CatalogBrowsePage(rhdhGuestPage); + catalogImport = new CatalogImport(rhdhGuestPage); }); 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 @@ -105,51 +77,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..585e09d6ef 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,7 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; -import { Common } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; +import { HomePage } from "../../support/pages/home-page"; +import { SettingsPage } from "../../support/pages/settings-page"; test.describe("Test user settings info card", { tag: "@layer3-equivalent" }, () => { test.beforeAll(() => { @@ -11,32 +11,26 @@ test.describe("Test user settings info card", { tag: "@layer3-equivalent" }, () }); }); - let uiHelper: UIhelper; + let homePage: HomePage; + let settingsPage: SettingsPage; - test.beforeEach(async ({ page }) => { - const common = new Common(page); - await common.loginAsGuest(); - - uiHelper = new UIhelper(page); + test.beforeEach(({ guestPage }) => { + homePage = new HomePage(guestPage); + settingsPage = new SettingsPage(guestPage); }); - 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(); - - // Verify card header is visible - await expect(page.getByText("RHDH Build info")).toBeVisible(); + test("Check if customized build info is rendered", async () => { + await homePage.openHomeSidebar(); + await settingsPage.openFromProfile("Guest"); - // 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.verifyBuildInfoCardVisible(); + await settingsPage.verifyBuildInfoText("TechDocs builder: local"); + await settingsPage.verifyBuildInfoText("Authentication provider: Github"); - await page.getByTitle("Show more").click(); + await settingsPage.expandShowMoreSection(); - // 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..4b68a1b7ae 100644 --- a/e2e-tests/playwright/e2e/settings.spec.ts +++ b/e2e-tests/playwright/e2e/settings.spec.ts @@ -1,76 +1,41 @@ -import { test, expect } from "@support/coverage/test"; +import { test } from "@support/coverage/test"; -import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; +import { SettingsPage } from "../support/pages/settings-page"; 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 }) => { + test.beforeEach(async ({ guestPage }) => { test.info().annotations.push({ type: "component", description: "core", }); - const common = new Common(page); - uiHelper = new UIhelper(page); - await common.loginAsGuest(); - await uiHelper.goToSettingsPage(); + settingsPage = new SettingsPage(guestPage); + 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..027b817edc 100644 --- a/e2e-tests/playwright/e2e/smoke-test.spec.ts +++ b/e2e-tests/playwright/e2e/smoke-test.spec.ts @@ -1,11 +1,9 @@ import { test } from "@support/coverage/test"; -import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; +import { HomePage } from "../support/pages/home-page"; test.describe("Smoke test", { tag: "@smoke" }, () => { - let uiHelper: UIhelper; - let common: Common; + let homePage: HomePage; test.beforeAll(() => { test.info().annotations.push({ @@ -14,13 +12,11 @@ test.describe("Smoke test", { tag: "@smoke" }, () => { }); }); - test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); - common = new Common(page); - await common.loginAsGuest(); + test.beforeEach(({ guestPage }) => { + homePage = new HomePage(guestPage); }); - test("Verify the Homepage renders", async () => { - await uiHelper.verifyHeading("Welcome back!"); + test("Verify the RHDH instance homepage renders", async () => { + await homePage.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..26cd07e820 100644 --- a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts +++ b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts @@ -1,14 +1,8 @@ -import { ChildProcessWithoutNullStreams, exec, spawn } from "child_process"; - import { expect, test } from "@support/coverage/test"; import Redis from "ioredis"; -import { Common } from "../utils/common"; -import { UIhelper } from "../utils/ui-helper"; - -function streamDataToString(data: Buffer | string): string { - return typeof data === "string" ? data : data.toString(); -} +import { TechDocsPage } from "../support/pages/techdocs-page"; +import { PortForwardSession } from "../utils/port-forward"; test.describe("Verify Redis Cache DB", () => { test.beforeAll(() => { @@ -19,56 +13,36 @@ test.describe("Verify Redis Cache DB", () => { }); test.describe.configure({ mode: "serial" }); - let common: Common; - let uiHelper: UIhelper; - let portForward: ChildProcessWithoutNullStreams; + let techDocsPage: TechDocsPage; + let portForward: PortForwardSession | null = null; let redis: Redis; - test.beforeEach(async ({ page }) => { - uiHelper = new UIhelper(page); - common = new Common(page); - await common.loginAsGuest(); + test.beforeEach(async ({ guestPage }) => { + techDocsPage = new TechDocsPage(guestPage); console.log("Starting port-forward process..."); - portForward = spawn("/bin/sh", [ - "-c", - ` - oc login --token="${process.env.K8S_CLUSTER_TOKEN}" --server="${process.env.K8S_CLUSTER_URL}" --insecure-skip-tls-verify=true && - kubectl config set-context --current --namespace="${process.env.NAME_SPACE}" && - kubectl port-forward service/redis 6379:6379 --namespace="${process.env.NAME_SPACE}" - `, - ]); - + portForward = new PortForwardSession( + { + shellCommand: ` + oc login --token="${process.env.K8S_CLUSTER_TOKEN}" --server="${process.env.K8S_CLUSTER_URL}" --insecure-skip-tls-verify=true && + kubectl config set-context --current --namespace="${process.env.NAME_SPACE}" && + kubectl port-forward service/redis 6379:6379 --namespace="${process.env.NAME_SPACE}" + `, + }, + { + readyPattern: /Forwarding from 127\\.0\\.0\\.1:6379/u, + }, + ); console.log("Waiting for port-forward to be ready..."); - await new Promise((resolve, reject) => { - portForward.stdout.on("data", (data: Buffer | string) => { - if (streamDataToString(data).includes("Forwarding from 127.0.0.1:6379")) { - resolve(); - } - }); - - portForward.stderr.on("data", (data: Buffer | string) => { - const message = streamDataToString(data); - console.error(`Port forwarding failed: ${message}`); - reject(new Error(`Port forwarding failed: ${message}`)); - }); - }); + await portForward.start(); }); 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, @@ -91,13 +65,10 @@ test.describe("Verify Redis Cache DB", () => { }); }); - test.afterEach(() => { + test.afterEach(async () => { if (redis?.status === "ready") { redis.disconnect(); } - console.log("Killing port-forward process with ID:", portForward.pid); - portForward.kill("SIGKILL"); - console.log("Killing remaining port-forward process."); - exec(`ps aux | grep 'kubectl port-forward' | grep -v grep | awk '{print $2}' | xargs kill -9`); + await portForward?.stop(); }); }); 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/api/github.ts b/e2e-tests/playwright/support/api/github.ts deleted file mode 100644 index cd5c604a83..0000000000 --- a/e2e-tests/playwright/support/api/github.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { GITHUB_API_ENDPOINTS } from "../../utils/api-endpoints"; -import { APIHelper } from "../../utils/api-helper"; -import { JANUS_ORG } from "../../utils/constants"; - -// https://docs.github.com/en/rest?apiVersion=2022-11-28 -export default class GithubApi { - public getReposFromOrg(org = JANUS_ORG) { - return APIHelper.getGithubPaginatedRequest(GITHUB_API_ENDPOINTS.orgRepos(org)); - } - - public async fileExistsInRepo(owner: string, repo: string, file: string): Promise { - const resp = await APIHelper.githubRequest( - "GET", - `${GITHUB_API_ENDPOINTS.contents(owner, repo)}/${file}`, - ); - const status = resp.status(); - if (status === 403) { - throw new Error("You don't have permissions to see this path"); - } - return [200, 302, 304].includes(status); - } -} diff --git a/e2e-tests/playwright/support/api/rhdh-auth-hack.ts b/e2e-tests/playwright/support/api/rhdh-auth-hack.ts index add1b0c896..0e3e0906e3 100644 --- a/e2e-tests/playwright/support/api/rhdh-auth-hack.ts +++ b/e2e-tests/playwright/support/api/rhdh-auth-hack.ts @@ -1,7 +1,7 @@ import { Page } from "@playwright/test"; import playwrightConfig from "../../../playwright.config"; -import { UIhelper } from "../../utils/ui-helper"; +import * as navigation from "../../utils/ui-helper/navigation"; //https://redhatquickcourses.github.io/devhub-admin/devhub-admin/1/chapter2/rbac.html#_lab_rbac_rest_api export class RhdhAuthUiHack { @@ -27,7 +27,6 @@ export class RhdhAuthUiHack { } private async fetchApiTokenFromPage(page: Page): Promise { - const uiHelper = new UIhelper(page); const baseURL = playwrightConfig.use?.baseURL; if (baseURL === undefined || baseURL === "") { throw new Error("playwright.config use.baseURL is not defined"); @@ -38,7 +37,7 @@ export class RhdhAuthUiHack { request.url() === `${baseURL}/api/search/query?term=` && request.method() === "GET", { timeout: 15000 }, ); - await uiHelper.openSidebar("Home"); + await navigation.openSidebar(page, "Home"); const getRequest = await requestPromise; const authToken = await getRequest.headerValue("Authorization"); return authToken; diff --git a/e2e-tests/playwright/support/auth/app-shell.ts b/e2e-tests/playwright/support/auth/app-shell.ts new file mode 100644 index 0000000000..e4bea0c7fc --- /dev/null +++ b/e2e-tests/playwright/support/auth/app-shell.ts @@ -0,0 +1,23 @@ +import { expect, type Page } from "@playwright/test"; + +import { waitForRhdhReady } from "../../utils/wait-for-rhdh-ready"; + +const LOADING_INDICATOR_SELECTORS = [ + 'div[class*="MuiLinearProgress-root"]', + '[class*="MuiCircularProgress-root"]', +] as const; + +export async function waitForLoadingToSettle(page: Page, timeout = 120_000): Promise { + for (const selector of LOADING_INDICATOR_SELECTORS) { + const indicator = page.locator(selector).first(); + const visible = await indicator.isVisible().catch(() => false); + if (visible) { + await expect(indicator).toBeHidden({ timeout }); + } + } +} + +export async function waitForAppReady(page: Page, timeout = 120_000): Promise { + await waitForRhdhReady(page.request, timeout); + await waitForLoadingToSettle(page, timeout); +} diff --git a/e2e-tests/playwright/support/auth/guest-auth.ts b/e2e-tests/playwright/support/auth/guest-auth.ts new file mode 100644 index 0000000000..0db71dff6e --- /dev/null +++ b/e2e-tests/playwright/support/auth/guest-auth.ts @@ -0,0 +1,24 @@ +import { type Page } from "@playwright/test"; + +import { getCurrentLanguage, getTranslations } from "../../e2e/localization/locale"; +import * as interaction from "../../utils/ui-helper/interaction"; +import * as navigation from "../../utils/ui-helper/navigation"; +import * as verification from "../../utils/ui-helper/verification"; +import { waitForAppReady } from "./app-shell"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +export async function signInAsGuest(page: Page, timeout = 120_000): Promise { + await page.goto("/"); + await waitForAppReady(page, timeout); + + page.once("dialog", async (dialog) => { + console.log(`Dialog message: ${dialog.message()}`); + await dialog.accept(); + }); + + await verification.verifyHeading(page, t["rhdh"][lang]["signIn.page.title"], timeout); + await interaction.clickButton(page, t["core-components"][lang]["signIn.guestProvider.enter"]); + await navigation.waitForSideBarVisible(page); +} diff --git a/e2e-tests/playwright/support/auth/provider-auth.ts b/e2e-tests/playwright/support/auth/provider-auth.ts new file mode 100644 index 0000000000..3e2cca16f9 --- /dev/null +++ b/e2e-tests/playwright/support/auth/provider-auth.ts @@ -0,0 +1,101 @@ +import { expect, type BrowserContext, type Page } from "@playwright/test"; + +import { getCurrentLanguage, getTranslations } from "../../e2e/localization/locale"; +import * as interaction from "../../utils/ui-helper/interaction"; +import { + handleGitHubPopupLogin, + handleGitlabPopupLogin, + handleKeycloakPopupLogin, + handleMicrosoftAzurePopupLogin, + handlePingFederatePopupLogin, +} from "../../utils/common/auth-popup"; +import { waitForAppReady } from "./app-shell"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +export class AuthProviderSession { + constructor(private readonly page: Page) {} + + async clearAuthState(context: BrowserContext): Promise { + await context.clearCookies(); + await context.clearPermissions(); + } + + private async openLandingPageWithProviderMessage(message: string): Promise { + await this.page.goto("/"); + await waitForAppReady(this.page); + await expect(this.page.getByText(message)).toBeVisible(); + } + + private async openPrimarySignInPopup(): Promise { + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + interaction.clickButton(this.page, t["core-components"][lang]["signIn.title"]), + ]); + return popup; + } + + async loginWithKeycloak(username: string, password: string): Promise { + await this.openLandingPageWithProviderMessage( + t["rhdh"][lang]["signIn.providers.oidc.message"], + ); + const popup = await this.openPrimarySignInPopup(); + return handleKeycloakPopupLogin(popup, username, password); + } + + async loginWithGitHub(username: string, password: string, twofactor: string): Promise { + await this.openLandingPageWithProviderMessage( + t["rhdh"][lang]["signIn.providers.github.message"], + ); + const popup = await this.openPrimarySignInPopup(); + return handleGitHubPopupLogin(popup, username, password, twofactor); + } + + async loginWithGitHubFromSettingsPage( + username: string, + password: string, + twofactor: string, + ): Promise { + await this.page.goto("/settings/auth-providers"); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.page + .getByTitle( + t["user-settings"][lang]["providerSettingsItem.title.signIn"].replace( + "{{title}}", + "GitHub", + ), + ) + .click(), + interaction.clickButton(this.page, t["core-components"][lang]["oauthRequestDialog.login"]), + ]); + + return handleGitHubPopupLogin(popup, username, password, twofactor); + } + + async loginWithGitLab(username: string, password: string): Promise { + await this.openLandingPageWithProviderMessage( + t["rhdh"][lang]["signIn.providers.gitlab.message"], + ); + const popup = await this.openPrimarySignInPopup(); + return handleGitlabPopupLogin(popup, username, password); + } + + async loginWithMicrosoftAzure(username: string, password: string): Promise { + await this.openLandingPageWithProviderMessage( + t["rhdh"][lang]["signIn.providers.microsoft.message"], + ); + const popup = await this.openPrimarySignInPopup(); + return handleMicrosoftAzurePopupLogin(popup, username, password); + } + + async loginWithPingFederate(username: string, password: string): Promise { + await this.openLandingPageWithProviderMessage( + t["rhdh"][lang]["signIn.providers.oidc.message"], + ); + const popup = await this.openPrimarySignInPopup(); + return handlePingFederatePopupLogin(popup, username, password); + } +} diff --git a/e2e-tests/playwright/support/coverage/test.ts b/e2e-tests/playwright/support/coverage/test.ts index cb76b0bcc2..3e335b2734 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,13 @@ import { test as baseTest, expect as baseExpect, + type BrowserContext, type Page, type TestInfo, } from "@playwright/test"; +import { AuthProviderSession } from "../auth/provider-auth"; +import { signInAsGuest } from "../auth/guest-auth"; +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 +125,64 @@ 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; + rhdhGuestPage: Page; + rhdhAuthSession: AuthProviderSession; +}; + +type RhdhPerTestFixtures = { + guestPage: Page; + authSession: AuthProviderSession; +}; + // 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( + { + page: async ({ page }, use, testInfo) => { + await startCoverageForPage(page); + await use(page); + await stopCoverageForPage(page, testInfo); + }, + guestPage: async ({ page }, use) => { + await signInAsGuest(page); + await use(page); + }, + authSession: async ({ page }, use) => { + await use(new AuthProviderSession(page)); + }, + 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" }, + ], + rhdhGuestPage: [ + async ({ rhdhPage }, use) => { + await signInAsGuest(rhdhPage); + await use(rhdhPage); + }, + { scope: "worker" }, + ], + rhdhAuthSession: [ + async ({ rhdhPage }, use) => { + await use(new AuthProviderSession(rhdhPage)); + }, + { 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..93149c76f4 --- /dev/null +++ b/e2e-tests/playwright/support/fixtures/auth-provider-harness.ts @@ -0,0 +1,148 @@ +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; + +type PrepareAuthProviderOptions = { + requiredEnvVars: string[]; + envSecrets?: Record; + extraSecrets?: Record | (() => Record); + beforeSecrets?: () => Promise; + beforeDeploy?: () => Promise; + enableProvider: (deployment: RHDHDeployment) => Promise; +}; + +type AuthLoginCase = { + configure?: () => Promise; + login: () => Promise; + assert: () => Promise; + cleanup?: () => Promise; + expectedResult?: string; +}; + +/** 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 create(namespace: string, instanceName = "rhdh"): AuthProviderHarness { + 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 = deployment.getBackstageUrl(); + const backstageBackendUrl = deployment.getBackstageBackendUrl(); + 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 prepareProvider(options: PrepareAuthProviderOptions): Promise { + this.expectEnvVars(options.requiredEnvVars); + await this.loadConfigsAndProvisionNamespace(); + await options.beforeSecrets?.(); + await this.addBaseUrlSecretsIfRemote(); + + if (options.envSecrets !== undefined) { + await this.addSecretsFromEnv(options.envSecrets); + } + const extraSecrets = + typeof options.extraSecrets === "function" ? options.extraSecrets() : options.extraSecrets; + if (extraSecrets !== undefined) { + for (const [key, value] of Object.entries(extraSecrets)) { + await this.deployment.addSecretData(key, value); + } + } + + await this.createSecret(); + await options.enableProvider(this.deployment); + await this.deployment.updateAllConfigs(); + await options.beforeDeploy?.(); + await this.deployAndWait(); + } + + async reconcileAfterConfigChange(): Promise { + await this.deployment.updateAllConfigs(); + await this.deployment.restartLocalDeployment(); + await this.deployment.waitForConfigReconciled(); + await this.deployment.waitForDeploymentReady(); + await this.deployment.waitForSynced(); + } + + async runLoginCase(options: AuthLoginCase): Promise { + if (options.configure !== undefined) { + await options.configure(); + } + const result = await options.login(); + expect(result).toBe(options.expectedResult ?? "Login successful"); + await options.assert(); + await options.cleanup?.(); + } + + async cleanup(): Promise { + console.log("[TEST] Starting cleanup..."); + await this.deployment.killRunningProcess(); + console.log("[TEST] Cleanup completed"); + } +} diff --git a/e2e-tests/playwright/support/harnesses/runtime-harness.ts b/e2e-tests/playwright/support/harnesses/runtime-harness.ts new file mode 100644 index 0000000000..c5a255eaad --- /dev/null +++ b/e2e-tests/playwright/support/harnesses/runtime-harness.ts @@ -0,0 +1,76 @@ +import { configurePostgresCertificate, configurePostgresCredentials } from "../../utils/postgres-config"; +import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; +import { pollUntil } from "../../utils/poll-until"; + +type ExternalPostgresOptions = { + certificateContent?: string | null; + credentials: { + host: string; + port?: string; + user: string; + password: string; + database?: string; + sslMode?: string; + }; +}; + +export class RuntimeHarness { + constructor( + private readonly namespace: string, + private readonly deploymentName: string = getRhdhDeploymentName(), + private readonly kubeClient: KubeClient = new KubeClient(), + ) {} + + async updateConfigMapTitle(configMapName: string, title: string): Promise { + await this.kubeClient.updateConfigMapTitle(configMapName, this.namespace, title); + } + + async configurePostgresCertificate(certificateContent: string): Promise { + await configurePostgresCertificate(this.kubeClient, this.namespace, certificateContent); + } + + async configurePostgresCredentials( + credentials: ExternalPostgresOptions["credentials"], + ): Promise { + await configurePostgresCredentials(this.kubeClient, this.namespace, credentials); + } + + async restartDeployment(): Promise { + await this.kubeClient.restartDeployment(this.deploymentName, this.namespace); + } + + async restartDeploymentWithRetry(timeoutMs = 90_000, intervalMs = 15_000): Promise { + let lastError: unknown; + try { + await pollUntil( + async () => { + try { + await this.restartDeployment(); + return true; + } catch (error) { + lastError = error; + const message = error instanceof Error ? error.message : String(error); + console.warn(`Deployment restart failed, retrying: ${message}`); + return false; + } + }, + { + timeoutMs, + intervalMs, + label: "Failed to restart deployment", + }, + ); + } catch { + const message = lastError instanceof Error ? lastError.message : "unknown error"; + throw new Error(`Failed to restart deployment: ${message}`); + } + } + + async configureExternalPostgres(options: ExternalPostgresOptions): Promise { + if (options.certificateContent !== undefined && options.certificateContent !== null) { + await this.configurePostgresCertificate(options.certificateContent); + } + await this.configurePostgresCredentials(options.credentials); + await this.restartDeploymentWithRetry(); + } +} 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..9b4ab948a9 --- /dev/null +++ b/e2e-tests/playwright/support/pages/application-provider-test-page.ts @@ -0,0 +1,49 @@ +import { expect, Page } from "@playwright/test"; + +import * as navigation from "../../utils/ui-helper/navigation"; +import * as verification from "../../utils/ui-helper/verification"; + +/** Application provider plugin test page interactions. */ +export class ApplicationProviderTestPage { + constructor(private readonly page: Page) {} + + async open(): Promise { + await navigation.goToPageUrl(this.page, "/application-provider-test-page"); + } + + async verifyTestPageContent(): Promise { + await verification.verifyText(this.page, "application/provider TestPage"); + await verification.verifyText( + this.page, + "This card will work only if you register the TestProviderOne and TestProviderTwo correctly.", + ); + } + + async verifyContextOneCard(): Promise { + await expect(this.contextCards("Context one").first()).toBeVisible(); + } + + async verifyContextTwoCard(): Promise { + await expect(this.contextCards("Context two").first()).toBeVisible(); + } + + 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/backstage-showcase.ts b/e2e-tests/playwright/support/pages/backstage-showcase.ts deleted file mode 100644 index 7a047dc571..0000000000 --- a/e2e-tests/playwright/support/pages/backstage-showcase.ts +++ /dev/null @@ -1,64 +0,0 @@ -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"; - -export class BackstageShowcase { - private readonly page: Page; - private uiHelper: UIhelper; - - constructor(page: Page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - - static getShowcasePRs(state: "open" | "closed" | "all", paginated = false) { - return APIHelper.getGitHubPRs("redhat-developer", "rhdh", state, paginated); - } - - async clickNextPage() { - await BACKSTAGE_SHOWCASE_COMPONENTS.getNextPageButton(this.page).click(); - } - - async clickPreviousPage() { - await BACKSTAGE_SHOWCASE_COMPONENTS.getPreviousPageButton(this.page).click(); - } - - async clickLastPage() { - await BACKSTAGE_SHOWCASE_COMPONENTS.getLastPageButton(this.page).click(); - } - - async verifyPRRowsPerPage(rows: number, allPRs: { title: string; number: string }[]) { - await this.selectRowsPerPage(rows); - await this.uiHelper.verifyText(allPRs[rows - 1].title, false); - await this.uiHelper.verifyLink(allPRs[rows].number, { - exact: false, - notVisible: true, - }); - - const tableRows = BACKSTAGE_SHOWCASE_COMPONENTS.getTableRows(this.page); - await expect(tableRows).toHaveCount(rows); - } - - async selectRowsPerPage(rows: number) { - await this.page.getByRole("combobox").click(); - await this.page.getByRole("option", { name: String(rows) }).click(); - } - - async verifyPRStatisticsRendered() { - const regex = /Average Size Of PR\d+ lines/u; - await this.uiHelper.verifyText(regex); - } - - async verifyAboutCardIsDisplayed() { - const url = "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; - await expect(this.page.locator(`a[href="${url}"]`)).toBeVisible(); - } - - async verifyPRRows(allPRs: { title: string }[], startRow: number, lastRow: number) { - for (let i = startRow; i < lastRow; i++) { - await this.uiHelper.verifyRowsInTable([allPRs[i].title], false); - } - } -} 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..430cc1d29d --- /dev/null +++ b/e2e-tests/playwright/support/pages/catalog-browse-page.ts @@ -0,0 +1,173 @@ +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 { findTableCellByColumn } from "../selectors/semantic/table-helpers"; +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 rowText = await firstRow.textContent(); + if (rowText === null || rowText === "") { + throw new Error("Expected the first catalog row to have text content"); + } + const createdAtCell = await findTableCellByColumn(this.page, rowText, "Created At"); + 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..09115036fd 100644 --- a/e2e-tests/playwright/support/pages/catalog-import.ts +++ b/e2e-tests/playwright/support/pages/catalog-import.ts @@ -1,20 +1,14 @@ 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 * as interaction from "../../utils/ui-helper/interaction"; +import { CATALOG_IMPORT_COMPONENTS } from "../selectors/page-selectors"; const t = getTranslations(); const lang = getCurrentLanguage(); export class CatalogImport { - private page: Page; - private uiHelper: UIhelper; - - constructor(page: Page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } + constructor(private readonly page: Page) {} /** * Fills the component URL input and clicks the "Analyze" button. @@ -25,9 +19,7 @@ export class CatalogImport { private async analyzeAndWait(url: string): Promise { await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); await expect( - await this.uiHelper.clickButton( - t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"], - ), + await interaction.clickButton(this.page, t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"]), ).not.toBeVisible({ timeout: 25_000, }); @@ -40,7 +32,9 @@ export class CatalogImport { * @returns boolean indicating if the component is already registered */ isComponentAlreadyRegistered(): Promise { - return this.uiHelper.isBtnVisible(t["catalog-import"][lang]["stepReviewLocation.refresh"]); + return this.page + .getByRole("button", { name: t["catalog-import"][lang]["stepReviewLocation.refresh"] }) + .isVisible(); } /** @@ -54,16 +48,17 @@ export class CatalogImport { await this.analyzeAndWait(url); const isComponentAlreadyRegistered = await this.isComponentAlreadyRegistered(); if (isComponentAlreadyRegistered) { - await this.uiHelper.clickButton(t["catalog-import"][lang]["stepReviewLocation.refresh"]); + await interaction.clickButton(this.page, t["catalog-import"][lang]["stepReviewLocation.refresh"]); expect( - await this.uiHelper.isBtnVisible( - t["catalog-import"][lang]["stepFinishImportLocation.backButtonText"], - ), + await this.page + .getByRole("button", { name: t["catalog-import"][lang]["stepFinishImportLocation.backButtonText"] }) + .isVisible(), ).toBeTruthy(); } else { - await this.uiHelper.clickButton(t["catalog-import"][lang]["stepReviewLocation.import"]); + await interaction.clickButton(this.page, t["catalog-import"][lang]["stepReviewLocation.import"]); if (clickViewComponent) { - await this.uiHelper.clickButton( + await interaction.clickButton( + this.page, t["catalog-import"][lang]["stepFinishImportLocation.locations.viewButtonText"], ); } @@ -73,16 +68,16 @@ export class CatalogImport { async analyzeComponent(url: string) { await this.page.fill(CATALOG_IMPORT_COMPONENTS.componentURL, url); - await this.uiHelper.clickButton(t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"]); + await interaction.clickButton(this.page, t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"]); } async inspectEntityAndVerifyYaml(text: string) { await this.page.getByTitle("More").click(); await this.page.getByRole("menuitem").getByText("Inspect entity").click(); - await this.uiHelper.clickTab("Raw YAML"); + await interaction.clickTab(this.page, "Raw YAML"); await expect(this.page.getByTestId("code-snippet")).toContainText(text); - await this.uiHelper.clickButton("Close"); + await interaction.clickButton(this.page, "Close"); } } -export { BackstageShowcase } from "./backstage-showcase"; +export { RhdhInstance } from "./rhdh-instance"; diff --git a/e2e-tests/playwright/support/pages/catalog-item.ts b/e2e-tests/playwright/support/pages/catalog-item.ts deleted file mode 100644 index df22f0de9c..0000000000 --- a/e2e-tests/playwright/support/pages/catalog-item.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { expect, Page } from "@playwright/test"; - -import { GITHUB_URL } from "../../utils/constants"; - -export class CatalogItem { - private page: Page; - - githubLink = (path: string): string => { - return `a[href*="${GITHUB_URL}${path}"]`; - }; - - constructor(page: Page) { - this.page = page; - } - - async validateGithubLink(s: string) { - const url = this.githubLink(s); - const link = this.page.locator(url).first(); - await expect(link).toBeVisible(); - } -} 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/catalog.ts b/e2e-tests/playwright/support/pages/catalog.ts deleted file mode 100644 index a8a0a5a381..0000000000 --- a/e2e-tests/playwright/support/pages/catalog.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Locator, Page } from "@playwright/test"; - -import playwrightConfig from "../../../playwright.config"; -import { UIhelper } from "../../utils/ui-helper"; - -//${BASE_URL}/catalog page -export class Catalog { - private page: Page; - private uiHelper: UIhelper; - private searchField: Locator; - - constructor(page: Page) { - this.page = page; - this.uiHelper = new UIhelper(page); - this.searchField = page.getByRole("searchbox").first(); - } - - async go() { - await this.uiHelper.openSidebar("Catalog"); - } - - async goToByName(name: string) { - await this.uiHelper.openCatalogSidebar("Component"); - await this.page.getByRole("textbox", { name: "Search" }).fill(name); - await this.uiHelper.clickLink(name); - } - - async goToBackstageJanusProject() { - await this.goToByName("backstage-janus"); - } - - async search(s: string) { - await this.searchField.clear(); - const baseURL = playwrightConfig.use?.baseURL ?? ""; - const searchResponse = this.page.waitForResponse( - new RegExp(`${baseURL}/api/catalog/entities/by-query/*`, "u"), - ); - await this.searchField.fill(s); - await searchResponse; - } - - tableRow(content: string) { - return this.page.locator(`tr >> a >> text="${content}"`); - } -} diff --git a/e2e-tests/playwright/support/pages/home-page.ts b/e2e-tests/playwright/support/pages/home-page.ts index 9d04660f62..452de891de 100644 --- a/e2e-tests/playwright/support/pages/home-page.ts +++ b/e2e-tests/playwright/support/pages/home-page.ts @@ -1,23 +1,58 @@ import { Page, expect } from "@playwright/test"; -import { UIhelper } from "../../utils/ui-helper"; +import * as interaction from "../../utils/ui-helper/interaction"; +import * as navigation from "../../utils/ui-helper/navigation"; +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 verifyWelcomeHeading(): Promise { + await verification.verifyHeading(this.page, "Welcome back!"); + } + + async openHomeSidebar(): Promise { + await navigation.openSidebar(this.page, "Home"); + } + + async verifyTextInCard(cardHeading: string, text: string | RegExp, exact = true): Promise { + const card = HOME_PAGE_COMPONENTS.getCard(this.page, cardHeading); + await expect(card).toBeVisible(); + if (typeof text === "string") { + await expect(card.getByText(text, { exact })).toBeVisible(); + return; + } + await expect(card.getByText(text)).toBeVisible(); + } + + async verifyHeading(heading: string | RegExp): Promise { + await verification.verifyHeading(this.page, heading); + } + + async verifyDivHasText(text: string | RegExp): Promise { + await verification.verifyDivHasText(this.page, text); + } + + async clickButton(label: string): Promise { + await interaction.clickButton(this.page, label); + } + + async verifyMainHeadingVisible(): Promise { + await expect(this.page.getByRole("heading", { level: 1 })).toBeVisible(); + } + 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/rbac.ts b/e2e-tests/playwright/support/pages/rbac.ts deleted file mode 100644 index 893c7e0677..0000000000 --- a/e2e-tests/playwright/support/pages/rbac.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { APIResponse, Page, expect } from "@playwright/test"; - -import { UIhelper } from "../../utils/ui-helper"; -import { Policy, Role } from "../api/rbac-api-structures"; - -export class Roles { - private readonly page: Page; - private readonly uiHelper: UIhelper; - - constructor(page: Page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - static getRolesListCellsIdentifier() { - const roleName = /^(role|user|group):[a-zA-Z]+\/[\w@*.~-]+$/u; - const usersAndGroups = - /^(1\s(user|group)|[2-9]\s(users|groups))(, (1\s(user|group)|[2-9]\s(users|groups)))?$/u; - const permissionPolicies = /\d/u; - return [roleName, usersAndGroups, permissionPolicies]; - } - - static getUsersAndGroupsListCellsIdentifier() { - const name = /^[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*$/u; - const type = /^(User|Group)$/u; - const members = /^(-|\d+)$/u; - return [name, type, members]; - } - - static getPermissionPoliciesListCellsIdentifier() { - const policies = /^(?:(Read|Create|Update|Delete)(?:, (?:Read|Create|Update|Delete))*|Use)$/u; - return [policies]; - } - - //Depending on the version of the Backstage, it can be 'Permission Policies' or 'Accessible Plugins' - // Accepts either term - static getRolesListColumnsText() { - return [/^Name$/u, /^Users and groups$/u, /Permission Policies|Accessible plugins/u]; - } - - static getUsersAndGroupsListColumnsText() { - return ["Name", "Type", "Members"]; - } - - static getPermissionPoliciesListColumnsText() { - return ["Plugin", "Permission", "Policies"]; - } -} - -export async function removeMetadataFromResponse(response: APIResponse): Promise { - try { - const responseJson: unknown = await response.json(); - - if (!Array.isArray(responseJson)) { - console.warn(`Expected an array but received: ${JSON.stringify(responseJson)}`); - return []; - } - - return responseJson.map((item: unknown) => { - if (typeof item === "object" && item !== null && "metadata" in item) { - const record = { ...(item as Record) }; - delete record.metadata; - return record; - } - return item; - }); - } catch (error) { - console.error("Error processing API response:", error); - throw new Error("Failed to process the API response", { cause: error }); - } -} - -export async function checkRbacResponse(response: APIResponse, expected: Role[] | Policy[]) { - const cleanResponse = await removeMetadataFromResponse(response); - expect(cleanResponse).toEqual(expected); -} diff --git a/e2e-tests/playwright/support/pages/rhdh-instance.ts b/e2e-tests/playwright/support/pages/rhdh-instance.ts new file mode 100644 index 0000000000..87f97d8cfd --- /dev/null +++ b/e2e-tests/playwright/support/pages/rhdh-instance.ts @@ -0,0 +1,77 @@ +import { Page, expect } from "@playwright/test"; + +import { APIHelper } from "../../utils/api-helper"; +import * as verification from "../../utils/ui-helper/verification"; +import { RHDH_INSTANCE_TABLE } from "../selectors/rhdh-instance-table"; + +/** Page object for RHDH instance catalog views (PR tables, entity cards). */ +export class RhdhInstance { + constructor(private readonly page: Page) {} + + static getRhdhPullRequests(state: "open" | "closed" | "all", paginated = false) { + return APIHelper.getGitHubPRs("redhat-developer", "rhdh", state, paginated); + } + + async clickNextPage() { + await RHDH_INSTANCE_TABLE.getNextPageButton(this.page).click(); + } + + async clickPreviousPage() { + await RHDH_INSTANCE_TABLE.getPreviousPageButton(this.page).click(); + } + + async clickLastPage() { + await RHDH_INSTANCE_TABLE.getLastPageButton(this.page).click(); + } + + async verifyPRRowsPerPage(rows: number, allPRs: { title: string; number: string }[]) { + await this.selectRowsPerPage(rows); + await verification.verifyText(this.page, allPRs[rows - 1].title, false); + await verification.verifyLink(this.page, allPRs[rows].number, { exact: false, notVisible: true }); + + const tableRows = RHDH_INSTANCE_TABLE.getTableRows(this.page); + await expect(tableRows).toHaveCount(rows); + } + + async selectRowsPerPage(rows: number) { + await this.page.getByRole("combobox").click(); + await this.page.getByRole("option", { name: String(rows) }).click(); + } + + async verifyPRStatisticsRendered() { + const regex = /Average Size Of PR\d+ lines/u; + await verification.verifyText(this.page, regex); + } + + async verifyAboutCardIsDisplayed() { + const url = "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; + await expect(this.page.locator(`a[href="${url}"]`)).toBeVisible(); + } + + async verifyPRRows(allPRs: { title: string }[], startRow: number, lastRow: number) { + for (let i = startRow; i < lastRow; i++) { + await verification.verifyRowsInTable(this.page, [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..6a0044be43 --- /dev/null +++ b/e2e-tests/playwright/support/pages/self-service-page.ts @@ -0,0 +1,51 @@ +import { Page } from "@playwright/test"; + +import * as interaction from "../../utils/ui-helper/interaction"; +import * as navigation from "../../utils/ui-helper/navigation"; +import * as verification from "../../utils/ui-helper/verification"; +import { SEARCH_OBJECTS_COMPONENTS } from "../selectors/page-selectors"; + +/** Self-service / scaffolder template list interactions. */ +export class SelfServicePage { + constructor(private readonly page: Page) {} + + async open(): Promise { + await navigation.goToSelfServicePage(this.page); + } + + async verifyTemplatesHeading(): Promise { + await verification.verifyHeading(this.page, "Templates"); + } + + async clickImportGitRepository(): Promise { + await interaction.clickButton(this.page, "Import an existing Git repository"); + } + + async clickImportGitRepositoryLocalized(buttonTitle: string): Promise { + await interaction.clickButton(this.page, buttonTitle); + } + + async waitForTemplateTitle(template: string, level = 4): Promise { + await verification.waitForTitle(this.page, template, level); + } + + async verifyHeading(heading: string): Promise { + await verification.verifyHeading(this.page, heading); + } + + async clickButton(label: string): Promise { + await interaction.clickButton(this.page, label); + } + + async searchTemplate(name: string): Promise { + await this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, name); + } + + async verifyTemplateHeading(template: string): Promise { + await verification.verifyHeading(this.page, template); + } + + async verifyText(text: string, exact = true): Promise { + await verification.verifyText(this.page, 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..673c4ba1fd --- /dev/null +++ b/e2e-tests/playwright/support/pages/settings-page.ts @@ -0,0 +1,220 @@ +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 signOut(): Promise { + await this.openUserSettingsMenu(); + await SETTINGS_PAGE_COMPONENTS.getSignOut(this.page).click(); + } + + 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..a59d584e9e --- /dev/null +++ b/e2e-tests/playwright/support/pages/sidebar-page.ts @@ -0,0 +1,62 @@ +import { expect, Page } from "@playwright/test"; + +import { getCurrentLanguage, getTranslations } from "../../e2e/localization/locale"; +import * as navigation from "../../utils/ui-helper/navigation"; +import * as verification from "../../utils/ui-helper/verification"; + +const t = getTranslations(); +const lang = getCurrentLanguage(); + +/** Sidebar navigation on the RHDH instance. */ +export class SidebarPage { + constructor(private readonly page: Page) {} + + getSideBarMenuItem(name: string) { + return this.page.getByTestId("login-button").getByText(name); + } + + async openSidebar(label: string): Promise { + await navigation.openSidebar(this.page, label); + } + + async openSidebarButton(label: string): Promise { + await navigation.openSidebarButton(this.page, label); + } + + async openReferencesLearningPaths(): Promise { + await this.openSidebarButton("References"); + await this.openSidebar("Learning Paths"); + } + + async openFavoritesDocs(): Promise { + await this.openSidebarButton("Favorites"); + await this.openSidebar(t["rhdh"][lang]["menuItem.docs"]); + } + + async verifyDocumentationHeading(): Promise { + await verification.verifyHeading(this.page, "Documentation"); + } + + async verifyText(text: string | RegExp, exact = true): Promise { + await verification.verifyText(this.page, text, exact); + } + + async verifyLinkHidden(name: string): Promise { + await expect(this.page.getByRole("link", { name })).toBeHidden(); + } + + async verifyMenuItemInSection(section: string, itemText: string): Promise { + const sectionMenu = this.getSideBarMenuItem(section); + await expect(sectionMenu.getByText(itemText)).toBeVisible(); + } + + 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..29d4b09e32 --- /dev/null +++ b/e2e-tests/playwright/support/pages/techdocs-page.ts @@ -0,0 +1,24 @@ +import { Page } from "@playwright/test"; + +import * as interaction from "../../utils/ui-helper/interaction"; +import * as verification from "../../utils/ui-helper/verification"; +import { SidebarPage } from "./sidebar-page"; + +/** TechDocs navigation and content verification. */ +export class TechDocsPage { + private readonly sidebar: SidebarPage; + + constructor(private readonly page: Page) { + this.sidebar = new SidebarPage(page); + } + + async openDocFromFavorites(docName: string): Promise { + await this.sidebar.openSidebarButton("Favorites"); + await this.sidebar.openSidebar("Docs"); + await interaction.clickLink(this.page, docName); + } + + async verifyDocHeading(heading: string): Promise { + await verification.verifyHeading(this.page, heading); + } +} diff --git a/e2e-tests/playwright/support/pages/workflows.ts b/e2e-tests/playwright/support/pages/workflows.ts deleted file mode 100644 index 269f1cfd82..0000000000 --- a/e2e-tests/playwright/support/pages/workflows.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Page } from "@playwright/test"; - -const workflowsTable = (page: Page) => page.getByRole("table").filter({ hasText: "Workflows" }); - -const WORKFLOWS = { - workflowsTable, -}; - -export default WORKFLOWS; 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..c630524667 100644 --- a/e2e-tests/playwright/utils/accessibility.ts +++ b/e2e-tests/playwright/utils/accessibility.ts @@ -1,21 +1,46 @@ import AxeBuilder from "@axe-core/playwright"; -import { Page, TestInfo } from "@playwright/test"; +import { expect, Page, TestInfo } from "@playwright/test"; export async function runAccessibilityTests( page: Page, testInfo: TestInfo, attachName = "accessibility-scan-results.violations.json", ) { + // Let Backstage loading indicators finish before scanning the page shell. + const progressBars = page.locator('[role="progressbar"]'); + if ((await progressBars.count()) > 0) { + await expect(progressBars).toBeHidden({ timeout: 60_000 }); + } + // 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/index.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts index 9cad3cb804..432e3be1a2 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment/index.ts @@ -344,12 +344,20 @@ class RHDHDeployment implements RHDHDeploymentState { return followLogsImpl(this, searchString, timeoutMs); } + getBackstageUrl(): string { + return computeBackstageUrlImpl(this); + } + computeBackstageUrl(): Promise { - return Promise.resolve(computeBackstageUrlImpl(this)); + return Promise.resolve(this.getBackstageUrl()); + } + + getBackstageBackendUrl(): string { + return computeBackstageBackendUrlImpl(this); } computeBackstageBackendUrl(): Promise { - return Promise.resolve(computeBackstageBackendUrlImpl(this)); + return Promise.resolve(this.getBackstageBackendUrl()); } async loadAllConfigs(): Promise { 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..df6225ecc2 100644 --- a/e2e-tests/playwright/utils/common/browser.ts +++ b/e2e-tests/playwright/utils/common/browser.ts @@ -1,53 +1,38 @@ -import * as path from "path"; +import * as path from "node:path"; -import { type Browser, type Cookie, type Page, type TestInfo } from "@playwright/test"; +import { type Browser, type BrowserContext, type Page, type TestInfo } from "@playwright/test"; import { startCoverageForPage, stopCoverageForPage } from "../../support/coverage/test"; -export function parseAuthStateCookies(content: string): Cookie[] { - const parsed: unknown = JSON.parse(content); - if ( - typeof parsed !== "object" || - parsed === null || - !("cookies" in parsed) || - !Array.isArray(parsed.cookies) - ) { - throw new TypeError("Invalid auth state: expected object with cookies array"); - } - const rawCookies: unknown[] = parsed.cookies; - const cookies = rawCookies.filter( - (cookie): cookie is Cookie => - typeof cookie === "object" && - cookie !== null && - "name" in cookie && - typeof cookie.name === "string" && - "value" in cookie && - typeof cookie.value === "string", - ); - if (cookies.length !== rawCookies.length) { - throw new TypeError("Invalid auth state: cookies must have name and value"); - } - 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) { +export async function setupBrowser( + browser: Browser, + testInfo: TestInfo, +): Promise<{ page: Page; context: BrowserContext }> { 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: resolveVideoDir(testInfo), + size: { width: 1280, height: 720 }, + }, }); const page = await context.newPage(); await startCoverageForPage(page); - return { page, context }; } 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 deleted file mode 100644 index be52055712..0000000000 --- a/e2e-tests/playwright/utils/common/index.ts +++ /dev/null @@ -1,321 +0,0 @@ -import * as fs from "fs"; - -import { test, 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 { getErrorMessage } from "../errors"; -import { UIhelper } from "../ui-helper"; -import { - handleGitHubPopupLogin, - handleGitlabPopupLogin, - handleKeycloakPopupLogin, - handleMicrosoftAzurePopupLogin, - handlePingFederatePopupLogin, -} from "./auth-popup"; -import { parseAuthStateCookies } from "./browser"; - -export { setupBrowser, teardownBrowser } from "./browser"; - -const t = getTranslations(); -const lang = getCurrentLanguage(); - -const LOADING_INDICATOR_SELECTORS = [ - 'div[class*="MuiLinearProgress-root"]', - '[class*="MuiCircularProgress-root"]', -] as const; - -export class Common { - page: Page; - uiHelper: UIhelper; - private readonly authStateFileName = "authState.json"; - - constructor(page: Page) { - this.page = page; - this.uiHelper = new UIhelper(page); - } - - async loginAsGuest() { - await this.page.goto("/"); - await this.waitForLoad(240000); - // 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()}`); - await dialog.accept(); - }); - - await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.guestProvider.enter"]); - await this.uiHelper.waitForSideBarVisible(); - } - - async waitForLoad(timeout = 120000) { - for (const selector of LOADING_INDICATOR_SELECTORS) { - await this.page.waitForSelector(selector, { - state: "hidden", - timeout: timeout, - }); - } - } - - async signOut() { - await this.page.click(SETTINGS_PAGE_COMPONENTS.userSettingsMenu); - await this.page.click(SETTINGS_PAGE_COMPONENTS.signOut); - await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); - } - - private async logintoGithub(userid: string) { - await this.page.goto("https://github.com/login"); - await this.page.waitForSelector("#login_field"); - await this.page.fill("#login_field", userid); - - const password = - userid === process.env.GH_USER_ID - ? process.env.GH_USER_PASS - : userid === process.env.GH_USER2_ID - ? process.env.GH_USER2_PASS - : undefined; - if (password === undefined || password === "") { - throw new Error("Invalid User ID"); - } - await this.page.fill("#password", password); - - 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 this.page.fill("#app_totp", this.getGitHub2FAOTP(userid)); - } - - await this.page.waitForLoadState("networkidle"); - } - - async logintoKeycloak(userid: string, password: string) { - /* oxlint-disable playwright/no-raw-locators -- Keycloak login popup (third-party) */ - await new Promise((resolve) => { - this.page.once("popup", async (popup) => { - await popup.waitForLoadState(); - await popup.locator("#username").fill(userid); - await popup.locator("#password").fill(password); - try { - await popup.locator("#kc-login").click({ timeout: 5000 }); - } catch (error) { - if (!getErrorMessage(error).includes("Target closed")) { - throw error; - } - } - resolve(); - }); - }); - /* oxlint-enable playwright/no-raw-locators */ - } - - async loginAsKeycloakUser( - userid: string = process.env.GH_USER_ID ?? "", - password: string = process.env.GH_USER_PASS ?? "", - ) { - await this.page.goto("/"); - await this.waitForLoad(240000); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); - await this.logintoKeycloak(userid, password); - await this.uiHelper.waitForSideBarVisible(); - } - - async loginAsGithubUser(userid: string = process.env.GH_USER_ID ?? "") { - const sessionFileName = `authState_${userid}.json`; - - if (fs.existsSync(sessionFileName)) { - const cookies = parseAuthStateCookies(fs.readFileSync(sessionFileName, "utf-8")); - 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.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.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); - await this.checkAndReauthorizeGithubApp(); - await this.uiHelper.waitForSideBarVisible(); - await this.page.context().storageState({ path: sessionFileName }); - console.log(`Authentication state saved for user: ${userid}`); - } - } - - async checkAndReauthorizeGithubApp() { - /* oxlint-disable playwright/no-raw-locators -- GitHub OAuth authorize popup (third-party) */ - await new Promise((resolve) => { - this.page.once("popup", async (popup) => { - await popup.waitForLoadState(); - - const authorizeButton = popup.locator("button.js-oauth-authorize-btn"); - await Promise.race([ - popup.waitForEvent("close", { timeout: 10_000 }), - authorizeButton.waitFor({ state: "visible", timeout: 10_000 }), - ]).catch(() => {}); - - if (!popup.isClosed() && (await authorizeButton.isVisible())) { - await popup.locator("body").click(); - await authorizeButton.waitFor(); - await authorizeButton.click(); - } - resolve(); - }); - }); - /* oxlint-enable playwright/no-raw-locators */ - } - - async checkAndClickOnGHloginPopup(force = false) { - const frameLocator = this.page.getByLabel("Login Required"); - try { - await frameLocator.waitFor({ state: "visible", timeout: 2000 }); - await this.clickOnGHloginPopup(); - } catch (error) { - if (force) throw error; - } - } - - async clickOnGHloginPopup() { - const isLoginRequiredVisible = await this.uiHelper.isTextVisible( - t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], - ); - if (isLoginRequiredVisible) { - await this.uiHelper.clickButton( - t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], - ); - await this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]); - await this.checkAndReauthorizeGithubApp(); - await this.uiHelper.waitForLoginBtnDisappear(); - } else { - console.log('"Log in" button is not visible. Skipping login popup actions.'); - } - } - - getGitHub2FAOTP(userid: string): string { - const ghUserId = process.env.GH_USER_ID; - const ghUser2Id = process.env.GH_USER2_ID; - const secrets: Record = {}; - if (ghUserId !== undefined && ghUserId !== "") { - secrets[ghUserId] = process.env.GH_2FA_SECRET; - } - if (ghUser2Id !== undefined && ghUser2Id !== "") { - secrets[ghUser2Id] = process.env.GH_USER2_2FA_SECRET; - } - - const secret = secrets[userid]; - if (secret === undefined || secret === "") { - throw new Error("Invalid User ID"); - } - - return authenticator.generate(secret); - } - - getGoogle2FAOTP(): string { - const secret = process.env.GOOGLE_2FA_SECRET; - if (secret === undefined || secret === "") { - throw new Error("GOOGLE_2FA_SECRET is not set"); - } - return authenticator.generate(secret); - } - - async keycloakLogin(username: string, password: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handleKeycloakPopupLogin(popup, username, password); - } - - 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"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handleGitHubPopupLogin(popup, username, password, twofactor); - } - - async githubLoginFromSettingsPage(username: string, password: string, twofactor: string) { - await this.page.goto("/settings/auth-providers"); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.page - .getByTitle( - t["user-settings"][lang]["providerSettingsItem.title.signIn"].replace( - "{{title}}", - "GitHub", - ), - ) - .click(), - this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]), - ]); - - return handleGitHubPopupLogin(popup, username, password, twofactor); - } - - async gitlabLogin(username: string, password: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.gitlab.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handleGitlabPopupLogin(popup, username, password); - } - - async MicrosoftAzureLogin(username: string, password: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.microsoft.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handleMicrosoftAzurePopupLogin(popup, username, password); - } - - async pingFederateLogin(username: string, password: string) { - await this.page.goto("/"); - await this.page.waitForSelector( - `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, - ); - - const [popup] = await Promise.all([ - this.page.waitForEvent("popup"), - this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), - ]); - - return handlePingFederatePopupLogin(popup, username, password); - } -} 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 deleted file mode 100644 index 2613c9b133..0000000000 --- a/e2e-tests/playwright/utils/keycloak/keycloak.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { expect, Page } from "@playwright/test"; - -import { CatalogUsersPO } from "../../support/page-objects/catalog/catalog-users-obj"; -import { UIhelper } from "../ui-helper"; -import Group from "./group"; -import User from "./user"; - -interface AuthResponse { - access_token: string; -} - -function isAuthResponse(data: unknown): data is AuthResponse { - return ( - typeof data === "object" && - data !== null && - "access_token" in data && - typeof Reflect.get(data, "access_token") === "string" - ); -} - -function isUserArray(data: unknown): data is User[] { - return Array.isArray(data); -} - -function isGroupArray(data: unknown): data is Group[] { - return Array.isArray(data); -} - -function requireBase64Env(name: string): string { - const value = process.env[name]; - if (value === undefined || value === "") { - throw new Error(`Missing required environment variable: ${name}`); - } - return Buffer.from(value, "base64").toString(); -} - -class Keycloak { - private readonly baseURL: string; - private readonly realm: string; - private readonly clientId: string; - private readonly clientSecret: string; - - constructor() { - this.baseURL = requireBase64Env("KEYCLOAK_AUTH_BASE_URL"); - this.realm = requireBase64Env("KEYCLOAK_AUTH_REALM"); - this.clientSecret = requireBase64Env("KEYCLOAK_AUTH_CLIENT_SECRET"); - this.clientId = requireBase64Env("KEYCLOAK_AUTH_CLIENTID"); - } - - async getAuthenticationToken(): Promise { - const response = await fetch( - `${this.baseURL}/auth/realms/${this.realm}/protocol/openid-connect/token`, - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "client_credentials", - client_id: this.clientId, - client_secret: this.clientSecret, - }).toString(), - }, - ); - - if (response.status !== 200) throw new Error("Failed to authenticate"); - const data: unknown = await response.json(); - if (!isAuthResponse(data)) { - throw new Error("Failed to authenticate: invalid token response"); - } - return data.access_token; - } - - async getUsers(authToken: string): Promise { - const response = await fetch(`${this.baseURL}/auth/admin/realms/${this.realm}/users`, { - method: "GET", - headers: { - Authorization: `Bearer ${authToken}`, - }, - }); - - if (response.status !== 200) { - const errorText = await response.text(); - throw new Error(`Failed to get users: ${response.status} - ${errorText}`); - } - const data: unknown = await response.json(); - if (!isUserArray(data)) { - throw new Error("Failed to get users: invalid response format"); - } - return data; - } - - async getGroupsOfUser(authToken: string, userId: string): Promise { - const response = await fetch( - `${this.baseURL}/auth/admin/realms/${this.realm}/users/${userId}/groups`, - { - method: "GET", - headers: { - Authorization: `Bearer ${authToken}`, - }, - }, - ); - - if (response.status !== 200) { - const errorText = await response.text(); - throw new Error(`Failed to get groups of user: ${response.status} - ${errorText}`); - } - const data: unknown = await response.json(); - if (!isGroupArray(data)) { - throw new Error("Failed to get groups of user: invalid response format"); - } - return data; - } - - async checkUserDetails( - page: Page, - keycloakUser: User, - token: string, - uiHelper: UIhelper, - keycloak: Keycloak, - ) { - await CatalogUsersPO.visitUserPage(page, keycloakUser.username); - const emailLink = CatalogUsersPO.getEmailLink(page); - 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); - await expect(groupLink).toBeVisible(); - } - - await CatalogUsersPO.visitBaseURL(page); - } -} - -export default Keycloak; 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 b201471e71..cd5f970e18 100644 --- a/e2e-tests/playwright/utils/kube-client/helpers.ts +++ b/e2e-tests/playwright/utils/kube-client/helpers.ts @@ -145,13 +145,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 0c8ae85e22..cfeef0ecda 100644 --- a/e2e-tests/playwright/utils/kube-client/index.ts +++ b/e2e-tests/playwright/utils/kube-client/index.ts @@ -2,6 +2,7 @@ import * as k8s from "@kubernetes/client-node"; import { V1ConfigMap } from "@kubernetes/client-node"; 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"; @@ -337,6 +338,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, @@ -344,6 +346,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/port-forward.ts b/e2e-tests/playwright/utils/port-forward.ts new file mode 100644 index 0000000000..e7d2a602dc --- /dev/null +++ b/e2e-tests/playwright/utils/port-forward.ts @@ -0,0 +1,119 @@ +import type { ChildProcessByStdio } from "node:child_process"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import type { Readable } from "node:stream"; + +type PortForwardCommand = + | { + command: string; + args: string[]; + } + | { + shellCommand: string; + }; + +type PortForwardOptions = { + readyPattern: RegExp; + readyTimeoutMs?: number; + stopTimeoutMs?: number; +}; + +export class PortForwardSession { + private child: ChildProcessByStdio | null = null; + private readonly output: string[] = []; + + constructor( + private readonly command: PortForwardCommand, + private readonly options: PortForwardOptions, + ) {} + + get process(): ChildProcessByStdio | null { + return this.child; + } + + async start(): Promise> { + if (this.child !== null) { + return this.child; + } + + this.output.length = 0; + const child = + "shellCommand" in this.command + ? spawn("/bin/sh", ["-c", this.command.shellCommand], { + stdio: ["ignore", "pipe", "pipe"], + }) + : spawn(this.command.command, this.command.args, { + stdio: ["ignore", "pipe", "pipe"], + }); + + this.child = child; + + const readyTimeoutMs = this.options.readyTimeoutMs ?? 30_000; + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for port-forward to be ready.\n${this.output.join("")}`)); + }, readyTimeoutMs); + + const handleOutput = (chunk: Buffer | string) => { + const text = chunk.toString(); + this.output.push(text); + if (this.options.readyPattern.test(text)) { + cleanup(); + resolve(); + } + }; + + const handleExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + reject( + new Error( + `Port-forward exited before it became ready (code=${code}, signal=${signal}).\n${this.output.join("")}`, + ), + ); + }; + + const cleanup = () => { + clearTimeout(timeout); + child.stdout.off("data", handleOutput); + child.stderr.off("data", handleOutput); + child.off("exit", handleExit); + }; + + child.stdout.on("data", handleOutput); + child.stderr.on("data", handleOutput); + child.on("exit", handleExit); + }); + + return child; + } + + async restart(): Promise> { + await this.stop(); + return this.start(); + } + + async stop(): Promise { + const child = this.child; + if (child === null) { + return; + } + + this.child = null; + + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + + const stopTimeoutMs = this.options.stopTimeoutMs ?? 5_000; + const killTimeout = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + }, stopTimeoutMs); + + child.kill("SIGTERM"); + await once(child, "exit"); + clearTimeout(killTimeout); + } +} diff --git a/e2e-tests/playwright/utils/postgres-config.ts b/e2e-tests/playwright/utils/postgres-config.ts index 61fececc4d..657dc85fd9 100644 --- a/e2e-tests/playwright/utils/postgres-config.ts +++ b/e2e-tests/playwright/utils/postgres-config.ts @@ -15,6 +15,7 @@ import { readFileSync, existsSync } from "fs"; import { Client } from "pg"; import { KubeClient } from "./kube-client"; +import { sleep } from "./poll-until"; /** * Convert escaped newlines (\n) to actual newline characters. @@ -148,11 +149,7 @@ async function dropDatabaseWithRetry( console.log( `Retry ${attempt}/${maxRetries} for database ${db} after ${delay}ms (${errorMsg})`, ); - await new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, delay); - }); + await sleep(delay); } } return false; diff --git a/e2e-tests/playwright/utils/ui-helper/class.ts b/e2e-tests/playwright/utils/ui-helper/class.ts deleted file mode 100644 index cac7e08c57..0000000000 --- a/e2e-tests/playwright/utils/ui-helper/class.ts +++ /dev/null @@ -1,310 +0,0 @@ -import { Locator, Page } from "@playwright/test"; - -import { SEARCH_OBJECTS_COMPONENTS } from "../../support/page-objects/page-obj"; -import * as interaction from "./interaction"; -import * as misc from "./misc"; -import * as navigation from "./navigation"; -import * as table from "./table"; -import * as verification from "./verification"; -import * as visibility from "./visibility"; - -export class UIhelper { - private page: Page; - - constructor(page: Page) { - this.page = page; - } - - verifyComponentInCatalog(kind: string, expectedRows: string[]) { - return misc.verifyComponentInCatalog(this.page, kind, expectedRows); - } - - getSideBarMenuItem(menuItem: string): Locator { - return this.page.getByTestId("login-button").getByText(menuItem); - } - - fillTextInputByLabel(label: string, text: string) { - return interaction.fillTextInputByLabel(this.page, label, text); - } - - searchInputPlaceholder(searchText: string) { - return this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, searchText); - } - - searchInputAriaLabel(searchText: string) { - return this.page.fill(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, searchText); - } - - pressTab() { - return interaction.pressTab(this.page); - } - - checkCheckbox(text: string) { - return interaction.checkCheckbox(this.page, text); - } - - uncheckCheckbox(text: string) { - return interaction.uncheckCheckbox(this.page, text); - } - - clickButton(label: string | RegExp, options?: { exact?: boolean; force?: boolean }) { - return interaction.clickButton(this.page, label, options); - } - - clickBtnByTitleIfNotPressed(title: string) { - return interaction.clickBtnByTitleIfNotPressed(this.page, title); - } - - clickByDataTestId(dataTestId: string) { - return interaction.clickByDataTestId(this.page, dataTestId); - } - - clickDivByTitle(title: string) { - return interaction.clickDivByTitle(this.page, title); - } - - clickButtonByText( - buttonText: string | RegExp, - options?: { - exact?: boolean; - timeout?: number; - force?: boolean; - }, - ) { - return interaction.clickButtonByText(this.page, buttonText, options); - } - - clickButtonByLabel(label: string | RegExp) { - return interaction.clickButtonByLabel(this.page, label); - } - - markAllNotificationsAsReadIfVisible() { - return navigation.markAllNotificationsAsReadIfVisible(this.page); - } - - clickByTitleIfVisible(title: string, elementType: string = "div") { - return interaction.clickByTitleIfVisible(this.page, title, elementType); - } - - verifyDivHasText(divText: string | RegExp) { - return verification.verifyDivHasText(this.page, divText); - } - - clickLink(options: string | { href: string } | { ariaLabel: string }) { - return interaction.clickLink(this.page, options); - } - - openProfileDropdown() { - return navigation.openProfileDropdown(this.page); - } - - goToPageUrl(url: string, heading?: string) { - return navigation.goToPageUrl(this.page, url, heading); - } - - goToMyProfilePage() { - return navigation.goToMyProfilePage(this.page); - } - - goToSettingsPage() { - return navigation.goToSettingsPage(this.page); - } - - goToSelfServicePage() { - return navigation.goToSelfServicePage(this.page); - } - - verifyLink(arg: string | { label: string }, options?: { exact?: boolean; notVisible?: boolean }) { - return verification.verifyLink(this.page, arg, options); - } - - isBtnVisibleByTitle(text: string) { - return visibility.isBtnVisibleByTitle(this.page, text); - } - - isBtnVisible(text: string) { - return visibility.isBtnVisible(this.page, text); - } - - isTextVisible(text: string, timeout = 10000) { - return visibility.isTextVisible(this.page, text, timeout); - } - - verifyTextVisible(text: string, exact = false, timeout = 10000) { - return verification.verifyTextVisible(this.page, text, exact, timeout); - } - - verifyLinkVisible(text: string, timeout = 10000) { - return verification.verifyLinkVisible(this.page, text, timeout); - } - - waitForSideBarVisible() { - return navigation.waitForSideBarVisible(this.page); - } - - openSidebar(navBarText: string) { - return navigation.openSidebar(this.page, navBarText); - } - - openCatalogSidebar(kind: string) { - return navigation.openCatalogSidebar(this.page, kind); - } - - openSidebarButton(navBarButtonLabel: string) { - return navigation.openSidebarButton(this.page, navBarButtonLabel); - } - - selectMuiBox(label: string, value: string, notVisible?: boolean) { - return navigation.selectMuiBox(this.page, label, value, notVisible); - } - - verifyRowsInTable(rowTexts: (string | RegExp)[], exact: boolean = true) { - return verification.verifyRowsInTable(this.page, rowTexts, exact); - } - - waitForTextDisappear(text: string) { - return verification.waitForTextDisappear(this.page, text); - } - - verifyText(text: string | RegExp, exact: boolean = true, timeout: number = 5000) { - return verification.verifyText(this.page, text, exact, timeout); - } - - verifyTextInSelector(selector: string, expectedText: string) { - return verification.verifyTextInSelector(this.page, selector, expectedText); - } - - verifyPartialTextInSelector(selector: string, partialText: string) { - return verification.verifyPartialTextInSelector(this.page, selector, partialText); - } - - verifyColumnHeading(rowTexts: string[] | RegExp[], exact: boolean = true) { - return verification.verifyColumnHeading(this.page, rowTexts, exact); - } - - verifyHeading(heading: string | RegExp, timeout: number = 20000) { - return verification.verifyHeading(this.page, heading, timeout); - } - - verifyParagraph(paragraph: string) { - return verification.verifyParagraph(this.page, paragraph); - } - - waitForTitle(text: string, level: number = 1) { - return verification.waitForTitle(this.page, text, level); - } - - clickTab(tabName: string) { - return interaction.clickTab(this.page, tabName); - } - - verifyCellsInTable(texts: (string | RegExp)[]) { - return table.verifyCellsInTable(this.page, texts); - } - - getButtonSelector(label: string): string { - return `button:has-text("${label}")`; - } - - getLoginBtnSelector(): string { - return 'button:has-text("Log in")'; - } - - waitForLoginBtnDisappear() { - return table.waitForLoginBtnDisappear(this.page); - } - - verifyButtonURL( - label: string | RegExp, - url: string | RegExp, - options?: { locator?: string | Locator; exact?: boolean }, - ) { - return table.verifyButtonURL(this.page, label, url, options); - } - - verifyRowInTableByUniqueText(uniqueRowText: string, cellTexts: string[] | RegExp[]) { - return table.verifyRowInTableByUniqueText(this.page, uniqueRowText, cellTexts); - } - - clickOnLinkInTableByUniqueText( - uniqueRowText: string, - linkText: string | RegExp, - exact: boolean = true, - ) { - return table.clickOnLinkInTableByUniqueText(this.page, uniqueRowText, linkText, exact); - } - - clickOnButtonInTableByUniqueText(uniqueRowText: string, textOrLabel: string | RegExp) { - return table.clickOnButtonInTableByUniqueText(this.page, uniqueRowText, textOrLabel); - } - - verifyLinkinCard(cardHeading: string, linkText: string, exact = true) { - return misc.verifyLinkinCard(this.page, cardHeading, linkText, exact); - } - - clickBtnInCard(cardText: string, btnText: string, exact = true) { - return interaction.clickBtnInCard(this.page, cardText, btnText, exact); - } - - verifyTextinCard(cardHeading: string, text: string | RegExp, exact = true) { - return misc.verifyTextinCard(this.page, cardHeading, text, exact); - } - - verifyTableHeadingAndRows(texts: string[]) { - return table.verifyTableHeadingAndRows(this.page, texts); - } - - toRgb(color: string): string { - return misc.toRgb(color); - } - - checkCssColor(page: Page, selector: string, expectedColor: string) { - return misc.checkCssColor(page, selector, expectedColor); - } - - verifyTableIsEmpty() { - return table.verifyTableIsEmpty(this.page); - } - - waitForCardWithHeader(cardHeading: string) { - return misc.waitForCardWithHeader(this.page, cardHeading); - } - - verifyAlertErrorMessage(message: string | RegExp) { - return verification.verifyAlertErrorMessage(this.page, message); - } - - clickById(id: string) { - return interaction.clickById(this.page, id); - } - - clickSpanByText(text: string) { - return verification.clickSpanByText(this.page, text); - } - - verifyLocationRefreshButtonIsEnabled(locationName: string) { - return misc.verifyLocationRefreshButtonIsEnabled(this.page, locationName); - } - - clickUnregisterButtonForDisplayedEntity( - buttonName: "Delete Entity" | "Unregister Location" = "Delete Entity", - ) { - return misc.clickUnregisterButtonForDisplayedEntity(this.page, buttonName); - } - - verifyPluginRow(text: string, expectedEnabled: string, expectedPreinstalled: string) { - return table.verifyPluginRow(this.page, text, expectedEnabled, expectedPreinstalled); - } - - verifyTextInTooltip(text: string | RegExp) { - return verification.verifyTextInTooltip(this.page, text); - } - - hideQuickstartIfVisible() { - return misc.hideQuickstartIfVisible(this.page); - } - - openQuickstartIfHidden() { - return misc.openQuickstartIfHidden(this.page); - } -} diff --git a/e2e-tests/playwright/utils/ui-helper/index.ts b/e2e-tests/playwright/utils/ui-helper/index.ts deleted file mode 100644 index 931149cc10..0000000000 --- a/e2e-tests/playwright/utils/ui-helper/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UIhelper } from "./class"; 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..b1d7584950 100644 --- a/e2e-tests/playwright/utils/ui-helper/navigation.ts +++ b/e2e-tests/playwright/utils/ui-helper/navigation.ts @@ -47,13 +47,15 @@ export async function goToSelfServicePage(page: Page) { } export async function waitForSideBarVisible(page: Page) { - await page.waitForSelector("nav a", { timeout: 10_000 }); + await expect(page.getByRole("navigation").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"); + const navLink = page.getByRole("link", { name: navBarText }).first(); + await expect(navLink).toBeVisible({ timeout: 15_000 }); + await navLink.click(); } export async function openCatalogSidebar(page: Page, kind: string) { diff --git a/e2e-tests/playwright/utils/ui-helper/table.ts b/e2e-tests/playwright/utils/ui-helper/table.ts index d9f2f0cb58..dfcc9d375c 100644 --- a/e2e-tests/playwright/utils/ui-helper/table.ts +++ b/e2e-tests/playwright/utils/ui-helper/table.ts @@ -1,6 +1,7 @@ import { expect, Locator, Page } from "@playwright/test"; -import { getTableCell, getTableRow } from "../../support/page-objects/ui-locators"; +import { findTableCellByColumn } from "../../support/selectors/semantic/table-helpers"; +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 +85,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(); @@ -108,13 +111,8 @@ export async function verifyPluginRow( expectedEnabled: string, expectedPreinstalled: string, ) { - const rowSelector = `tr:has(td:text-is("${text}"))`; - const row = page.locator(rowSelector); - - // Index 2 for "Enabled" - const enabledColumn = row.getByRole("cell").nth(2); - // Index 3 for "Preinstalled" - const preinstalledColumn = row.getByRole("cell").nth(3); + const enabledColumn = await findTableCellByColumn(page, text, "Enabled"); + const preinstalledColumn = await findTableCellByColumn(page, text, "Preinstalled"); await expect(enabledColumn).toHaveText(expectedEnabled); await expect(preinstalledColumn).toHaveText(expectedPreinstalled); diff --git a/e2e-tests/playwright/utils/ui-helper/verification.ts b/e2e-tests/playwright/utils/ui-helper/verification.ts index ffb25312e2..246f1fa5d3 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)).toBeHidden(); } 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 14455da54a..7906a4b0c6 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.59.1" "@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.0" + vitest: "npm:^4.1.9" winston: "npm:3.14.2" yaml: "npm:2.9.0" languageName: unknown @@ -2171,6 +2481,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" @@ -2180,18 +2497,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" @@ -2229,6 +2534,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" @@ -2238,6 +2552,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" @@ -2303,6 +2624,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" @@ -2401,19 +2734,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" @@ -2460,6 +2780,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" @@ -2469,6 +2799,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" @@ -2483,7 +2822,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: @@ -2659,15 +2998,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" @@ -3132,6 +3462,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.defaults@npm:^4.2.0": version: 4.2.0 resolution: "lodash.defaults@npm:4.2.0" @@ -3223,6 +3673,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" @@ -3294,7 +3753,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: @@ -3460,6 +3919,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" @@ -3540,6 +4008,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" @@ -3825,6 +4300,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" @@ -3927,6 +4409,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" @@ -3934,6 +4423,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" @@ -3995,6 +4491,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" @@ -4186,6 +4693,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" @@ -4312,6 +4877,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" @@ -4356,6 +4928,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" @@ -4407,6 +4986,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" @@ -4414,6 +5000,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" @@ -4611,6 +5204,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" @@ -4618,6 +5235,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" @@ -4680,7 +5304,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 @@ -4872,6 +5496,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" @@ -4933,6 +5682,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"