diff --git a/.github/workflows/e2e-tests-lint.yaml b/.github/workflows/e2e-tests-lint.yaml index 01414339f3..f8d6dbdaaa 100644 --- a/.github/workflows/e2e-tests-lint.yaml +++ b/.github/workflows/e2e-tests-lint.yaml @@ -13,7 +13,7 @@ on: jobs: lint: - name: TSC, ESLint, ShellCheck and Prettier + name: Oxlint, Oxfmt, and ShellCheck runs-on: ubuntu-latest steps: @@ -33,18 +33,18 @@ jobs: working-directory: ./e2e-tests run: yarn install --mode=skip-build - - name: Run TypeScript Compiler check + - name: Run Oxlint working-directory: ./e2e-tests - run: yarn tsc:check + run: yarn lint - - name: Run ESLint check + - name: Verify Playwright test collection working-directory: ./e2e-tests - run: yarn lint:check + run: yarn test:list - name: Run ShellCheck working-directory: ./e2e-tests run: yarn shellcheck - - name: Run Prettier check + - name: Run Oxfmt check working-directory: ./e2e-tests - run: yarn prettier:check + run: yarn fmt:check diff --git a/e2e-tests/.lintstagedrc.js b/e2e-tests/.lintstagedrc.js index 60e3600c30..99810f1ed0 100644 --- a/e2e-tests/.lintstagedrc.js +++ b/e2e-tests/.lintstagedrc.js @@ -3,7 +3,6 @@ */ export default { "*.sh": "shellcheck --severity=warning --color=always", - "*": "yarn prettier:fix", + "*": "yarn fmt", "*.{js,jsx,ts,tsx,mjs,cjs}": "yarn lint:fix", - "*.{ts,tsx}": () => "yarn tsc:check", }; diff --git a/e2e-tests/.oxfmtrc.json b/e2e-tests/.oxfmtrc.json new file mode 100644 index 0000000000..b2384c0567 --- /dev/null +++ b/e2e-tests/.oxfmtrc.json @@ -0,0 +1,5 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "sortImports": true, + "ignorePatterns": [".local-test", "coverage"] +} diff --git a/e2e-tests/.prettierrc.cjs b/e2e-tests/.prettierrc.cjs deleted file mode 100644 index 6439e38fba..0000000000 --- a/e2e-tests/.prettierrc.cjs +++ /dev/null @@ -1,50 +0,0 @@ -// @ts-check - -/** @type {import("prettier").Config} */ -module.exports = { - plugins: ["prettier-plugin-sh"], - overrides: [ - { - files: "*.sh", - options: { - parser: "sh", - // Shell script specific formatting options - keepComments: true, - indent: 2, - endOfLine: "lf", - }, - }, - { - files: "*.md", - options: { - parser: "markdown", - // Markdown specific formatting options - tabWidth: 2, - useTabs: false, - proseWrap: "preserve", - endOfLine: "lf", - }, - }, - { - files: "*.{yaml,yml}", - options: { - parser: "yaml", - // YAML specific formatting options - tabWidth: 2, - useTabs: false, - endOfLine: "lf", - }, - }, - ], - // General Prettier options (explicit defaults) - printWidth: 80, - tabWidth: 2, - useTabs: false, - semi: true, - singleQuote: false, - trailingComma: "all", - bracketSpacing: true, - bracketSameLine: false, - arrowParens: "always", - endOfLine: "lf", -}; diff --git a/e2e-tests/eslint.config.js b/e2e-tests/eslint.config.js deleted file mode 100644 index 47cbf6bfef..0000000000 --- a/e2e-tests/eslint.config.js +++ /dev/null @@ -1,136 +0,0 @@ -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; -import checkFile from "eslint-plugin-check-file"; -import { fileURLToPath } from "url"; -import { dirname } from "path"; -import playwright from "eslint-plugin-playwright"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -export default [ - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ["**/*.ts", "**/*.tsx"], - languageOptions: { - parserOptions: { - project: "./tsconfig.json", - tsconfigRootDir: __dirname, - }, - }, - rules: { - "@typescript-eslint/naming-convention": [ - "error", - { - selector: "variable", - format: ["camelCase"], - }, - { - selector: "variable", - modifiers: ["const", "exported"], - format: ["UPPER_CASE"], - }, - { - selector: "function", - format: ["camelCase"], - }, - { - selector: "parameter", - format: ["camelCase"], - leadingUnderscore: "allow", - }, - { - selector: "memberLike", - modifiers: ["private"], - format: ["camelCase"], - leadingUnderscore: "allow", - }, - { - selector: "typeLike", - format: ["PascalCase"], - }, - { - selector: "enumMember", - format: ["UPPER_CASE"], - }, - { - selector: "class", - format: ["PascalCase"], - }, - ], - "@typescript-eslint/no-floating-promises": "error", - "@typescript-eslint/await-thenable": "error", - }, - }, - { - files: ["**/*.{js,ts,jsx,tsx}"], - plugins: { - "check-file": checkFile, - }, - rules: { - "check-file/filename-naming-convention": [ - "error", - { - "**/*.{js,ts,jsx,tsx}": "KEBAB_CASE", - }, - { - ignoreMiddleExtensions: true, - }, - ], - "check-file/folder-naming-convention": [ - "error", - { - "**": "KEBAB_CASE", - }, - ], - }, - }, - { - ignores: [ - "node_modules/**", - "playwright-report/**", - "test-results/**", - "coverage/**", - ".local-test/**", - ".prettierrc.cjs", - ], - }, - // Playwright recommended rules for test files - { - ...playwright.configs["flat/recommended"], - files: ["**/*.spec.ts", "**/*.test.ts", "playwright/**/*.ts"], - rules: { - ...playwright.configs["flat/recommended"].rules, - // Only disable rules that cause errors, keep warnings - "playwright/expect-expect": "off", // Allow tests without explicit assertions - "playwright/valid-title": "off", // Allow duplicate prefixes in test titles - "playwright/valid-describe-callback": "off", // Allow async describe callbacks - "playwright/valid-expect": "error", // Keep this as error to catch missing matchers - "playwright/no-wait-for-selector": "off", // Allow wait for selector - "playwright/no-wait-for-timeout": "off", // Allow wait for timeout - "playwright/prefer-native-locators": "warn", - "playwright/no-raw-locators": [ - "warn", - { - allowed: [], - }, - ], - "playwright/no-skipped-test": [ - "warn", - { - allowConditional: true, - }, - ], - "no-restricted-syntax": [ - "error", - { - selector: - "CallExpression[callee.name='test'] > ArrowFunctionExpression CallExpression[callee.property.name='fixme'][callee.object.name='test'] > ArrowFunctionExpression.arguments:first-child", - message: - "test.fixme() inside a test body should use a boolean condition, not a function. Use: test.fixme(condition) instead of test.fixme(() => condition)", - }, - ], - }, - }, -]; diff --git a/e2e-tests/oxlint.config.ts b/e2e-tests/oxlint.config.ts new file mode 100644 index 0000000000..102f60fa49 --- /dev/null +++ b/e2e-tests/oxlint.config.ts @@ -0,0 +1,117 @@ +import { defineConfig } from "oxlint"; + +export default defineConfig({ + plugins: ["eslint", "typescript", "unicorn", "oxc", "import", "node", "promise"], + categories: { + correctness: "error", + suspicious: "error", + }, + options: { + typeAware: true, + typeCheck: true, + }, + jsPlugins: ["eslint-plugin-playwright", "eslint-plugin-check-file"], + ignorePatterns: [ + "node_modules/**", + "playwright-report/**", + "test-results/**", + "coverage/**", + ".local-test/**", + ], + rules: { + "typescript/no-floating-promises": "error", + "typescript/await-thenable": "error", + "typescript/no-unsafe-assignment": "error", + "typescript/no-unsafe-member-access": "error", + "typescript/no-unsafe-call": "error", + "typescript/no-unsafe-return": "error", + "typescript/strict-void-return": "error", + "check-file/filename-naming-convention": [ + "error", + { + "**/*.{js,ts,jsx,tsx}": "KEBAB_CASE", + }, + { + ignoreMiddleExtensions: true, + }, + ], + "check-file/folder-naming-convention": [ + "error", + { + "**": "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: [ + { + files: ["playwright/e2e/auth-providers/**/*.spec.ts"], + rules: { + "typescript/strict-void-return": "off", + }, + }, + { + files: ["**/*.spec.ts", "**/*.test.ts", "playwright/**/*.ts"], + 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. + "eslint/no-empty-pattern": "off", + "playwright/valid-title": "off", + "playwright/valid-describe-callback": "off", + "playwright/no-wait-for-selector": "off", + "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", + "waitForTitle", + ], + }, + ], + }, + }, + ], +}); diff --git a/e2e-tests/package.json b/e2e-tests/package.json index 36e0cb46b0..704a6de808 100644 --- a/e2e-tests/package.json +++ b/e2e-tests/package.json @@ -3,9 +3,6 @@ "version": "1.11.0", "private": true, "type": "module", - "engines": { - "node": "24" - }, "scripts": { "showcase": "playwright test --project=showcase", "showcase-rbac": "playwright test --project=showcase-rbac", @@ -23,35 +20,13 @@ "showcase-localization-fr": "LOCALE=fr playwright test --project=showcase-localization-fr", "showcase-localization-it": "LOCALE=it playwright test --project=showcase-localization-it", "showcase-localization-ja": "LOCALE=ja playwright test --project=showcase-localization-ja", - "lint:check": "eslint . --ext .js,.ts", - "lint:fix": "eslint . \"playwright/**/*.{ts,js}\" --fix", + "lint": "oxlint .", + "lint:fix": "oxlint --fix .", + "test:list": "playwright test --list", + "fmt": "oxfmt .", + "fmt:check": "oxfmt --check .", "postinstall": "playwright install chromium", - "tsc": "tsc", - "tsc:check": "tsc -p tsconfig.json", - "shellcheck": "git ls-files -z '*.sh' | xargs -0 shellcheck --severity=warning --color=always", - "prettier:check": "prettier --ignore-unknown --check .", - "prettier:fix": "prettier --ignore-unknown --write ." - }, - "devDependencies": { - "@axe-core/playwright": "4.11.2", - "@eslint/js": "9.39.4", - "@microsoft/microsoft-graph-types": "2.43.1", - "@playwright/test": "1.59.1", - "@types/node": "24.12.2", - "@types/pg": "8.20.0", - "@typescript-eslint/eslint-plugin": "8.59.4", - "@typescript-eslint/parser": "8.59.4", - "eslint": "9.39.4", - "eslint-plugin-check-file": "3.3.1", - "eslint-plugin-playwright": "2.10.4", - "ioredis": "5.10.1", - "monocart-coverage-reports": "2.12.11", - "otplib": "12.0.1", - "prettier": "3.8.3", - "prettier-plugin-sh": "0.18.1", - "shellcheck": "4.1.0", - "typescript": "5.9.3", - "typescript-eslint": "8.59.4" + "shellcheck": "git ls-files -z '*.sh' | xargs -0 shellcheck --severity=warning --color=always" }, "dependencies": { "@azure/arm-network": "34.2.0", @@ -68,5 +43,27 @@ "winston": "3.14.2", "yaml": "2.9.0" }, + "devDependencies": { + "@axe-core/playwright": "4.11.2", + "@microsoft/microsoft-graph-types": "2.43.1", + "@playwright/test": "1.59.1", + "@types/js-yaml": "^4.0.9", + "@types/node": "24.12.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", + "ioredis": "5.10.1", + "monocart-coverage-reports": "2.12.11", + "otplib": "12.0.1", + "oxfmt": "0.56.0", + "oxlint": "1.71.0", + "oxlint-tsgolint": "0.23.0", + "shellcheck": "4.1.0", + "typescript": "6.0.3" + }, + "engines": { + "node": "24" + }, "packageManager": "yarn@4.12.0" } diff --git a/e2e-tests/playwright.config.ts b/e2e-tests/playwright.config.ts index 096ce26bbc..6b0c24c5d4 100644 --- a/e2e-tests/playwright.config.ts +++ b/e2e-tests/playwright.config.ts @@ -1,5 +1,6 @@ import { defineConfig, devices } from "@playwright/test"; import type { ReporterDescription } from "@playwright/test"; + import { PW_PROJECT } from "./playwright/projects"; process.env.JOB_NAME = process.env.JOB_NAME || ""; @@ -10,21 +11,13 @@ const args = process.argv; if (args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_DE))) { process.env.LOCALE = "de"; -} else if ( - args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_ES)) -) { +} else if (args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_ES))) { process.env.LOCALE = "es"; -} else if ( - args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_FR)) -) { +} else if (args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_FR))) { process.env.LOCALE = "fr"; -} else if ( - args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_IT)) -) { +} else if (args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_IT))) { process.env.LOCALE = "it"; -} else if ( - args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_JA)) -) { +} else if (args.some((arg) => arg.includes(PW_PROJECT.SHOWCASE_LOCALIZATION_JA))) { process.env.LOCALE = "ja"; } else if (!process.env.LOCALE) { process.env.LOCALE = "en"; @@ -56,9 +49,7 @@ export default defineConfig({ ["list"], ["junit", { outputFile: process.env.JUNIT_RESULTS || "junit-results.xml" }], ...(process.env.COLLECT_COVERAGE === "true" - ? ([ - ["./playwright/support/coverage/reporter.ts"], - ] satisfies ReporterDescription[]) + ? ([["./playwright/support/coverage/reporter.ts"]] satisfies ReporterDescription[]) : []), ], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/e2e-tests/playwright/data/rbac-constants.ts b/e2e-tests/playwright/data/rbac-constants.ts index e93b5ae329..425c8413d4 100644 --- a/e2e-tests/playwright/data/rbac-constants.ts +++ b/e2e-tests/playwright/data/rbac-constants.ts @@ -6,213 +6,208 @@ import { Policy, Role } from "../support/api/rbac-api-structures"; export const TEST_USER = "user:default/rhdh-qe"; export const TEST_USER_2 = "user:default/rhdh-qe-2"; -export class RbacConstants { - static getExpectedRoles(): Role[] { - return [ - { - memberReferences: ["user:default/rhdh-qe"], - name: "role:default/rbac_admin", - }, - { - memberReferences: ["user:default/guest"], - name: "role:default/guests", - }, - { - memberReferences: ["user:default/user_team_a", "user:default/rhdh-qe"], - name: "role:default/team_a", - }, - { - memberReferences: ["user:xyz/user"], - name: "role:xyz/team_a", - }, - { - memberReferences: ["group:default/rhdh-qe-2-team"], - name: "role:default/test2-role", - }, - { - memberReferences: ["user:default/rhdh-qe"], - name: "role:default/qe_rbac_admin", - }, - { - memberReferences: [ - "group:default/rhdh-qe-parent-team", - "group:default/rhdh-qe-child-team", - ], - name: "role:default/transitive-owner", - }, - { - memberReferences: ["user:default/rhdh-qe-5"], - name: "role:default/kubernetes_reader", - }, - { - memberReferences: ["user:default/rhdh-qe-5", "user:default/rhdh-qe-6"], - name: "role:default/catalog_reader", - }, - { - memberReferences: ["user:default/rhdh-qe-7", "user:default/rhdh-qe-9"], - name: "role:default/all_resource_reader", - }, - { - memberReferences: ["user:default/rhdh-qe-8"], - name: "role:default/all_resource_denier", - }, - { - memberReferences: ["user:default/rhdh-qe-7", "user:default/rhdh-qe-8"], - name: "role:default/owned_resource_reader", - }, - { - memberReferences: ["user:default/rhdh-qe-9"], - name: "role:default/conditional_denier", - }, - ]; - } +export function getExpectedRoles(): Role[] { + return [ + { + memberReferences: ["user:default/rhdh-qe"], + name: "role:default/rbac_admin", + }, + { + memberReferences: ["user:default/guest"], + name: "role:default/guests", + }, + { + memberReferences: ["user:default/user_team_a", "user:default/rhdh-qe"], + name: "role:default/team_a", + }, + { + memberReferences: ["user:xyz/user"], + name: "role:xyz/team_a", + }, + { + memberReferences: ["group:default/rhdh-qe-2-team"], + name: "role:default/test2-role", + }, + { + memberReferences: ["user:default/rhdh-qe"], + name: "role:default/qe_rbac_admin", + }, + { + memberReferences: ["group:default/rhdh-qe-parent-team", "group:default/rhdh-qe-child-team"], + name: "role:default/transitive-owner", + }, + { + memberReferences: ["user:default/rhdh-qe-5"], + name: "role:default/kubernetes_reader", + }, + { + memberReferences: ["user:default/rhdh-qe-5", "user:default/rhdh-qe-6"], + name: "role:default/catalog_reader", + }, + { + memberReferences: ["user:default/rhdh-qe-7", "user:default/rhdh-qe-9"], + name: "role:default/all_resource_reader", + }, + { + memberReferences: ["user:default/rhdh-qe-8"], + name: "role:default/all_resource_denier", + }, + { + memberReferences: ["user:default/rhdh-qe-7", "user:default/rhdh-qe-8"], + name: "role:default/owned_resource_reader", + }, + { + memberReferences: ["user:default/rhdh-qe-9"], + name: "role:default/conditional_denier", + }, + ]; +} - static getExpectedPolicies(): Policy[] { - return [ - { - entityReference: "role:default/rbac_admin", - permission: "policy-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/rbac_admin", - permission: "policy.entity.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/rbac_admin", - permission: "policy-entity", - policy: "delete", - effect: "allow", - }, - { - entityReference: "role:default/rbac_admin", - permission: "policy-entity", - policy: "update", - effect: "allow", - }, - { - entityReference: "role:default/rbac_admin", - permission: "catalog-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/guests", - permission: "catalog.entity.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/team_a", - permission: "catalog-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:xyz/team_a", - permission: "catalog-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:xyz/team_a", - permission: "catalog.entity.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:xyz/team_a", - permission: "catalog.location.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:xyz/team_a", - permission: "catalog.location.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "kubernetes.proxy", - policy: "use", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "kubernetes.resources.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "kubernetes.clusters.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "catalog.entity.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "catalog.location.create", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/qe_rbac_admin", - permission: "catalog.location.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/kubernetes_reader", - permission: "kubernetes.resources.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/kubernetes_reader", - permission: "kubernetes.clusters.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/catalog_reader", - permission: "catalog.entity.read", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/all_resource_reader", - permission: "catalog-entity", - policy: "read", - effect: "allow", - }, - { - entityReference: "role:default/all_resource_reader", - permission: "catalog-entity", - policy: "create", - effect: "allow", - }, - { - entityReference: "role:default/all_resource_denier", - permission: "catalog-entity", - policy: "read", - effect: "deny", - }, - { - entityReference: "role:default/all_resource_denier", - permission: "catalog-entity", - policy: "create", - effect: "allow", - }, - ]; - } +export function getExpectedPolicies(): Policy[] { + return [ + { + entityReference: "role:default/rbac_admin", + permission: "policy-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/rbac_admin", + permission: "policy.entity.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/rbac_admin", + permission: "policy-entity", + policy: "delete", + effect: "allow", + }, + { + entityReference: "role:default/rbac_admin", + permission: "policy-entity", + policy: "update", + effect: "allow", + }, + { + entityReference: "role:default/rbac_admin", + permission: "catalog-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/guests", + permission: "catalog.entity.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/team_a", + permission: "catalog-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:xyz/team_a", + permission: "catalog-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:xyz/team_a", + permission: "catalog.entity.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:xyz/team_a", + permission: "catalog.location.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:xyz/team_a", + permission: "catalog.location.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "kubernetes.proxy", + policy: "use", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "kubernetes.resources.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "kubernetes.clusters.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "catalog.entity.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "catalog.location.create", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/qe_rbac_admin", + permission: "catalog.location.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/kubernetes_reader", + permission: "kubernetes.resources.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/kubernetes_reader", + permission: "kubernetes.clusters.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/catalog_reader", + permission: "catalog.entity.read", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/all_resource_reader", + permission: "catalog-entity", + policy: "read", + effect: "allow", + }, + { + entityReference: "role:default/all_resource_reader", + permission: "catalog-entity", + policy: "create", + effect: "allow", + }, + { + entityReference: "role:default/all_resource_denier", + permission: "catalog-entity", + policy: "read", + effect: "deny", + }, + { + entityReference: "role:default/all_resource_denier", + permission: "catalog-entity", + policy: "create", + effect: "allow", + }, + ]; } 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 f4228fc492..51c9ac6f15 100644 --- a/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts +++ b/e2e-tests/playwright/e2e/audit-log/auditor-catalog.spec.ts @@ -1,11 +1,11 @@ import { test } from "@support/coverage/test"; + +import { CatalogImport } from "../../support/pages/catalog-import"; +import { APIHelper } from "../../utils/api-helper"; import { Common } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; import { LogUtils } from "./log-utils"; -import { CatalogImport } from "../../support/pages/catalog-import"; -import { APIHelper } from "../../utils/api-helper"; -const template = - "https://github.com/janus-qe/sample-service/blob/main/demo_template.yaml"; +const template = "https://github.com/janus-qe/sample-service/blob/main/demo_template.yaml"; const entityName = "hello-world-2"; const namespace = "default"; 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 6adee9054a..89ba3f8482 100644 --- a/e2e-tests/playwright/e2e/audit-log/auditor-rbac.spec.ts +++ b/e2e-tests/playwright/e2e/audit-log/auditor-rbac.spec.ts @@ -1,4 +1,6 @@ import { test, expect, Page } from "@support/coverage/test"; + +import RhdhRbacApi from "../../support/api/rbac-api"; import { Common, setupBrowser, teardownBrowser } from "../../utils/common"; import { RBAC_API, @@ -12,10 +14,8 @@ import { buildNotAllowedError, httpMethod, } from "./rbac-test-utils"; -import RhdhRbacApi from "../../support/api/rbac-api"; -const auditStatus = (ok: boolean): "succeeded" | "failed" => - ok ? "succeeded" : "failed"; +const auditStatus = (ok: boolean): "succeeded" | "failed" => (ok ? "succeeded" : "failed"); let common: Common; let rbacApi: RhdhRbacApi; @@ -171,11 +171,7 @@ test.describe("Auditor check for RBAC Plugin", () => { { name: "update", call: () => - rbacApi.updatePolicy( - ROLE_NAME, - [POLICY_DATA], - [{ ...POLICY_DATA, effect: "deny" }], - ), + rbacApi.updatePolicy(ROLE_NAME, [POLICY_DATA], [{ ...POLICY_DATA, effect: "deny" }]), url: RBAC_API.policy.item(ROLE_NAME), action: "update" as const, }, @@ -195,11 +191,7 @@ test.describe("Auditor check for RBAC Plugin", () => { USER_ENTITY_REF, { method: httpMethod(s.action), url: s.url }, { actionType: s.action, source: "rest" }, - buildNotAllowedError( - s.action, - "policy", - `${ROLE_NAME},policy-entity,read,allow`, - ), + buildNotAllowedError(s.action, "policy", `${ROLE_NAME},policy-entity,read,allow`), "failed", ); }); diff --git a/e2e-tests/playwright/e2e/audit-log/log-utils.ts b/e2e-tests/playwright/e2e/audit-log/log-utils.ts index a24b5f58d1..ba9fd2f080 100644 --- a/e2e-tests/playwright/e2e/audit-log/log-utils.ts +++ b/e2e-tests/playwright/e2e/audit-log/log-utils.ts @@ -1,23 +1,108 @@ -import { expect } from "@playwright/test"; import { execFile, exec } from "child_process"; + import { type JsonObject } from "@backstage/types"; -import { - Log, - type LogRequest, - type EventStatus, - type EventSeverityLevel, -} from "./logs"; +import { expect } from "@playwright/test"; + import { getBackstageDeploySelector } from "../../utils/helper"; +import { Log, type LogRequest, type EventStatus, type EventSeverityLevel } from "./logs"; -export class LogUtils { +function formatError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringifyForComparison(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return JSON.stringify(value); +} + +function compareValues(actual: unknown, expected: unknown): void { + if (isRecord(expected)) { + Object.keys(expected).forEach((subKey) => { + const expectedSubValue = expected[subKey]; + const actualSubValue = isRecord(actual) ? actual[subKey] : undefined; + compareValues(actualSubValue, expectedSubValue); + }); + } else if (typeof expected === "number") { + expect(actual).toBe(expected); + } else if (typeof expected === "string") { + if (actual === undefined || actual === null) { + throw new Error(`Expected value "${expected}" but got ${String(actual)}`); + } + expect(stringifyForComparison(actual)).toContain(expected); + } else { + expect(actual).toBe(expected); + } +} + +function validateLog(actual: Log, expected: Partial): void { + for (const [key, expectedValue] of Object.entries(expected)) { + if (expectedValue === undefined) { + continue; + } + compareValues(getLogProperty(actual, key), expectedValue); + } +} + +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 parseLogFromJson(text: string): Log { + 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); +} + +export const LogUtils = { /** * Executes a command and returns the output as a promise. - * - * @param command The command to execute - * @param args An array of arguments for the command - * @returns A promise that resolves with the command output */ - static executeCommand(command: string, args: string[] = []): Promise { + executeCommand(command: string, args: string[] = []): Promise { return new Promise((resolve, reject) => { execFile(command, args, { encoding: "utf8" }, (error, stdout, stderr) => { if (error) { @@ -30,17 +115,12 @@ export class LogUtils { resolve(stdout); }); }); - } + }, /** * Executes a command with retry logic. - * - * @param command The command to execute - * @param args An array of arguments for the command - * @param maxRetries Maximum number of retry attempts (default: 3) - * @returns A promise that resolves with the command output */ - static async executeCommandWithRetries( + async executeCommandWithRetries( command: string, args: string[] = [], maxRetries: number = 3, @@ -55,10 +135,7 @@ export class LogUtils { console.log(`Command executed successfully on attempt ${attempt + 1}`); return output; } catch (error) { - console.error( - `Error executing command on attempt ${attempt + 1}:`, - error, - ); + console.error(`Error executing command on attempt ${attempt + 1}:`, error); attempt++; } } @@ -66,15 +143,12 @@ export class LogUtils { throw new Error( `Failed to execute command "${command} ${args.join(" ")}" after ${maxRetries} attempts.`, ); - } + }, /** * Executes a shell command and returns the output as a promise. - * - * @param command The shell command to execute - * @returns A promise that resolves with the command output */ - static executeShellCommand(command: string): Promise { + executeShellCommand(command: string): Promise { return new Promise((resolve, reject) => { exec(command, { encoding: "utf8" }, (error, stdout, stderr) => { if (error) { @@ -87,81 +161,30 @@ export class LogUtils { resolve(stdout); }); }); - } - - /** - * Validates if the actual log matches the expected log values. - * It compares both primitive and nested object properties. - * - * @param actual The actual log returned by the system - * @param expected The expected log values to validate against - */ - public static validateLog(actual: Log, expected: Partial) { - Object.keys(expected).forEach((key) => { - const expectedValue = expected[key as keyof Log]; - if (expectedValue !== undefined) { - const actualValue = actual[key as keyof Log]; - LogUtils.compareValues(actualValue, expectedValue); - } - }); - } + }, - /** - * Compare the actual and expected values. Uses 'toBe' for numbers and 'toContain' for strings/arrays. - * Handles nested object comparison. - * - * @param actual The actual value to compare - * @param expected The expected value - */ - private static compareValues(actual: unknown, expected: unknown) { - if (typeof expected === "object" && expected !== null) { - Object.keys(expected).forEach((subKey) => { - const expectedSubValue = expected[subKey]; - const actualSubValue = actual?.[subKey]; - LogUtils.compareValues(actualSubValue, expectedSubValue); - }); - } else if (typeof expected === "number") { - expect(actual).toBe(expected); - } else if (typeof expected === "string") { - if (actual === undefined || actual === null) { - throw new Error(`Expected value "${expected}" but got ${actual}`); - } - expect(String(actual)).toContain(expected); - } else { - expect(actual).toBe(expected); - } - } + validateLog, /** * Lists all pods in the specified namespace and returns their details. - * - * @param namespace The namespace to list pods from - * @returns A promise that resolves with the pod details */ - static async listPods(namespace: string): Promise { + async listPods(namespace: string): Promise { const args = ["get", "pods", "-n", namespace, "-o", "wide"]; try { console.log("Fetching pod list with command:", "oc", args.join(" ")); return await LogUtils.executeCommand("oc", args); } catch (error) { console.error("Error listing pods:", error); - throw new Error( - `Failed to list pods in namespace "${namespace}": ${error}`, - ); + throw new Error(`Failed to list pods in namespace "${namespace}": ${formatError(error)}`, { + cause: error, + }); } - } + }, /** * Fetches detailed information about a specific pod. - * - * @param podName The name of the pod to fetch details for - * @param namespace The namespace where the pod is located - * @returns A promise that resolves with the pod details in JSON format */ - static async getPodDetails( - podName: string, - namespace: string, - ): Promise { + async getPodDetails(podName: string, namespace: string): Promise { const args = ["get", "pod", podName, "-n", namespace, "-o", "json"]; try { const output = await LogUtils.executeCommand("oc", args); @@ -169,20 +192,16 @@ export class LogUtils { return output; } catch (error) { console.error(`Error fetching details for pod ${podName}:`, error); - throw new Error(`Failed to fetch pod details: ${error}`); + throw new Error(`Failed to fetch pod details: ${formatError(error)}`, { + cause: error, + }); } - } + }, /** * Fetches logs using grep for filtering directly in the shell. - * - * @param filterWords The required words the logs must contain to filter the logs - * @param namespace The namespace to use to retrieve logs from pod - * @param maxRetries Maximum number of retry attempts - * @param retryDelay Delay (in milliseconds) between retries - * @returns The log line matching the filter, or throws an error if not found */ - static async getPodLogsWithGrep( + async getPodLogsWithGrep( filterWords: string[] = [], namespace: string = process.env.NAME_SPACE || "showcase-ci-nightly", maxRetries: number = 4, @@ -191,9 +210,6 @@ export class LogUtils { const deploySelector = getBackstageDeploySelector(); const tailNumber = 500; - // Resolve the deployment by its metadata labels, then fetch logs from it. - // This works for both Helm and Operator since both set app.kubernetes.io/name - // on the Deployment (with different values), even though pod labels differ. const deployTarget = `$(oc get deploy -n ${namespace} -l ${deploySelector} -o name)`; let grepCommand = `oc logs ${deployTarget} --tail=${tailNumber} -c backstage-backend -n ${namespace}`; for (const word of filterWords) { @@ -203,54 +219,45 @@ export class LogUtils { let attempt = 0; while (attempt <= maxRetries) { try { - console.log( - `Attempt ${attempt + 1}/${maxRetries + 1}: Fetching logs with grep...`, - ); + console.log(`Attempt ${attempt + 1}/${maxRetries + 1}: Fetching logs with grep...`); const output = await LogUtils.executeShellCommand(grepCommand); - const logLines = output - .split("\n") - .filter((line) => line.trim() !== ""); + const logLines = output.split("\n").filter((line) => line.trim() !== ""); if (logLines.length > 0) { console.log("Matching log line found:", logLines[0]); - return logLines[0]; // Return the first matching log + return logLines[0]; } console.warn( - `No matching logs found for filter "${filterWords}" on attempt ${attempt + 1}. Retrying...`, + `No matching logs found for filter ${JSON.stringify(filterWords)} on attempt ${attempt + 1}. Retrying...`, ); } catch (error) { - console.error( - `Error fetching logs on attempt ${attempt + 1}:`, - error.message, - ); + console.error(`Error fetching logs on attempt ${attempt + 1}:`, formatError(error)); } attempt++; if (attempt <= maxRetries) { console.log(`Waiting ${retryDelay / 1000} seconds before retrying...`); - await new Promise((resolve) => setTimeout(resolve, retryDelay)); + await new Promise((resolve) => { + setTimeout(resolve, retryDelay); + }); } } throw new Error( - `Failed to fetch logs for filter "${filterWords}" after ${maxRetries + 1} attempts.`, + `Failed to fetch logs for filter ${JSON.stringify(filterWords)} after ${maxRetries + 1} attempts.`, ); - } + }, /** * Logs in to OpenShift using a token and server URL. - * - * @returns A promise that resolves when the login is successful */ - static async loginToOpenShift(): Promise { + async loginToOpenShift(): Promise { const token = process.env.K8S_CLUSTER_TOKEN || ""; const server = process.env.K8S_CLUSTER_URL || ""; if (!token || !server) { - throw new Error( - "Environment variables K8S_CLUSTER_TOKEN and K8S_CLUSTER_URL must be set.", - ); + throw new Error("Environment variables K8S_CLUSTER_TOKEN and K8S_CLUSTER_URL must be set."); } const command = "oc"; @@ -266,26 +273,14 @@ export class LogUtils { console.log("Login successful."); } catch (error) { console.error("Error during login: ", error); - throw new Error(`Failed to login to OpenShift`); + throw new Error(`Failed to login to OpenShift`, { cause: error }); } - } + }, /** * Validates if the actual log matches the expected log values for a specific event. - * This is a reusable method for different log validations across various tests. - * - * @param eventId The id of the event to filter in the logs - * @param actorId The id of actor initiating the request - * @param request The url endpoint and HTTP method (GET, POST, etc.) hit - * @param meta The metadata about the event - * @param error The error that occurred - * @param status The status of event - * @param plugin The plugin name that triggered the log event - * @param severityLevel The level of severity of the event - * @param filterWords The required words the logs must contain to filter the logs besides eventId and request url if specified - * @param namespace The namespace to use to retrieve logs from pod */ - public static async validateLogEvent( + async validateLogEvent( eventId: string, actorId: string, request?: LogRequest, @@ -296,22 +291,21 @@ export class LogUtils { severityLevel: EventSeverityLevel = "medium", filterWords: string[] = [], namespace: string = process.env.NAME_SPACE || "showcase-ci-nightly", - ) { + ): Promise { const filterWordsAll = [eventId, status, ...filterWords]; if (request?.method) filterWordsAll.push(request.method); if (request?.url) filterWordsAll.push(request.url); try { - const actualLog = await LogUtils.getPodLogsWithGrep( - filterWordsAll, - namespace, - ); + const actualLog = await LogUtils.getPodLogsWithGrep(filterWordsAll, namespace); let parsedLog: Log; try { - parsedLog = JSON.parse(actualLog); + parsedLog = parseLogFromJson(actualLog); } catch (parseError) { console.error("Failed to parse log JSON. Log content:", actualLog); - throw new Error(`Invalid JSON received for log: ${parseError}`); + throw new Error(`Invalid JSON received for log: ${formatError(parseError)}`, { + cause: parseError, + }); } const expectedLog: Partial = { @@ -327,16 +321,16 @@ export class LogUtils { }; console.log("Validating log with expected values:", expectedLog); - LogUtils.validateLog(parsedLog, expectedLog); - } catch (error) { - console.error("Error validating log event:", error); + validateLog(parsedLog, expectedLog); + } catch (validationError) { + console.error("Error validating log event:", validationError); console.error("Event id:", eventId); console.error("Actor id:", actorId); console.error("Meta:", meta); console.error("Expected method:", request?.method); console.error("Expected URL:", request?.url); console.error("Plugin:", plugin); - throw error; + throw validationError; } - } -} + }, +}; diff --git a/e2e-tests/playwright/e2e/audit-log/logs.ts b/e2e-tests/playwright/e2e/audit-log/logs.ts index 7a5836024c..8f5ecdf1f2 100644 --- a/e2e-tests/playwright/e2e/audit-log/logs.ts +++ b/e2e-tests/playwright/e2e/audit-log/logs.ts @@ -4,7 +4,7 @@ class Actor { actorId?: string; } -export class LogRequest { +export interface LogRequest { body?: object; method: string; params?: object; @@ -16,13 +16,15 @@ export class LogRequest { url: string; } -class LogResponse { +interface LogResponse { status: number; } -export type EventStatus = "initiated" | "succeeded" | "failed"; +const EVENT_STATUSES = ["initiated", "succeeded", "failed"] as const; +export type EventStatus = (typeof EVENT_STATUSES)[number]; -export type EventSeverityLevel = "low" | "medium" | "high" | "critical"; +const EVENT_SEVERITY_LEVELS = ["low", "medium", "high", "critical"] as const; +export type EventSeverityLevel = (typeof EVENT_SEVERITY_LEVELS)[number]; export class Log { actor: Actor; @@ -60,8 +62,14 @@ export class Log { // Other properties without default values this.eventId = overrides.eventId || ""; this.plugin = overrides.plugin || ""; + this.severityLevel = overrides.severityLevel || "low"; + this.service = overrides.service || ""; + this.timestamp = overrides.timestamp || ""; this.request = overrides.request; this.response = overrides.response; this.meta = overrides.meta; + this.message = overrides.message; + this.name = overrides.name; + this.stack = overrides.stack; } } diff --git a/e2e-tests/playwright/e2e/audit-log/rbac-test-utils.ts b/e2e-tests/playwright/e2e/audit-log/rbac-test-utils.ts index 8d3c82a76c..37f57aaa3b 100644 --- a/e2e-tests/playwright/e2e/audit-log/rbac-test-utils.ts +++ b/e2e-tests/playwright/e2e/audit-log/rbac-test-utils.ts @@ -3,6 +3,7 @@ * --------------------------------------------------------------------------*/ import { type JsonObject } from "@backstage/types"; + import { LogUtils } from "./log-utils"; import { EventStatus, LogRequest } from "./logs"; @@ -37,10 +38,7 @@ export function buildNotAllowedError( entityRef?: string, ): string { // Backend verbs differ from our logical action names: - const backendVerb: Record< - "create" | "update" | "delete", - "add" | "edit" | "delete" - > = { + const backendVerb: Record<"create" | "update" | "delete", "add" | "edit" | "delete"> = { create: "add", update: "edit", delete: "delete", diff --git a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts index ae2be3e418..ecf921cf9f 100644 --- a/e2e-tests/playwright/e2e/auth-providers/github.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/github.spec.ts @@ -1,8 +1,9 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; +import { UIhelper } from "../../utils/ui-helper"; let page: Page; let context: BrowserContext; @@ -58,15 +59,15 @@ test.describe("Configure Github Provider", async () => { // 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(); + 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(); @@ -87,27 +88,27 @@ test.describe("Configure Github Provider", async () => { } await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_NAME", - process.env.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, + 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, + 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, + 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, + 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, + process.env.AUTH_PROVIDERS_GH_ORG_WEBHOOK_SECRET!, ); await deployment.createSecret(); @@ -128,16 +129,14 @@ test.describe("Configure Github Provider", async () => { test.beforeEach(async () => { test.info().setTimeout(600 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + 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, + process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, + process.env.AUTH_PROVIDERS_GH_ADMIN_2FA!, ); expect(login).toBe("Login successful"); @@ -152,7 +151,7 @@ test.describe("Configure Github Provider", async () => { await deployment.setGithubResolver("usernameMatchingUserEntityName", false); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -160,8 +159,8 @@ test.describe("Configure Github Provider", async () => { const login = await common.githubLogin( "rhdhqeauthadmin", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD, - process.env.AUTH_PROVIDERS_GH_ADMIN_2FA, + process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, + process.env.AUTH_PROVIDERS_GH_ADMIN_2FA!, ); expect(login).toBe("Login successful"); @@ -173,13 +172,10 @@ test.describe("Configure Github Provider", async () => { 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.setGithubResolver("emailMatchingUserEntityProfileEmail", false); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -187,26 +183,21 @@ test.describe("Configure Github Provider", async () => { const login = await common.githubLogin( "rhdhqeauth1", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD, - process.env.AUTH_PROVIDERS_GH_USER_2FA, + 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 uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await context.clearCookies(); }); test("Login with Github emailLocalPartMatchingUserEntityName resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setGithubResolver( - "emailLocalPartMatchingUserEntityName", - false, - ); + await deployment.setGithubResolver("emailLocalPartMatchingUserEntityName", false); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -214,28 +205,23 @@ test.describe("Configure Github Provider", async () => { const login = await common.githubLogin( "rhdhqeauth1", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD, - process.env.AUTH_PROVIDERS_GH_USER_2FA, + 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 uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await context.clearCookies(); }); test(`Set Github sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty( - "auth.providers.github.production.sessionDuration", - "3days", - ); + deployment.setAppConfigProperty("auth.providers.github.production.sessionDuration", "3days"); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -243,22 +229,21 @@ test.describe("Configure Github Provider", async () => { const login = await common.githubLogin( "rhdhqeauthadmin", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD, - process.env.AUTH_PROVIDERS_GH_ADMIN_2FA, + 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", - ); + const authCookie = cookies.find((cookie) => cookie.name === "github-refresh-token"); + expect(authCookie).toBeDefined(); const threeDays = 3 * 24 * 60 * 60 * 1000; // expected duration of 3 days in ms const tolerance = 3 * 60 * 1000; // allow for 3 minutes tolerance - const actualDuration = authCookie.expires * 1000 - Date.now(); + const actualDuration = authCookie!.expires * 1000 - Date.now(); expect(actualDuration).toBeGreaterThan(threeDays - tolerance); expect(actualDuration).toBeLessThan(threeDays + tolerance); @@ -271,34 +256,21 @@ test.describe("Configure Github Provider", async () => { 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 page.waitForTimeout(5000); + await expect + .poll( + async () => deployment.checkUserIsIngestedInCatalog(["RHDH QE User 1", "RHDH QE Admin"]), + { timeout: 120_000 }, + ) + .toBe(true); expect( - await deployment.checkUserIsIngestedInCatalog([ - "RHDH QE User 1", - "RHDH QE Admin", - ]), - ).toBe(true); - expect( - await 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"), + await 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 deployment.checkGroupIsChildOfGroup("test_users", "test_all"), - ).toBe(true); - expect( - await deployment.checkGroupIsChildOfGroup("test_admins", "test_all"), - ).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("test_users", "test_all")).toBe(true); + expect(await deployment.checkGroupIsChildOfGroup("test_admins", "test_all")).toBe(true); expect( await deployment.checkUserHasAnnotation( @@ -308,11 +280,7 @@ test.describe("Configure Github Provider", async () => { ), ).toBe(true); expect( - await deployment.checkUserHasAnnotation( - "rhdhqeauth1", - "MY_CUSTOM_ANNOTATION", - "rhdhqeauth1", - ), + await deployment.checkUserHasAnnotation("rhdhqeauth1", "MY_CUSTOM_ANNOTATION", "rhdhqeauth1"), ).toBe(true); }); @@ -323,7 +291,7 @@ test.describe("Configure Github Provider", async () => { ); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -331,8 +299,8 @@ test.describe("Configure Github Provider", async () => { const login = await common.githubLogin( "rhdhqeauth1", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD, - process.env.AUTH_PROVIDERS_GH_USER_2FA, + process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, + process.env.AUTH_PROVIDERS_GH_USER_2FA!, ); expect(login).toBe("Login successful"); diff --git a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts index c9baa35d12..0e3895ea6a 100644 --- a/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/gitlab.spec.ts @@ -1,8 +1,9 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + +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"; -import { GitLabHelper } from "../../utils/authentication-providers/gitlab-helper"; let page: Page; let context: BrowserContext; @@ -59,10 +60,10 @@ test.describe("Configure GitLab Provider", async () => { uiHelper = new UIhelper(page); // 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(); + 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({ @@ -80,9 +81,7 @@ test.describe("Configure GitLab Provider", async () => { true, // trusted = true to skip UI confirmation ); oauthAppId = oauthApp.id; - console.log( - `[TEST] GitLab OAuth application created - ID: ${oauthApp.application_id}`, - ); + console.log(`[TEST] GitLab OAuth application created - ID: ${oauthApp.application_id}`); // clean old namespaces await deployment.deleteNamespaceIfExists(); @@ -109,14 +108,8 @@ test.describe("Configure GitLab Provider", async () => { "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_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!, @@ -140,16 +133,11 @@ test.describe("Configure GitLab Provider", async () => { test.beforeEach(async () => { test.info().setTimeout(60 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + 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, - ); + const login = await common.gitlabLogin("user1", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -159,16 +147,13 @@ test.describe("Configure GitLab Provider", async () => { }); test(`Ingestion of GitLab users and groups: verify the user entities and groups are created with the correct relationships`, async () => { - await page.waitForTimeout(5000); - - expect( - await deployment.checkUserIsIngestedInCatalog([ - "user1", - "user2", - "user3", - "Administrator", - ]), - ).toBe(true); + await expect + .poll( + async () => + deployment.checkUserIsIngestedInCatalog(["user1", "user2", "user3", "Administrator"]), + { timeout: 120_000 }, + ) + .toBe(true); expect( await deployment.checkGroupIsIngestedInCatalog([ "my-org", @@ -186,55 +171,27 @@ test.describe("Configure GitLab Provider", async () => { expect(await 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 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 deployment.checkUserIsInGroup("user3", "group1-nested-nested_2"), - ).toBe(true); - expect( - await deployment.checkUserIsInGroup("root", "group1-nested-nested_2"), - ).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 deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe( - true, - ); - expect(await deployment.checkGroupIsParentOfGroup("my-org", "group1")).toBe( - true, - ); + expect(await deployment.checkGroupIsChildOfGroup("group1", "my-org")).toBe(true); + expect(await 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 deployment.checkGroupIsChildOfGroup("all", "my-org")).toBe(true); + expect(await 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 deployment.checkGroupIsChildOfGroup("group1-nested", "group1")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("group1", "group1-nested")).toBe(true); expect( - await deployment.checkGroupIsChildOfGroup( - "group1-nested-nested_2", - "group1-nested", - ), + await deployment.checkGroupIsChildOfGroup("group1-nested-nested_2", "group1-nested"), ).toBe(true); expect( - await deployment.checkGroupIsParentOfGroup( - "group1-nested", - "group1-nested-nested_2", - ), + await deployment.checkGroupIsParentOfGroup("group1-nested", "group1-nested-nested_2"), ).toBe(true); }); @@ -247,10 +204,7 @@ test.describe("Configure GitLab Provider", async () => { await gitlabHelper.deleteOAuthApplication(oauthAppId); console.log("[TEST] GitLab OAuth application deleted successfully"); } catch (error) { - console.error( - "[TEST] Failed to delete GitLab OAuth application:", - error, - ); + console.error("[TEST] Failed to delete GitLab OAuth application:", error); } } diff --git a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts index 60e274c386..a89b01c162 100644 --- a/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/ldap.spec.ts @@ -1,43 +1,42 @@ -/* eslint-disable */ - import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + +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"; -import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; + let page: Page; -let context: BrowserContext; +let browserContext: BrowserContext; +let nsgCleanup: (() => Promise) | undefined; /* SUPPORTED RESOLVERS LDAP: [x] oidcLdapUuidMatchingAnnotation -> (Default) */ -test.describe("Configure LDAP Provider", async () => { +const namespace = "albarbaro-test-namespace-ldap"; +const appConfigMap = "app-config-rhdh"; +const rbacConfigMap = "rbac-policy"; +const dynamicPluginsConfigMap = "dynamic-plugins"; +const secretName = "rhdh-secrets"; + +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}`); + +test.describe("Configure LDAP Provider", () => { let common: Common; let uiHelper: UIhelper; - const namespace = "albarbaro-test-namespace-ldap"; - 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) => { @@ -51,33 +50,34 @@ test.describe("Configure LDAP Provider", async () => { await deployment.loadAllConfigs(); // setup playwright helpers - ({ context, page } = await setupBrowser(browser, testInfo)); + ({ 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(); + 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 deployment.createNamespace()).waitForNamespaceActive(); + await (await deployment.createNamespace()).waitForNamespaceActive(); // create all base configmaps await deployment.createAllConfigs(); @@ -87,73 +87,40 @@ test.describe("Configure LDAP Provider", async () => { // set enviroment variables and create secret if (!process.env.ISRUNNINGLOCAL) { - deployment.addSecretData("BASE_URL", backstageUrl); - deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); + await deployment.addSecretData("BASE_URL", backstageUrl); + await deployment.addSecretData("BASE_BACKEND_URL", backstageBackendUrl); } - deployment.addSecretData( - "DEFAULT_USER_PASSWORD", - process.env.DEFAULT_USER_PASSWORD, - ); - deployment.addSecretData("RHBK_LDAP_REALM", process.env.RHBK_LDAP_REALM); - deployment.addSecretData( - "RHBK_LDAP_CLIENT_ID", - process.env.RHBK_LDAP_CLIENT_ID, - ); - deployment.addSecretData( - "RHBK_LDAP_CLIENT_SECRET", - process.env.RHBK_LDAP_CLIENT_SECRET, - ); - deployment.addSecretData("LDAP_BIND_DN", process.env.RHBK_LDAP_USER_BIND); - deployment.addSecretData( - "LDAP_BIND_SECRET", - process.env.RHBK_LDAP_USER_PASSWORD, - ); - deployment.addSecretData("LDAP_TARGET_URL", process.env.RHBK_LDAP_TARGET); - deployment.addSecretData( - "DEFAULT_USER_PASSWORD", - process.env.DEFAULT_USER_PASSWORD, - ); - deployment.addSecretData( - "DEFAULT_USER_PASSWORD_2", - process.env.DEFAULT_USER_PASSWORD_2, - ); - deployment.addSecretData( - "LDAP_GROUPS_DN", - "OU=Groups,OU=RHDH Local,DC=rhdh,DC=test", - ); - deployment.addSecretData( - "LDAP_USERS_DN", - "OU=Users,OU=RHDH Local,DC=rhdh,DC=test", - ); - deployment.addSecretData("RHBK_BASE_URL", process.env.RHBK_BASE_URL); - deployment.addSecretData("RHBK_REALM", process.env.RHBK_REALM); - deployment.addSecretData("RHBK_CLIENT_ID", process.env.RHBK_CLIENT_ID); - deployment.addSecretData( - "RHBK_CLIENT_SECRET", - process.env.RHBK_CLIENT_SECRET, - ); - - deployment.addSecretData( + 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, + process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_ID!, ); - deployment.addSecretData( + await deployment.addSecretData( "AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET", - process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET, + process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!, ); - deployment.addSecretData( - "PINGFEDERATE_BASE_URL", - process.env.PINGFEDERATE_BASE_URL, - ); - deployment.addSecretData( - "PINGFEDERATE_CLIENT_ID", - process.env.PINGFEDERATE_CLIENT_ID, - ); - deployment.addSecretData( + 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, + process.env.PINGFEDERATE_CLIENT_SECRET!, ); await deployment.createSecret(); @@ -169,7 +136,7 @@ test.describe("Configure LDAP Provider", async () => { 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!, + process.env.AUTH_PROVIDERS_ARM_SUBSCRIPTION_ID, ); // Allow public IP in NSG for E2E testing @@ -180,12 +147,10 @@ test.describe("Configure LDAP Provider", async () => { "AllowE2EJobs", ); console.log(`[TEST] NSG access configured successfully`); - console.log( - `[TEST] Rule created: ${nsgConfig.ruleName} for IP: ${nsgConfig.publicIp}`, - ); + console.log(`[TEST] Rule created: ${nsgConfig.ruleName} for IP: ${nsgConfig.publicIp}`); // Store cleanup function for afterAll - (test as any).nsgCleanup = nsgConfig.cleanup; + nsgCleanup = nsgConfig.cleanup; } catch (error) { console.error("[TEST] Failed to configure NSG access:", error); // Continue with test even if NSG configuration fails @@ -201,15 +166,13 @@ test.describe("Configure LDAP Provider", async () => { test.beforeEach(async () => { test.info().setTimeout(600 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + 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, + process.env.RHBK_LDAP_USER_PASSWORD!, ); expect(login).toBe("Login successful"); @@ -220,12 +183,7 @@ test.describe("Configure LDAP Provider", async () => { 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 deployment.checkUserIsIngestedInCatalog(["User 1", "User 2", "User 3", "RHDH Admin"]), ).toBe(true); expect( @@ -238,34 +196,16 @@ test.describe("Configure LDAP Provider", async () => { "SubAdmins", ]), ).toBe(true); - expect(await deployment.checkUserIsInGroup("rhdh-admin", "Admins")).toBe( + 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 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( true, ); - expect(await deployment.checkUserIsInGroup("user1", "All_Users")).toBe( - true, - ); - expect(await 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(true); }); test("Login with PingFederate OIDC (with LDAP catalog)", async () => { @@ -273,17 +213,14 @@ test.describe("Configure LDAP Provider", async () => { await deployment.enablePingFederateOIDCLogin(); await deployment.updateAllConfigs(); - await page.waitForTimeout(3000); + 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, - ); + const login = await common.pingFederateLogin("user1", process.env.RHBK_LDAP_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -294,28 +231,22 @@ test.describe("Configure LDAP Provider", async () => { 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", - ldapUuidKey: "sub", // match sub claim as required by OIDC spec - }, - ], - ); + deployment.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ + { + resolver: "oidcLdapUuidMatchingAnnotation", + ldapUuidKey: "sub", // match sub claim as required by OIDC spec + }, + ]); await deployment.updateAllConfigs(); - await page.waitForTimeout(3000); + 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, - ); + const login = await common.pingFederateLogin("user1", process.env.RHBK_LDAP_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -328,8 +259,7 @@ test.describe("Configure LDAP Provider", async () => { // Clean up NSG rule try { - const nsgCleanup = (test as any).nsgCleanup; - if (nsgCleanup && typeof nsgCleanup === "function") { + if (nsgCleanup) { console.log("[TEST] Cleaning up NSG rule..."); await nsgCleanup(); console.log("[TEST] NSG cleanup completed"); diff --git a/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts b/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts index 6e4b9c82bb..cc76ab2a31 100644 --- a/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/microsoft.spec.ts @@ -1,9 +1,10 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + +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"; -import { MSClient } from "../../utils/authentication-providers/msgraph-helper"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; +import { UIhelper } from "../../utils/ui-helper"; let page: Page; let context: BrowserContext; @@ -58,10 +59,10 @@ test.describe("Configure Microsoft Provider", async () => { 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(); + 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(); @@ -80,37 +81,31 @@ test.describe("Configure Microsoft Provider", async () => { 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("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, + process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, ); await deployment.addSecretData( "AUTH_PROVIDERS_AZURE_CLIENT_SECRET", - process.env.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, + process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, ); await deployment.addSecretData( "MICROSOFT_CLIENT_ID", - process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID, + process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, ); await deployment.addSecretData( "MICROSOFT_CLIENT_SECRET", - process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET, + process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!, ); await deployment.addSecretData( "MICROSOFT_TENANT_ID", - process.env.AUTH_PROVIDERS_AZURE_TENANT_ID, + process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, ); await deployment.createSecret(); @@ -122,17 +117,15 @@ test.describe("Configure Microsoft Provider", async () => { // 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, + process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, + process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!, + process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, ); 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", - ); + console.log("[TEST] Microsoft Azure App Registration configured successfully"); // create backstage deployment and wait for it to be ready await deployment.createBackstageDeployment(); @@ -144,15 +137,13 @@ test.describe("Configure Microsoft Provider", async () => { test.beforeEach(async () => { test.info().setTimeout(600 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + 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, + process.env.DEFAULT_USER_PASSWORD_2!, ); expect(login).toBe("Login successful"); @@ -165,13 +156,10 @@ test.describe("Configure Microsoft Provider", async () => { test("Login with Microsoft emailMatchingUserEntityAnnotation resolver", async () => { //Looks up the user by matching their Microsoft email to the email entity annotation. //User atena has no email attribute set - await deployment.setMicrosoftResolver( - "emailMatchingUserEntityAnnotation", - false, - ); + await deployment.setMicrosoftResolver("emailMatchingUserEntityAnnotation", false); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -179,7 +167,7 @@ test.describe("Configure Microsoft Provider", async () => { const login = await common.MicrosoftAzureLogin( "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2, + process.env.DEFAULT_USER_PASSWORD_2!, ); expect(login).toBe("Login successful"); @@ -190,24 +178,19 @@ test.describe("Configure Microsoft Provider", async () => { const login2 = await common.MicrosoftAzureLogin( "atena@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2, + process.env.DEFAULT_USER_PASSWORD_2!, ); expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage( - NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, - ); + await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await context.clearCookies(); }); test("Login with Microsoft emailMatchingUserEntityProfileEmail resolver", async () => { //A common sign-in resolver that looks up the user using the local part of their email address as the entity name. - await deployment.setMicrosoftResolver( - "emailMatchingUserEntityProfileEmail", - false, - ); + await deployment.setMicrosoftResolver("emailMatchingUserEntityProfileEmail", false); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -215,7 +198,7 @@ test.describe("Configure Microsoft Provider", async () => { const login = await common.MicrosoftAzureLogin( "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2, + process.env.DEFAULT_USER_PASSWORD_2!, ); expect(login).toBe("Login successful"); @@ -228,13 +211,10 @@ test.describe("Configure Microsoft Provider", async () => { //TODO: entiny 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.setMicrosoftResolver("emailLocalPartMatchingUserEntityName", false); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -242,7 +222,7 @@ test.describe("Configure Microsoft Provider", async () => { const login = await common.MicrosoftAzureLogin( "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2, + process.env.DEFAULT_USER_PASSWORD_2!, ); expect(login).toBe("Login successful"); @@ -253,23 +233,18 @@ test.describe("Configure Microsoft Provider", async () => { const login2 = await common.MicrosoftAzureLogin( "tyke@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2, + process.env.DEFAULT_USER_PASSWORD_2!, ); expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage( - NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, - ); + await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); }); test(`Set Micrisoft sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty( - "auth.providers.microsoft.production.sessionDuration", - "3days", - ); + deployment.setAppConfigProperty("auth.providers.microsoft.production.sessionDuration", "3days"); await deployment.updateAllConfigs(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); + await deployment.waitForConfigReconciled(); await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -277,21 +252,20 @@ test.describe("Configure Microsoft Provider", async () => { const login = await common.MicrosoftAzureLogin( "zeus@rhdhtesting.onmicrosoft.com", - process.env.DEFAULT_USER_PASSWORD_2, + 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", - ); + const authCookie = cookies.find((cookie) => cookie.name === "microsoft-refresh-token"); + expect(authCookie).toBeDefined(); const threeDays = 3 * 24 * 60 * 60 * 1000; // expected duration of 3 days in ms const tolerance = 3 * 60 * 1000; // allow for 3 minutes tolerance - const actualDuration = authCookie.expires * 1000 - Date.now(); + const actualDuration = authCookie!.expires * 1000 - Date.now(); expect(actualDuration).toBeGreaterThan(threeDays - tolerance); expect(actualDuration).toBeLessThan(threeDays + tolerance); @@ -303,17 +277,20 @@ test.describe("Configure Microsoft Provider", async () => { 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 page.waitForTimeout(5000); - expect( - await deployment.checkUserIsIngestedInCatalog([ - "TEST Admin", - "TEST Atena", - "TEST Elio", - "TEST Tyke", - "TEST Zeus", - ]), - ).toBe(true); + await expect + .poll( + async () => + deployment.checkUserIsIngestedInCatalog([ + "TEST Admin", + "TEST Atena", + "TEST Elio", + "TEST Tyke", + "TEST Zeus", + ]), + { timeout: 120_000 }, + ) + .toBe(true); expect( await deployment.checkGroupIsIngestedInCatalog([ "TEST_admins", @@ -323,56 +300,30 @@ test.describe("Configure Microsoft Provider", async () => { ]), ).toBe(true); expect( - await deployment.checkUserIsInGroup( - "admin_rhdhtesting.onmicrosoft.com", - "TEST_admins", - ), + await deployment.checkUserIsInGroup("admin_rhdhtesting.onmicrosoft.com", "TEST_admins"), ).toBe(true); expect( - await deployment.checkUserIsInGroup( - "zeus_rhdhtesting.onmicrosoft.com", - "TEST_admins", - ), + await deployment.checkUserIsInGroup("zeus_rhdhtesting.onmicrosoft.com", "TEST_admins"), ).toBe(true); expect( - await deployment.checkUserIsInGroup( - "atena_rhdhtesting.onmicrosoft.com", - "TEST_goddesses", - ), + await deployment.checkUserIsInGroup("atena_rhdhtesting.onmicrosoft.com", "TEST_goddesses"), ).toBe(true); expect( - await deployment.checkUserIsInGroup( - "tiche_rhdhtesting.onmicrosoft.com", - "TEST_goddesses", - ), + await deployment.checkUserIsInGroup("tiche_rhdhtesting.onmicrosoft.com", "TEST_goddesses"), ).toBe(true); expect( - await deployment.checkUserIsInGroup( - "elio_rhdhtesting.onmicrosoft.com", - "TEST_gods", - ), + await deployment.checkUserIsInGroup("elio_rhdhtesting.onmicrosoft.com", "TEST_gods"), ).toBe(true); expect( - await deployment.checkUserIsInGroup( - "zeus_rhdhtesting.onmicrosoft.com", - "TEST_gods", - ), + await 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 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); }); test.afterAll(async () => { @@ -383,9 +334,9 @@ test.describe("Configure Microsoft Provider", async () => { try { console.log("[TEST] Cleaning up 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, + process.env.AUTH_PROVIDERS_AZURE_CLIENT_ID!, + process.env.AUTH_PROVIDERS_AZURE_CLIENT_SECRET!, + process.env.AUTH_PROVIDERS_AZURE_TENANT_ID!, ); const redirectUrl = `${backstageUrl}/api/auth/microsoft/handler/frame`; @@ -393,10 +344,7 @@ test.describe("Configure Microsoft Provider", async () => { await graphClient.removeAppRedirectUrlsAsync([redirectUrl]); console.log("[TEST] Microsoft Azure App Registration cleanup completed"); } catch (error) { - console.error( - "[TEST] Failed to cleanup Microsoft Azure App Registration:", - error, - ); + console.error("[TEST] Failed to cleanup Microsoft Azure App Registration:", error); // Don't fail the test cleanup if Azure cleanup fails } }); diff --git a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts index 387fd90659..5da18a9947 100644 --- a/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts +++ b/e2e-tests/playwright/e2e/auth-providers/oidc.spec.ts @@ -1,9 +1,10 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; + +import { KeycloakHelper } from "../../utils/authentication-providers/keycloak-helper"; import RHDHDeployment from "../../utils/authentication-providers/rhdh-deployment"; import { Common, setupBrowser } from "../../utils/common"; -import { UIhelper } from "../../utils/ui-helper"; -import { KeycloakHelper } from "../../utils/authentication-providers/keycloak-helper"; import { NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE } from "../../utils/constants"; +import { UIhelper } from "../../utils/ui-helper"; let page: Page; let context: BrowserContext; @@ -29,10 +30,10 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { 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, + baseUrl: process.env.RHBK_BASE_URL!, + realmName: process.env.RHBK_REALM!, + clientId: process.env.RHBK_CLIENT_ID!, + clientSecret: process.env.RHBK_CLIENT_SECRET!, }); // set deployment instance @@ -71,11 +72,11 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { 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(); + 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(); @@ -94,32 +95,20 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { 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("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, + 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, + process.env.AUTH_PROVIDERS_GH_ORG_CLIENT_SECRET!, ); await deployment.createSecret(); @@ -141,16 +130,11 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { test.beforeEach(async () => { test.info().setTimeout(600 * 1000); - console.log( - `Running test case ${test.info().title} - Attempt #${test.info().retry}`, - ); + 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, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -168,22 +152,16 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { test("Login with OIDC oidcSubClaimMatchingKeycloakUserId resolver", async () => { await deployment.enableOIDCLoginWithIngestion(); - await deployment.setOIDCResolver( - "oidcSubClaimMatchingKeycloakUserId", - false, - ); + await deployment.setOIDCResolver("oidcSubClaimMatchingKeycloakUserId", false); await deployment.updateAllConfigs(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS + 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, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -192,22 +170,16 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC emailMatchingUserEntityProfileEmail resolver", async () => { - await deployment.setOIDCResolver( - "emailMatchingUserEntityProfileEmail", - false, - ); + await deployment.setOIDCResolver("emailMatchingUserEntityProfileEmail", false); await deployment.updateAllConfigs(); + await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS 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, - ); + const login = await common.keycloakLogin("zeus", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -216,68 +188,48 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC emailLocalPartMatchingUserEntityName resolver", async () => { - await deployment.setOIDCResolver( - "emailLocalPartMatchingUserEntityName", - false, - ); + await deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", false); await deployment.updateAllConfigs(); + await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS 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, - ); + 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, - ); + const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login2).toBe("Login successful"); - await uiHelper.verifyAlertErrorMessage( - NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE, - ); + await uiHelper.verifyAlertErrorMessage(NO_USER_FOUND_IN_CATALOG_ERROR_MESSAGE); await keycloakHelper.initialize(); await keycloakHelper.clearUserSessions("atena"); }); test("Login with OIDC emailLocalPartMatchingUserEntityName with dangerouslyAllowSignInWithoutUserInCatalog resolver", async () => { - await deployment.setOIDCResolver( - "emailLocalPartMatchingUserEntityName", - true, - ); + await deployment.setOIDCResolver("emailLocalPartMatchingUserEntityName", true); await deployment.updateAllConfigs(); + await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS 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, - ); + 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, - ); + const login2 = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login2).toBe("Login successful"); await uiHelper.goToSettingsPage(); await uiHelper.verifyHeading("Atena Minerva"); @@ -285,22 +237,16 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test("Login with OIDC preferredUsernameMatchingUserEntityName resolver", async () => { - await deployment.setOIDCResolver( - "preferredUsernameMatchingUserEntityName", - false, - ); + await deployment.setOIDCResolver("preferredUsernameMatchingUserEntityName", false); await deployment.updateAllConfigs(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS + 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, - ); + const login = await common.keycloakLogin("atena", process.env.DEFAULT_USER_PASSWORD!); expect(login).toBe("Login successful"); await uiHelper.goToSettingsPage(); @@ -309,35 +255,28 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); test(`Set sessionDuration and confirm in auth cookie duration has been set`, async () => { - deployment.setAppConfigProperty( - "auth.providers.oidc.production.sessionDuration", - "3days", - ); + deployment.setAppConfigProperty("auth.providers.oidc.production.sessionDuration", "3days"); await deployment.updateAllConfigs(); + await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS 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, - ); + 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", - ); + const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); + expect(authCookie).toBeDefined(); const threeDays = 3 * 24 * 60 * 60 * 1000; // expected duration of 3 days in ms const tolerance = 3 * 60 * 1000; // allow for 3 minutes tolerance - const actualDuration = authCookie.expires * 1000 - Date.now(); + const actualDuration = authCookie!.expires * 1000 - Date.now(); expect(actualDuration).toBeGreaterThan(threeDays - tolerance); expect(actualDuration).toBeLessThan(threeDays + tolerance); @@ -357,41 +296,25 @@ 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( + 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 deployment.checkGroupIsChildOfGroup("goddesses", "all")).toBe(true); + expect(await deployment.checkGroupIsParentOfGroup("all", "gods")).toBe(true); + expect(await 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 deployment.checkUserIsIngestedInCatalog(["Invalid Username"])).toBe(true); + expect(await deployment.checkGroupIsIngestedInCatalog(["invalid@groupname"])).toBe(true); }); test("Ensure Guest login is disabled when setting environment to production", async () => { @@ -405,25 +328,21 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { }); 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, - ); + 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(); + 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", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", }, }); @@ -432,8 +351,8 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { "true", ); await deployment.updateAllConfigs(); + await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS await deployment.waitForDeploymentReady(); // wait for rhdh first sync and portal to be reachable @@ -443,8 +362,8 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { const ghLogin = await common.githubLoginFromSettingsPage( "rhdhqeauth1", - process.env.AUTH_PROVIDERS_GH_USER_PASSWORD, - process.env.AUTH_PROVIDERS_GH_USER_2FA, + process.env.AUTH_PROVIDERS_GH_USER_PASSWORD!, + process.env.AUTH_PROVIDERS_GH_USER_2FA!, ); expect(ghLogin).toBe("Login successful"); // Sign out for GitHub @@ -463,37 +382,27 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { "auth.autologout.idleTimeoutMinutes", 0.5, // minimum allowed value is 0.5 minutes ); - deployment.setAppConfigProperty( - "auth.autologout.promptBeforeIdleSeconds", - 5, - ); + deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); await deployment.updateAllConfigs(); + await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS 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, - ); + 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 page.waitForTimeout(5000); + await uiHelper.verifyTextVisible("Logging out due to inactivity", false, 60000); + await expect(page.getByText("Logging out due to inactivity")).toBeHidden({ + timeout: 30000, + }); await page.reload(); const cookies = await context.cookies(); - const authCookie = cookies.find( - (cookie) => cookie.name === "oidc-refresh-token", - ); + const authCookie = cookies.find((cookie) => cookie.name === "oidc-refresh-token"); expect(authCookie).toBeUndefined(); }); @@ -503,22 +412,16 @@ test.describe("Configure OIDC provider (using RHBK)", async () => { "auth.autologout.idleTimeoutMinutes", 0.5, // minimum allowed value is 0.5 minutes ); - deployment.setAppConfigProperty( - "auth.autologout.promptBeforeIdleSeconds", - 5, - ); + deployment.setAppConfigProperty("auth.autologout.promptBeforeIdleSeconds", 5); await deployment.updateAllConfigs(); + await deployment.waitForConfigReconciled(); await deployment.restartLocalDeployment(); - await page.waitForTimeout(3000); // wait is needed or the openshift rollout won't be detected - WORKING A MORE PERMANENT FIX TO REMOVE EXPLICIT TIMEOUT - FOR NOW IT UNBLOCK THE TESTS 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, - ); + 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 }); diff --git a/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts b/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts index f8049a081c..377ea4bb81 100644 --- a/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts +++ b/e2e-tests/playwright/e2e/catalog-timestamp.spec.ts @@ -1,11 +1,9 @@ import { Page, expect, test } from "@support/coverage/test"; -import { UIhelper } from "../utils/ui-helper"; -import { Common, setupBrowser, teardownBrowser } from "../utils/common"; + +import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; import { CatalogImport } from "../support/pages/catalog-import"; -import { - getTranslations, - getCurrentLanguage, -} from "../e2e/localization/locale"; +import { Common, setupBrowser, teardownBrowser } from "../utils/common"; +import { UIhelper } from "../utils/ui-helper"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -14,7 +12,7 @@ let page: Page; test.describe("Test timestamp column on Catalog", () => { test.skip( - () => process.env.JOB_NAME.includes("osd-gcp"), + () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), "skipping on OSD-GCP cluster due to RHDHBUGS-555", ); @@ -51,9 +49,7 @@ test.describe("Test timestamp column on Catalog", () => { test("Import an existing Git repository and verify `Created At` column and value in the Catalog Page", async () => { await uiHelper.goToSelfServicePage(); await uiHelper.clickButton( - t["scaffolder"][lang][ - "templateListPage.contentHeader.registerExistingButtonTitle" - ], + t["scaffolder"][lang]["templateListPage.contentHeader.registerExistingButtonTitle"], ); await catalogImport.registerExistingComponent(component); await uiHelper.openCatalogSidebar("Component"); @@ -73,9 +69,7 @@ test.describe("Test timestamp column on Catalog", () => { } // Wait for the table to have data rows - await expect( - page.getByRole("row").filter({ has: page.getByRole("cell") }), - ).not.toHaveCount(0); + 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 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 06c299f3e7..88ddae9638 100644 --- a/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts +++ b/e2e-tests/playwright/e2e/configuration-test/config-map.spec.ts @@ -1,6 +1,7 @@ import { test, expect } from "@support/coverage/test"; -import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; + import { Common } from "../../utils/common"; +import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { UIhelper } from "../../utils/ui-helper"; test.describe("Change app-config at e2e test runtime", () => { @@ -30,15 +31,9 @@ test.describe("Change app-config at e2e test runtime", () => { const dynamicTitle = generateDynamicTitle(); try { console.log(`Updating ConfigMap '${configMapName}' with new title.`); - await kubeUtils.updateConfigMapTitle( - configMapName, - namespace, - dynamicTitle, - ); + await kubeUtils.updateConfigMapTitle(configMapName, namespace, dynamicTitle); - console.log( - `Restarting deployment '${deploymentName}' to apply ConfigMap changes.`, - ); + console.log(`Restarting deployment '${deploymentName}' to apply ConfigMap changes.`); await kubeUtils.restartDeployment(deploymentName, namespace); const common = new Common(page); @@ -51,16 +46,13 @@ test.describe("Change app-config at e2e test runtime", () => { expect(await page.title()).toContain(dynamicTitle); console.log("Title successfully verified in the UI."); } catch (error) { - console.log( - `Test failed during ConfigMap update or deployment restart:`, - error, - ); + console.log(`Test failed during ConfigMap update or deployment restart:`, error); throw error; } }); }); function generateDynamicTitle() { - const timestamp = new Date().toISOString().replace(/[-:.]/g, ""); + const timestamp = new Date().toISOString().replaceAll(/[-:.]/g, ""); return `New Title - ${timestamp}`; } 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 f43758ea69..45d627125b 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,4 +1,5 @@ -import { test } from "@support/coverage/test"; +import { test, expect } from "@support/coverage/test"; + import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { @@ -7,26 +8,27 @@ import { configurePostgresCredentials, clearDatabase, } from "../../utils/postgres-config"; +import { UIhelper } from "../../utils/ui-helper"; interface AzureDbConfig { name: string; - host: string | undefined; + host: string; } test.describe("Verify TLS configuration with Azure Database for PostgreSQL health check", () => { - const namespace = process.env.NAME_SPACE_RUNTIME || "showcase-runtime"; + const namespace = process.env.NAME_SPACE_RUNTIME! || "showcase-runtime"; const deploymentName = getRhdhDeploymentName(); // Azure DB configuration from environment - const azureUser = process.env.AZURE_DB_USER; - const azurePassword = process.env.AZURE_DB_PASSWORD; + const azureUser = process.env.AZURE_DB_USER!; + const azurePassword = process.env.AZURE_DB_PASSWORD!; // Define all Azure DB configurations to test const azureConfigurations: AzureDbConfig[] = [ - { name: "latest-3", host: process.env.AZURE_DB_1_HOST }, - { name: "latest-2", host: process.env.AZURE_DB_2_HOST }, - { name: "latest-1", host: process.env.AZURE_DB_3_HOST }, - { name: "latest", host: process.env.AZURE_DB_4_HOST }, + { name: "latest-3", host: process.env.AZURE_DB_1_HOST! }, + { name: "latest-2", host: process.env.AZURE_DB_2_HOST! }, + { name: "latest-1", host: process.env.AZURE_DB_3_HOST! }, + { name: "latest", host: process.env.AZURE_DB_4_HOST! }, ]; test.beforeAll(async () => { @@ -42,9 +44,7 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt ); // Validate certificates are available - const azureCerts = readCertificateFile( - process.env.AZURE_DB_CERTIFICATES_PATH, - ); + const azureCerts = readCertificateFile(process.env.AZURE_DB_CERTIFICATES_PATH); if (!azureCerts) { throw new Error( "AZURE_DB_CERTIFICATES_PATH environment variable must be set and point to a valid certificate file", @@ -53,17 +53,13 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt // Validate required environment variables if (!azureUser || !azurePassword) { - throw new Error( - "AZURE_DB_USER and AZURE_DB_PASSWORD environment variables must be set", - ); + 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...", - ); + console.log("Configuring Azure Database for PostgreSQL TLS certificates..."); await configurePostgresCertificate(kubeClient, namespace, azureCerts); }); @@ -79,7 +75,7 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt host: config.host, user: azureUser, password: azurePassword, - certificatePath: process.env.AZURE_DB_CERTIFICATES_PATH, + certificatePath: process.env.AZURE_DB_CERTIFICATES_PATH!, }); }); @@ -91,12 +87,15 @@ test.describe("Verify TLS configuration with Azure Database for PostgreSQL healt user: azureUser, password: azurePassword, }); - await kubeClient.restartDeployment(deploymentName, namespace); + const restarted = await kubeClient.restartDeployment(deploymentName, namespace); + expect(restarted).toBeDefined(); }); 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!"); }); }); } 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 0a0edb5497..0e0f9d9ad5 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,6 +1,7 @@ import { test, expect } from "@support/coverage/test"; -import { UIhelper } from "../../utils/ui-helper"; + import { Common } from "../../utils/common"; +import { UIhelper } from "../../utils/ui-helper"; test.describe("Verify TLS configuration with external Crunchy Postgres DB", () => { test.beforeAll(async () => { @@ -19,10 +20,7 @@ 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 common.loginAsKeycloakUser(process.env.GH_USER2_ID, process.env.GH_USER2_PASS); await uiHelper.verifyHeading("Welcome back!"); await page.getByLabel("Catalog").first().click(); await uiHelper.selectMuiBox("Kind", "Component"); 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 14b31a0b8f..986e3e38d8 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,4 +1,5 @@ -import { test } from "@support/coverage/test"; +import { test, expect } from "@support/coverage/test"; + import { Common } from "../../utils/common"; import { KubeClient, getRhdhDeploymentName } from "../../utils/kube-client"; import { @@ -7,26 +8,27 @@ import { configurePostgresCredentials, clearDatabase, } from "../../utils/postgres-config"; +import { UIhelper } from "../../utils/ui-helper"; interface RdsConfig { name: string; - host: string | undefined; + host: string; } test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => { - const namespace = process.env.NAME_SPACE_RUNTIME || "showcase-runtime"; + const namespace = process.env.NAME_SPACE_RUNTIME! || "showcase-runtime"; const deploymentName = getRhdhDeploymentName(); // RDS configuration from environment - const rdsUser = process.env.RDS_USER; - const rdsPassword = process.env.RDS_PASSWORD; + const rdsUser = process.env.RDS_USER!; + const rdsPassword = process.env.RDS_PASSWORD!; // Define all RDS configurations to test const rdsConfigurations: RdsConfig[] = [ - { name: "latest-3", host: process.env.RDS_1_HOST }, - { name: "latest-2", host: process.env.RDS_2_HOST }, - { name: "latest-1", host: process.env.RDS_3_HOST }, - { name: "latest", host: process.env.RDS_4_HOST }, + { name: "latest-3", host: process.env.RDS_1_HOST! }, + { name: "latest-2", host: process.env.RDS_2_HOST! }, + { name: "latest-1", host: process.env.RDS_3_HOST! }, + { name: "latest", host: process.env.RDS_4_HOST! }, ]; test.beforeAll(async () => { @@ -51,9 +53,7 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => // Validate required environment variables if (!rdsUser || !rdsPassword) { - throw new Error( - "RDS_USER and RDS_PASSWORD environment variables must be set", - ); + throw new Error("RDS_USER and RDS_PASSWORD environment variables must be set"); } const kubeClient = new KubeClient(); @@ -75,7 +75,7 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => host: config.host, user: rdsUser, password: rdsPassword, - certificatePath: process.env.RDS_DB_CERTIFICATES_PATH, + certificatePath: process.env.RDS_DB_CERTIFICATES_PATH!, }); }); @@ -87,12 +87,15 @@ test.describe("Verify TLS configuration with RDS PostgreSQL health check", () => user: rdsUser, password: rdsPassword, }); - await kubeClient.restartDeployment(deploymentName, namespace); + const restarted = await kubeClient.restartDeployment(deploymentName, namespace); + expect(restarted).toBeDefined(); }); 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!"); }); }); } diff --git a/e2e-tests/playwright/e2e/github-happy-path.spec.ts b/e2e-tests/playwright/e2e/github-happy-path.spec.ts index 6975d9f76d..24ec297d94 100644 --- a/e2e-tests/playwright/e2e/github-happy-path.spec.ts +++ b/e2e-tests/playwright/e2e/github-happy-path.spec.ts @@ -1,25 +1,60 @@ import { test, expect, Page, BrowserContext } from "@support/coverage/test"; -import { UIhelper } from "../utils/ui-helper"; -import { Common, setupBrowser, teardownBrowser } from "../utils/common"; + +import { BackstageShowcase, CatalogImport } from "../support/pages/catalog-import"; import { RESOURCES } from "../support/test-data/resources"; -import { - BackstageShowcase, - CatalogImport, -} from "../support/pages/catalog-import"; 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 context: BrowserContext; +let browserContext: BrowserContext; // TODO: https://issues.redhat.com/browse/RHDHBUGS-2099 -test.describe.fixme("GitHub Happy path", async () => { +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"; + const component = "https://github.com/redhat-developer/rhdh/blob/main/catalog-entities/all.yaml"; test.beforeAll(async ({ browser }, testInfo) => { test.info().annotations.push({ @@ -27,7 +62,7 @@ test.describe.fixme("GitHub Happy path", async () => { description: "core", }); - ({ page, context } = await setupBrowser(browser, testInfo)); + ({ page, context: browserContext } = await setupBrowser(browser, testInfo)); uiHelper = new UIhelper(page); common = new Common(page); catalogImport = new CatalogImport(page); @@ -36,22 +71,19 @@ test.describe.fixme("GitHub Happy path", async () => { }); test("Login as a Github user from Settings page.", async () => { - await common.loginAsKeycloakUser( - process.env.GH_USER2_ID, - process.env.GH_USER2_PASS, - ); + 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, + 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}`); + 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 () => { @@ -68,9 +100,7 @@ test.describe.fixme("GitHub Happy path", async () => { await uiHelper.verifyComponentInCatalog("Group", ["Janus-IDP Authors"]); await uiHelper.verifyComponentInCatalog("API", ["Petstore"]); - await uiHelper.verifyComponentInCatalog("Component", [ - "Red Hat Developer Hub", - ]); + await uiHelper.verifyComponentInCatalog("Component", ["Red Hat Developer Hub"]); await uiHelper.selectMuiBox("Kind", "Resource"); await uiHelper.verifyRowsInTable([ @@ -123,7 +153,7 @@ test.describe.fixme("GitHub Happy path", async () => { 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 BackstageShowcase.getShowcasePRs("open"); + const openPRs = await getShowcasePullRequests("open"); await backstageShowcase.verifyPRRows(openPRs, 0, 5); }); @@ -133,14 +163,14 @@ test.describe.fixme("GitHub Happy path", async () => { await expect(closedButton).toBeVisible(); await expect(closedButton).toBeEnabled(); await closedButton.click(); - const closedPRs = await BackstageShowcase.getShowcasePRs("closed"); + 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 BackstageShowcase.getShowcasePRs("all", true); + 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) @@ -164,11 +194,7 @@ test.describe.fixme("GitHub Happy path", async () => { console.log("Clicking on Previous Page button"); await backstageShowcase.clickPreviousPage(); await common.waitForLoad(); - await backstageShowcase.verifyPRRows( - allPRs, - lastPagePRs - 5, - lastPagePRs - 1, - ); + 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 () => { @@ -176,7 +202,7 @@ test.describe.fixme("GitHub Happy path", async () => { await uiHelper.clickLink("Red Hat Developer Hub"); await common.clickOnGHloginPopup(); await uiHelper.clickTab("Pull/Merge Requests"); - const allPRs = await BackstageShowcase.getShowcasePRs("open"); + const allPRs = await getShowcasePullRequests("open"); await backstageShowcase.verifyPRRowsPerPage(5, allPRs); await backstageShowcase.verifyPRRowsPerPage(10, allPRs); await backstageShowcase.verifyPRRowsPerPage(20, allPRs); @@ -186,9 +212,7 @@ test.describe.fixme("GitHub Happy path", async () => { 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}")`, - ); + const resourceElement = page.locator(`#workspace:has-text("${resource}")`); await resourceElement.scrollIntoViewIfNeeded(); await expect(resourceElement).toBeVisible(); } @@ -198,7 +222,8 @@ test.describe.fixme("GitHub Happy path", async () => { test.fixme("Sign out and verify that you return back to the Sign in page", async () => { await uiHelper.goToSettingsPage(); await common.signOut(); - await context.clearCookies(); + await browserContext.clearCookies(); + await expect(page.getByRole("button", { name: "Sign In" })).toBeVisible(); }); test.afterAll(async ({}, 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 890d8ca55e..1f98a48585 100644 --- a/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts +++ b/e2e-tests/playwright/e2e/guest-signin-happy-path.spec.ts @@ -1,7 +1,12 @@ import { test } from "@support/coverage/test"; -import { UIhelper } from "../utils/ui-helper"; + 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(); test.describe("Guest Signing Happy path", () => { test.beforeAll(async () => { @@ -37,5 +42,6 @@ test.describe("Guest Signing Happy path", () => { 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"]); }); }); diff --git a/e2e-tests/playwright/e2e/home-page-customization.spec.ts b/e2e-tests/playwright/e2e/home-page-customization.spec.ts index 873bbbd456..69e0c6c858 100644 --- a/e2e-tests/playwright/e2e/home-page-customization.spec.ts +++ b/e2e-tests/playwright/e2e/home-page-customization.spec.ts @@ -1,8 +1,9 @@ import { test } from "@support/coverage/test"; -import { UIhelper } from "../utils/ui-helper"; -import { Common } from "../utils/common"; + 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; @@ -28,10 +29,7 @@ test.describe("Home page customization", () => { await runAccessibilityTests(page, testInfo); - await uiHelper.verifyTextinCard( - "Your Starred Entities", - "Your Starred Entities", - ); + 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"); @@ -58,18 +56,10 @@ test.describe("Home page customization", () => { test("Verify Customized Quick Access", async () => { // Expanded by default await homePage.verifyQuickAccess("Developer Tools", "Podman Desktop"); - await homePage.verifyQuickAccess("CI/CD Tools", [ - "ArgoCD", - "SonarQube", - "Quay.io", - ]); + await homePage.verifyQuickAccess("CI/CD Tools", ["ArgoCD", "SonarQube", "Quay.io"]); await homePage.verifyQuickAccess("OpenShift Clusters", "OpenShift"); // Collapsed by default await homePage.verifyQuickAccess("Monitoring Tools", "Grafana", true); - await homePage.verifyQuickAccess( - "Security Tools", - ["GitHub Security", "Keycloak"], - true, - ); + await homePage.verifyQuickAccess("Security Tools", ["GitHub Security", "Keycloak"], true); }); }); diff --git a/e2e-tests/playwright/e2e/instance-health-check.spec.ts b/e2e-tests/playwright/e2e/instance-health-check.spec.ts index 122fb322a0..92caf35122 100644 --- a/e2e-tests/playwright/e2e/instance-health-check.spec.ts +++ b/e2e-tests/playwright/e2e/instance-health-check.spec.ts @@ -13,7 +13,7 @@ test.describe("Application health check", () => { const response = await request.get(healthCheckEndpoint); - const responseBody = await response.json(); + const responseBody: unknown = await response.json(); expect(response.status()).toBe(200); diff --git a/e2e-tests/playwright/e2e/learning-path-page.spec.ts b/e2e-tests/playwright/e2e/learning-path-page.spec.ts index 7baca770d0..acb265a497 100644 --- a/e2e-tests/playwright/e2e/learning-path-page.spec.ts +++ b/e2e-tests/playwright/e2e/learning-path-page.spec.ts @@ -1,7 +1,8 @@ import { expect, test } from "@support/coverage/test"; -import { UIhelper } from "../utils/ui-helper"; -import { Common } from "../utils/common"; + 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(async () => { diff --git a/e2e-tests/playwright/e2e/localization/locale.ts b/e2e-tests/playwright/e2e/localization/locale.ts index 4e66e649e7..f4217b6aaf 100644 --- a/e2e-tests/playwright/e2e/localization/locale.ts +++ b/e2e-tests/playwright/e2e/localization/locale.ts @@ -1,23 +1,18 @@ import deBackstage from "../../../../translations/backstage-de.json" with { type: "json" }; -import deRhdh from "../../../../translations/rhdh-de.json" with { type: "json" }; -import deRhdhPlugins from "../../../../translations/rhdh-plugins-de.json" with { type: "json" }; - import esBackstage from "../../../../translations/backstage-es.json" with { type: "json" }; -import esRhdh from "../../../../translations/rhdh-es.json" with { type: "json" }; -import esRhdhPlugins from "../../../../translations/rhdh-plugins-es.json" with { type: "json" }; - import frBackstage from "../../../../translations/backstage-fr.json" with { type: "json" }; -import frRhdh from "../../../../translations/rhdh-fr.json" with { type: "json" }; -import frRhdhPlugins from "../../../../translations/rhdh-plugins-fr.json" with { type: "json" }; - import itBackstage from "../../../../translations/backstage-it.json" with { type: "json" }; -import itRhdh from "../../../../translations/rhdh-it.json" with { type: "json" }; -import itRhdhPlugins from "../../../../translations/rhdh-plugins-it.json" with { type: "json" }; - import jaBackstage from "../../../../translations/backstage-ja.json" with { type: "json" }; +import deRhdh from "../../../../translations/rhdh-de.json" with { type: "json" }; +import esRhdh from "../../../../translations/rhdh-es.json" with { type: "json" }; +import frRhdh from "../../../../translations/rhdh-fr.json" with { type: "json" }; +import itRhdh from "../../../../translations/rhdh-it.json" with { type: "json" }; import jaRhdh from "../../../../translations/rhdh-ja.json" with { type: "json" }; +import deRhdhPlugins from "../../../../translations/rhdh-plugins-de.json" with { type: "json" }; +import esRhdhPlugins from "../../../../translations/rhdh-plugins-es.json" with { type: "json" }; +import frRhdhPlugins from "../../../../translations/rhdh-plugins-fr.json" with { type: "json" }; +import itRhdhPlugins from "../../../../translations/rhdh-plugins-it.json" with { type: "json" }; import jaRhdhPlugins from "../../../../translations/rhdh-plugins-ja.json" with { type: "json" }; - import en from "../../../../translations/test/all-en.json" with { type: "json" }; const de = { @@ -50,7 +45,22 @@ const ja = { ...jaRhdhPlugins, }; -export type Locale = "de" | "en" | "es" | "fr" | "it" | "ja"; +const LOCALES = ["de", "en", "es", "fr", "it", "ja"] as const; +export type Locale = (typeof LOCALES)[number]; + +const NON_EN_LOCALE_BUNDLES = { + de, + es, + fr, + it, + ja, +} as const; + +const LOCALE_SET = new Set(LOCALES); + +function isLocale(lang: string): lang is Locale { + return LOCALE_SET.has(lang); +} type TranslationFile = Record>>; @@ -72,14 +82,21 @@ function createMergedTranslations() { for (const namespace of allNamespaces) { const enKeys = (en as TranslationFile)[namespace]?.en || {}; - merged[namespace] = { + const namespaceTranslations: Record> = { en: enKeys, - de: { ...enKeys, ...((de as TranslationFile)[namespace]?.de || {}) }, - es: { ...enKeys, ...((es as TranslationFile)[namespace]?.es || {}) }, - fr: { ...enKeys, ...((fr as TranslationFile)[namespace]?.fr || {}) }, - it: { ...enKeys, ...((it as TranslationFile)[namespace]?.it || {}) }, - ja: { ...enKeys, ...((ja as TranslationFile)[namespace]?.ja || {}) }, }; + + for (const locale of LOCALES) { + if (locale === "en") { + continue; + } + namespaceTranslations[locale] = { + ...enKeys, + ...(NON_EN_LOCALE_BUNDLES[locale] as TranslationFile)[namespace]?.[locale], + }; + } + + merged[namespace] = namespaceTranslations; } return merged; @@ -89,7 +106,7 @@ const translations = createMergedTranslations(); export function getCurrentLanguage(): Locale { const lang = process.env.LOCALE || "en"; - return lang as Locale; + return isLocale(lang) ? lang : "en"; } export function getTranslations() { 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 b061275ce9..13c9c8f084 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 @@ -16,11 +16,11 @@ export interface SchemaModeEnv { } function quoteIdent(name: string): string { - return '"' + String(name).replace(/"/g, '""') + '"'; + return '"' + name.replaceAll(/"/g, '""') + '"'; } function escapePasswordLiteral(value: string): string { - return String(value).replace(/'/g, "''"); + return value.replaceAll(/'/g, "''"); } export function normalizeDbHost(host: string): string { @@ -29,9 +29,7 @@ export function normalizeDbHost(host: string): string { let portForwardRestarter: (() => Promise) | null = null; -export function setPortForwardRestarter( - fn: (() => Promise) | null, -): void { +export function setPortForwardRestarter(fn: (() => Promise) | null): void { portForwardRestarter = fn; } @@ -71,22 +69,21 @@ async function connectWithRetry(config: ClientConfig): Promise { ); } } else { - console.warn( - `Connection attempt ${attempt}/${maxRetries} failed, retrying...`, - ); + console.warn(`Connection attempt ${attempt}/${maxRetries} failed, retrying...`); } const delay = Math.min(2000 * attempt, 10000); - await new Promise((resolve) => setTimeout(resolve, delay)); + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, delay); + }); } } } - const errorMsg = - lastError instanceof Error ? lastError.message : String(lastError); - throw new Error( - `Failed to connect after ${maxRetries} attempts: ${errorMsg}`, - ); + const errorMsg = lastError instanceof Error ? lastError.message : String(lastError); + throw new Error(`Failed to connect after ${maxRetries} attempts: ${errorMsg}`); } const defaultConnectionOptions: Partial = { @@ -95,10 +92,8 @@ const defaultConnectionOptions: Partial = { keepAliveInitialDelayMillis: 10000, }; -export async function connectWithSslFallback( - config: ClientConfig, -): Promise { - return await connectWithRetry({ ...defaultConnectionOptions, ...config }); +export async function connectWithSslFallback(config: ClientConfig): Promise { + return connectWithRetry({ ...defaultConnectionOptions, ...config }); } export function getSchemaModeEnv(): SchemaModeEnv { @@ -106,18 +101,12 @@ export function getSchemaModeEnv(): SchemaModeEnv { const dbAdminPassword = process.env.SCHEMA_MODE_DB_ADMIN_PASSWORD; const dbPassword = process.env.SCHEMA_MODE_DB_PASSWORD; - expect( - dbHost, - "SCHEMA_MODE_DB_HOST must be set for schema-mode tests", - ).toBeTruthy(); + expect(dbHost, "SCHEMA_MODE_DB_HOST must be set for schema-mode tests").toBeTruthy(); expect( dbAdminPassword, "SCHEMA_MODE_DB_ADMIN_PASSWORD must be set for schema-mode tests", ).toBeTruthy(); - expect( - dbPassword, - "SCHEMA_MODE_DB_PASSWORD must be set for schema-mode tests", - ).toBeTruthy(); + expect(dbPassword, "SCHEMA_MODE_DB_PASSWORD must be set for schema-mode tests").toBeTruthy(); return { dbHost: dbHost!, @@ -132,7 +121,7 @@ export function getSchemaModeEnv(): SchemaModeEnv { export async function connectAdminClient( config: Pick, ): Promise { - return await connectWithSslFallback({ + return connectWithSslFallback({ host: normalizeDbHost(config.dbHost), port: 5432, user: config.dbAdminUser, @@ -142,9 +131,7 @@ export async function connectAdminClient( }); } -export async function cleanupOldPluginDatabases( - adminClient: Client, -): Promise { +export async function cleanupOldPluginDatabases(adminClient: Client): Promise { const oldDbsResult = await adminClient.query<{ datname: string }>(` SELECT datname FROM pg_database WHERE datistemplate = false @@ -156,9 +143,7 @@ export async function cleanupOldPluginDatabases( return; } - console.log( - `Found ${oldDbsResult.rows.length} old plugin databases, cleaning up...`, - ); + console.log(`Found ${oldDbsResult.rows.length} old plugin databases, cleaning up...`); for (const db of oldDbsResult.rows) { try { @@ -169,9 +154,7 @@ export async function cleanupOldPluginDatabases( [db.datname], ); - await adminClient.query( - `DROP DATABASE IF EXISTS ${quoteIdent(db.datname)}`, - ); + await adminClient.query(`DROP DATABASE IF EXISTS ${quoteIdent(db.datname)}`); console.log(` Dropped: ${db.datname}`); } catch (err) { console.warn( @@ -185,13 +168,10 @@ export async function setupSchemaModeDatabase( adminClient: Client, config: SchemaModeEnv, ): Promise { - const { dbHost, dbAdminUser, dbAdminPassword, dbName, dbUser, dbPassword } = - config; + const { dbHost, dbAdminUser, dbAdminPassword, dbName, dbUser, dbPassword } = config; if (dbName !== "postgres") { - await adminClient - .query(`CREATE DATABASE ${quoteIdent(dbName)}`) - .catch(() => {}); + await adminClient.query(`CREATE DATABASE ${quoteIdent(dbName)}`).catch(() => {}); console.log(`✓ Created/verified test database: ${dbName}`); } else { console.log(`✓ Using default postgres database`); @@ -248,15 +228,9 @@ export async function setupSchemaModeDatabase( }); try { - await dbClient.query( - `GRANT CREATE ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`, - ); - await dbClient.query( - `GRANT USAGE ON SCHEMA public TO ${quoteIdent(dbUser)}`, - ); - await dbClient.query( - `GRANT CREATE ON SCHEMA public TO ${quoteIdent(dbUser)}`, - ); + await dbClient.query(`GRANT CREATE ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`); + await dbClient.query(`GRANT USAGE ON SCHEMA public TO ${quoteIdent(dbUser)}`); + await dbClient.query(`GRANT CREATE ON SCHEMA public TO ${quoteIdent(dbUser)}`); await dbClient.query( `GRANT ALL PRIVILEGES ON DATABASE ${quoteIdent(dbName)} TO ${quoteIdent(dbUser)}`, ); 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 8bec27f99f..2e258b3fc4 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 @@ -4,6 +4,7 @@ */ import * as yaml from "js-yaml"; + import { KubeClient } from "../../utils/kube-client"; import { getSchemaModeEnv, @@ -24,6 +25,14 @@ interface AppConfigYaml { [key: string]: unknown; } +function parseAppConfigYaml(value: unknown): AppConfigYaml { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError("App config YAML must be an object"); + } + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- js-yaml returns unknown; shape validated at use sites + return value as AppConfigYaml; +} + export class SchemaModeTestSetup { private namespace: string; private releaseName: string; @@ -31,11 +40,7 @@ export class SchemaModeTestSetup { private env: ReturnType; private kubeClient: KubeClient; - constructor( - namespace: string, - releaseName: string, - installMethod: "helm" | "operator", - ) { + constructor(namespace: string, releaseName: string, installMethod: "helm" | "operator") { this.namespace = namespace; this.releaseName = releaseName; this.installMethod = installMethod; @@ -114,12 +119,8 @@ export class SchemaModeTestSetup { metadata: { name: secretName }, data: { password: Buffer.from(this.env.dbPassword).toString("base64"), - "postgres-password": Buffer.from(this.env.dbPassword).toString( - "base64", - ), - POSTGRES_PASSWORD: Buffer.from(this.env.dbPassword).toString( - "base64", - ), + "postgres-password": Buffer.from(this.env.dbPassword).toString("base64"), + POSTGRES_PASSWORD: Buffer.from(this.env.dbPassword).toString("base64"), POSTGRES_DB: Buffer.from(this.env.dbName).toString("base64"), POSTGRES_USER: Buffer.from(this.env.dbUser).toString("base64"), POSTGRES_HOST: Buffer.from(rhdhPostgresHost).toString("base64"), @@ -157,30 +158,24 @@ export class SchemaModeTestSetup { 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)); + 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); + }); } } } - private async ensureDeploymentEnvVars( - deploymentName: string, - secretName: string, - ): Promise { + private async ensureDeploymentEnvVars(deploymentName: string, secretName: string): Promise { const deployment = await this.kubeClient.appsApi.readNamespacedDeployment( deploymentName, this.namespace, ); const containers = deployment.body.spec?.template?.spec?.containers || []; - const backstageIdx = containers.findIndex( - (c) => c.name === "backstage-backend", - ); + const backstageIdx = containers.findIndex((c) => c.name === "backstage-backend"); const backstageContainer = containers[backstageIdx]; if (!backstageContainer) { @@ -196,9 +191,7 @@ export class SchemaModeTestSetup { "POSTGRES_USER", "POSTGRES_PASSWORD", ]; - const missingVars = requiredVars.filter( - (v) => !existingEnv.some((e) => e.name === v), - ); + const missingVars = requiredVars.filter((v) => !existingEnv.some((e) => e.name === v)); if (missingVars.length === 0) { console.log("POSTGRES_* env vars already present in deployment"); @@ -244,16 +237,11 @@ export class SchemaModeTestSetup { } private async updateAppConfigForSchemaMode(): Promise { - const configMapName = await this.kubeClient.findAppConfigMap( - this.namespace, - ); + const configMapName = await this.kubeClient.findAppConfigMap(this.namespace); let configMapResponse; try { - configMapResponse = await this.kubeClient.getConfigMap( - configMapName, - this.namespace, - ); + configMapResponse = await this.kubeClient.getConfigMap(configMapName, this.namespace); } catch { throw new Error( `ConfigMap '${configMapName}' not found in namespace '${this.namespace}'. ` + @@ -262,17 +250,13 @@ export class SchemaModeTestSetup { } const configMap = configMapResponse.body; - const configKey = Object.keys(configMap.data || {}).find((key) => - key.includes("app-config"), - ); + const configKey = Object.keys(configMap.data || {}).find((key) => key.includes("app-config")); if (!configKey || !configMap.data) { - throw new Error( - `Could not find app-config key in ConfigMap ${configMapName}`, - ); + throw new Error(`Could not find app-config key in ConfigMap ${configMapName}`); } - const appConfig = yaml.load(configMap.data[configKey]) as AppConfigYaml; + const appConfig = parseAppConfigYaml(yaml.load(configMap.data[configKey])); if (!appConfig.backend) appConfig.backend = {}; const currentDbConfig = appConfig.backend.database; @@ -316,21 +300,17 @@ export class SchemaModeTestSetup { const routeNames = this.installMethod === "operator" ? [`backstage-${this.releaseName}`, `${this.releaseName}-developer-hub`] - : [ - `${this.releaseName}-developer-hub`, - `backstage-${this.releaseName}`, - ]; + : [`${this.releaseName}-developer-hub`, `backstage-${this.releaseName}`]; for (const routeName of routeNames) { try { - const route = - (await this.kubeClient.customObjectsApi.getNamespacedCustomObject( - "route.openshift.io", - "v1", - this.namespace, - "routes", - routeName, - )) as { body?: { spec?: { host?: string } } }; + const route = (await this.kubeClient.customObjectsApi.getNamespacedCustomObject( + "route.openshift.io", + "v1", + this.namespace, + "routes", + routeName, + )) as { body?: { spec?: { host?: string } } }; if (route?.body?.spec?.host) { const url = `https://${route.body.spec.host}`; @@ -367,16 +347,11 @@ export class SchemaModeTestSetup { const hasCreateDb = result.rows[0].rolcreatedb; if (!hasCreateDb) { - console.log( - `Database user "${this.env.dbUser}" has restricted permissions (NOCREATEDB)`, - ); + console.log(`Database user "${this.env.dbUser}" has restricted permissions (NOCREATEDB)`); return true; - } else { - console.warn( - `Database user "${this.env.dbUser}" has CREATEDB privilege`, - ); - return false; } + console.warn(`Database user "${this.env.dbUser}" has CREATEDB privilege`); + return false; } finally { await adminClient.end(); } 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 cbfb4c40cc..16cfca3b25 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,40 +7,43 @@ * Tests are opt-in - they skip when SCHEMA_MODE_* environment variables are not set. */ -import { test, expect } from "@support/coverage/test"; import { ChildProcessWithoutNullStreams, spawn } from "child_process"; + +import { test, expect } from "@support/coverage/test"; + import { Common } from "../../utils/common"; import { KubeClient } from "../../utils/kube-client"; 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 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); - proc.stdout.on("data", (data) => { - if (data.toString().includes("Forwarding from")) { + 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) => { - const msg = data.toString().trim(); + proc.stderr.on("data", (data: Buffer | string) => { + const msg = streamDataToString(data).trim(); if (msg) console.error(`Port-forward stderr: ${msg}`); }); @@ -51,36 +54,32 @@ function startPortForward( }); } -function killPortForward( - proc: ChildProcessWithoutNullStreams | undefined, -): Promise { +function killPortForward(proc: ChildProcessWithoutNullStreams | undefined): Promise { if (!proc || proc.exitCode !== null) return Promise.resolve(); return new Promise((resolve) => { - const forceKillTimeout = setTimeout(() => { - try { - proc.kill("SIGKILL"); - } catch { - // already dead - } - resolve(); - }, 5000); - proc.once("close", () => { - clearTimeout(forceKillTimeout); 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" - ) as "helm" | "operator"; + const installMethod = process.env.INSTALL_METHOD === "operator" ? "operator" : "helm"; let portForwardProcess: ChildProcessWithoutNullStreams | undefined; let testSetup: SchemaModeTestSetup; @@ -114,9 +113,7 @@ test.describe("Verify pluginDivisionMode: schema", () => { const pfNamespace = process.env.SCHEMA_MODE_PORT_FORWARD_NAMESPACE!; const pfResource = process.env.SCHEMA_MODE_PORT_FORWARD_RESOURCE!; - console.log( - `Starting port-forward: ${pfResource} in ${pfNamespace} -> localhost:5432`, - ); + console.log(`Starting port-forward: ${pfResource} in ${pfNamespace} -> localhost:5432`); portForwardProcess = await startPortForward(pfNamespace, pfResource); console.log("Port-forward established"); @@ -147,14 +144,11 @@ test.describe("Verify pluginDivisionMode: schema", () => { }); test("Verify database user has restricted permissions", async () => { - const hasRestrictedPerms = - await testSetup.verifyRestrictedDatabasePermissions(); + const hasRestrictedPerms = await testSetup.verifyRestrictedDatabasePermissions(); expect(hasRestrictedPerms).toBe(true); }); - test("Verify RHDH is accessible with schema mode", async ({ - page, - }, testInfo) => { + test("Verify RHDH is accessible with schema mode", async ({ page }, testInfo) => { const kubeClient = new KubeClient(); const deploymentName = testSetup.getDeploymentName(); @@ -166,10 +160,7 @@ test.describe("Verify pluginDivisionMode: schema", () => { const readyReplicas = deployment.body.status?.readyReplicas ?? 0; if (readyReplicas < 1) { - testInfo.skip( - true, - "Deployment is not ready (cluster capacity or PVC issue)", - ); + testInfo.skip(true, "Deployment is not ready (cluster capacity or PVC issue)"); return; } } catch (error) { @@ -179,8 +170,8 @@ test.describe("Verify pluginDivisionMode: schema", () => { const common = new Common(page); await common.loginAsGuest(); - console.log( - "RHDH is accessible - plugins successfully created schemas in schema mode", - ); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + + 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 42be8f7620..1e3e32dc7d 100644 --- a/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/application-listener.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@support/coverage/test"; -import { UIhelper } from "../../utils/ui-helper"; + import { Common } from "../../utils/common"; +import { UIhelper } from "../../utils/ui-helper"; test.describe("Test ApplicationListener", () => { test.beforeAll(async () => { @@ -18,9 +19,7 @@ test.describe("Test ApplicationListener", () => { await common.loginAsGuest(); }); - test("Verify that the LocationListener logs the current location", async ({ - page, - }) => { + test("Verify that the LocationListener logs the current location", async ({ page }) => { const logs: string[] = []; page.on("console", (msg) => { diff --git a/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts b/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts index 84590b6b03..bde2b089c6 100644 --- a/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/application-provider.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from "@support/coverage/test"; -import { UIhelper } from "../../utils/ui-helper"; + import { Common } from "../../utils/common"; +import { UIhelper } from "../../utils/ui-helper"; test.describe("Test ApplicationProvider", () => { test.beforeAll(async () => { @@ -30,41 +31,37 @@ test.describe("Test ApplicationProvider", () => { // Verify Context one cards are visible await uiHelper.verifyTextinCard("Context one", "Context one"); - // Find all card containers within main article that contain "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 - .locator("main article") - .locator("> div > div") // Direct children that are card containers + .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(); + 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 all card containers that contain "Context two" + // Find card containers that contain "Context two" const contextTwoCards = page - .locator("main article") + .getByRole("main") + .getByRole("article") .locator("> div > div") .filter({ hasText: "Context two" }); + /* oxlint-enable playwright/no-raw-locators */ // Click increment on the first Context two card await contextTwoCards.first().getByRole("button", { name: "+" }).click(); // Verify both Context two cards show count of 1 (shared state) - await expect( - contextTwoCards.first().getByRole("heading", { name: "1" }), - ).toBeVisible(); - await expect( - contextTwoCards.last().getByRole("heading", { name: "1" }), - ).toBeVisible(); + await expect(contextTwoCards.first().getByRole("heading", { name: "1" })).toBeVisible(); + await expect(contextTwoCards.last().getByRole("heading", { name: "1" })).toBeVisible(); }); }); diff --git a/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts b/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts index a3a68f9896..7e73a4ceef 100644 --- a/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/frontend/sidebar.spec.ts @@ -1,6 +1,7 @@ import { Page, test, expect } from "@support/coverage/test"; -import { UIhelper } from "../../../utils/ui-helper"; + import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; +import { UIhelper } from "../../../utils/ui-helper"; import { getTranslations, getCurrentLanguage } from "../../localization/locale"; const t = getTranslations(); @@ -8,73 +9,57 @@ const lang = getCurrentLanguage(); let page: Page; -test.describe( - "Validate Sidebar Navigation Customization", - { tag: "@layer3-equivalent" }, - () => { - let uiHelper: UIhelper; - let common: Common; +test.describe("Validate Sidebar Navigation Customization", { tag: "@layer3-equivalent" }, () => { + let uiHelper: UIhelper; + let common: Common; - test.beforeAll(async ({ browser }, testInfo) => { - test.info().annotations.push({ - type: "component", - description: "plugins", - }); + test.beforeAll(async ({ browser }, testInfo) => { + test.info().annotations.push({ + type: "component", + description: "plugins", + }); - page = (await setupBrowser(browser, testInfo)).page; - uiHelper = new UIhelper(page); - common = new Common(page); + page = (await setupBrowser(browser, testInfo)).page; + uiHelper = new UIhelper(page); + common = new Common(page); - await common.loginAsGuest(); - }); + await common.loginAsGuest(); + }); - test("Verify menu order and navigate to Docs", async () => { - // Verify presence of 'References' menu and related items - const referencesMenu = uiHelper.getSideBarMenuItem("References"); - expect(referencesMenu).not.toBeNull(); - expect( - referencesMenu.getByText(t["rhdh"][lang]["menuItem.apis"]), - ).not.toBeNull(); - expect( - referencesMenu.getByText(t["rhdh"][lang]["menuItem.learningPaths"]), - ).not.toBeNull(); + 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(); + // 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"]); + // 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 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 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(); + // 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(); - }); + await uiHelper.verifyText("Test_i enabled"); + await expect(page.getByRole("link", { name: "Test_i disabled" })).toBeHidden(); + }); - test.afterAll(async ({}, testInfo) => { - await teardownBrowser(page, testInfo); - }); - }, -); + test.afterAll(async ({}, testInfo) => { + await teardownBrowser(page, testInfo); + }); +}); diff --git a/e2e-tests/playwright/e2e/plugins/http-request.spec.ts b/e2e-tests/playwright/e2e/plugins/http-request.spec.ts index 3bd9eab45d..ad8cd61927 100644 --- a/e2e-tests/playwright/e2e/plugins/http-request.spec.ts +++ b/e2e-tests/playwright/e2e/plugins/http-request.spec.ts @@ -1,21 +1,21 @@ import { test } from "@support/coverage/test"; -import { UIhelper } from "../../utils/ui-helper"; -import { Common } from "../../utils/common"; + import { CatalogImport } from "../../support/pages/catalog-import"; +import { Common } from "../../utils/common"; +import { UIhelper } from "../../utils/ui-helper"; // https://github.com/RoadieHQ/roadie-backstage-plugins/tree/main/plugins/scaffolder-actions/scaffolder-backend-module-http-request // Pre-req: Enable roadiehq-scaffolder-backend-module-http-request-dynamic plugin // 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"), + () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), "skipping due to RHDHBUGS-555 on OSD Env", ); let uiHelper: UIhelper; let common: Common; let catalogImport: CatalogImport; - const template = - "https://github.com/janus-qe/software-template/blob/main/test-http-request.yaml"; + const template = "https://github.com/janus-qe/software-template/blob/main/test-http-request.yaml"; test.beforeAll(async () => { test.info().annotations.push({ 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 2df231bb40..b98502a2dc 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 @@ -1,16 +1,52 @@ -import { CatalogUsersPO } from "../../../support/page-objects/catalog/catalog-users-obj"; +import { test, expect, APIRequestContext, APIResponse, request } from "@support/coverage/test"; + +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 { - test, - expect, - APIRequestContext, - APIResponse, - request, -} from "@support/coverage/test"; -import playwrightConfig from "../../../../playwright.config"; -test.describe("Test licensed users info backend plugin", async () => { +interface HealthResponse { + status: string; +} + +interface QuantityResponse { + quantity: string; +} + +interface LicensedUser { + userEntityRef: string; + lastAuthTime: string; +} + +function isHealthResponse(value: unknown): value is HealthResponse { + if (typeof value !== "object" || value === null) { + return false; + } + return typeof Reflect.get(value, "status") === "string"; +} + +function isQuantityResponse(value: unknown): value is QuantityResponse { + if (typeof value !== "object" || value === null) { + return false; + } + return typeof Reflect.get(value, "quantity") === "string"; +} + +function isLicensedUser(value: unknown): value is LicensedUser { + if (typeof value !== "object" || value === null) { + return false; + } + return ( + typeof Reflect.get(value, "userEntityRef") === "string" && + typeof Reflect.get(value, "lastAuthTime") === "string" + ); +} + +function isLicensedUserArray(value: unknown): value is LicensedUser[] { + return Array.isArray(value) && value.every(isLicensedUser); +} + +test.describe("Test licensed users info backend plugin", () => { let common: Common; test.beforeAll(async () => { @@ -22,7 +58,7 @@ test.describe("Test licensed users info backend plugin", async () => { let apiToken: string; - const baseRHDHURL: string = playwrightConfig.use.baseURL; + const baseRHDHURL: string = playwrightConfig.use?.baseURL ?? ""; const pluginAPIURL: string = "api/licensed-users-info/"; test.beforeEach(async ({ page }) => { @@ -41,14 +77,16 @@ test.describe("Test licensed users info backend plugin", async () => { }); const response: APIResponse = await requestContext.get("health"); - const result = await response.json(); + const result: unknown = await response.json(); /* { status: 'ok' } */ - expect(result).toHaveProperty("status"); - expect(result.status).toBe("ok"); + expect(isHealthResponse(result)).toBe(true); + if (isHealthResponse(result)) { + expect(result.status).toBe("ok"); + } }); test("Test plugin user quantity url", async () => { @@ -61,14 +99,16 @@ test.describe("Test licensed users info backend plugin", async () => { }); const response: APIResponse = await requestContext.get("users/quantity"); - const result = await response.json(); + const result: unknown = await response.json(); /* { quantity: '1' } */ - expect(result).toHaveProperty("quantity"); - expect(Number(result.quantity)).toBeGreaterThan(0); + expect(isQuantityResponse(result)).toBe(true); + if (isQuantityResponse(result)) { + expect(Number(result.quantity)).toBeGreaterThan(0); + } }); test("Test plugin users url", async () => { @@ -81,7 +121,7 @@ test.describe("Test licensed users info backend plugin", async () => { }); const response: APIResponse = await requestContext.get("users"); - const result = await response.json(); + const result: unknown = await response.json(); /* [ @@ -92,11 +132,11 @@ test.describe("Test licensed users info backend plugin", async () => { ] */ - expect(Array.isArray(result)).toBe(true); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("userEntityRef"); - expect(result[0]).toHaveProperty("lastAuthTime"); - expect(result[0].userEntityRef).toContain("user:"); + expect(isLicensedUserArray(result)).toBe(true); + if (isLicensedUserArray(result)) { + expect(result.length).toBeGreaterThan(0); + expect(result[0].userEntityRef).toContain("user:"); + } }); test("Test plugin users as a csv url", async () => { @@ -114,9 +154,7 @@ test.describe("Test licensed users info backend plugin", async () => { expect(response.headers()["content-type"]).toContain("text/csv"); // 'content-disposition': 'attachment; filename="data.csv"', - expect(response.headers()["content-disposition"]).toBe( - 'attachment; filename="data.csv"', - ); + expect(response.headers()["content-disposition"]).toBe('attachment; filename="data.csv"'); const result = await response.text(); /* @@ -127,9 +165,7 @@ test.describe("Test licensed users info backend plugin", async () => { const csvHeaders = splitText[0]; const csvData = splitText[1]; - expect(csvHeaders).toContain( - "userEntityRef,displayName,email,lastAuthTime", - ); + expect(csvHeaders).toContain("userEntityRef,displayName,email,lastAuthTime"); expect(csvData).toContain("user:"); }); }); 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 4f2c84341d..5f077adf4e 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,16 +1,17 @@ -import { Page, test } from "@support/coverage/test"; -import { UIhelper } from "../../../utils/ui-helper"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; +import { Page, test, expect } from "@support/coverage/test"; + import { CatalogImport } from "../../../support/pages/catalog-import"; -import { APIHelper } from "../../../utils/api-helper"; -import { GITHUB_API_ENDPOINTS } from "../../../utils/api-endpoints"; 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; test.describe.serial("Test Scaffolder Backend Module Annotator", () => { test.skip( - () => process.env.JOB_NAME.includes("osd-gcp"), + () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), "skipping due to RHDHBUGS-555 on OSD Env", ); @@ -28,10 +29,7 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { label: "some-label", annotation: "some-annotation", repo: `test-annotator-${Date.now()}`, - repoOwner: Buffer.from( - process.env.GITHUB_ORG || "amFudXMtcWU=", - "base64", - ).toString("utf8"), // Default repoOwner janus-qe + repoOwner: Buffer.from(process.env.GITHUB_ORG || "amFudXMtcWU=", "base64").toString("utf8"), // Default repoOwner janus-qe }; test.beforeAll(async ({ browser }, testInfo) => { @@ -72,16 +70,10 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.clickButton("Choose"); await uiHelper.fillTextInputByLabel("Name", reactAppDetails.componentName); - await uiHelper.fillTextInputByLabel( - "Description", - reactAppDetails.description, - ); + 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.fillTextInputByLabel("Annotation", reactAppDetails.annotation); await uiHelper.clickButton("Next"); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.repoOwner); @@ -89,27 +81,19 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { 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("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 page.waitForTimeout(5000); + await expect(page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ + timeout: 30_000, + }); await uiHelper.clickLink("Open in catalog"); }); @@ -117,11 +101,8 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText( - `${reactAppDetails.componentName}`, - ["website"], - ); - await uiHelper.clickLink(`${reactAppDetails.componentName}`); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); + await uiHelper.clickLink(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml( `labels:\n custom: ${reactAppDetails.label}\n`, @@ -132,11 +113,8 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText( - `${reactAppDetails.componentName}`, - ["website"], - ); - await uiHelper.clickLink(`${reactAppDetails.componentName}`); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); + await uiHelper.clickLink(reactAppDetails.componentName); await catalogImport.inspectEntityAndVerifyYaml( `custom.io/annotation: ${reactAppDetails.annotation}`, @@ -147,15 +125,10 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.openCatalogSidebar("Component"); await uiHelper.searchInputPlaceholder(reactAppDetails.componentName); - await uiHelper.verifyRowInTableByUniqueText( - `${reactAppDetails.componentName}`, - ["website"], - ); - await uiHelper.clickLink(`${reactAppDetails.componentName}`); + await uiHelper.verifyRowInTableByUniqueText(reactAppDetails.componentName, ["website"]); + await uiHelper.clickLink(reactAppDetails.componentName); - await catalogImport.inspectEntityAndVerifyYaml( - `backstage.io/template-version: 0.0.1`, - ); + await catalogImport.inspectEntityAndVerifyYaml(`backstage.io/template-version: 0.0.1`); }); test("Verify template version annotation is present on the template", async () => { @@ -163,23 +136,16 @@ test.describe.serial("Test Scaffolder Backend Module Annotator", () => { await uiHelper.selectMuiBox("Kind", "Template"); await uiHelper.searchInputPlaceholder("Create React App Template\n"); - await uiHelper.verifyRowInTableByUniqueText("Create React App Template", [ - "website", - ]); + await uiHelper.verifyRowInTableByUniqueText("Create React App Template", ["website"]); await uiHelper.clickLink("Create React App Template"); - await catalogImport.inspectEntityAndVerifyYaml( - `backstage.io/template-version: 0.0.1`, - ); + await catalogImport.inspectEntityAndVerifyYaml(`backstage.io/template-version: 0.0.1`); }); test.afterAll(async ({}, testInfo) => { await APIHelper.githubRequest( "DELETE", - GITHUB_API_ENDPOINTS.deleteRepo( - reactAppDetails.repoOwner, - reactAppDetails.repo, - ), + 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 477d8b4dc5..aee70396ea 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,15 +1,16 @@ import { expect, Page, test } from "@support/coverage/test"; -import { UIhelper } from "../../../utils/ui-helper"; -import { Common, setupBrowser, teardownBrowser } from "../../../utils/common"; + import { CatalogImport } from "../../../support/pages/catalog-import"; -import { APIHelper } from "../../../utils/api-helper"; 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; test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { test.skip( - () => process.env.JOB_NAME.includes("osd-gcp"), + () => (process.env.JOB_NAME ?? "").includes("osd-gcp"), "skipping due to RHDHBUGS-555 on OSD Env", ); @@ -28,10 +29,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { label: "test-label", annotation: "test-annotation", repo: `test-relation-${Date.now()}`, - repoOwner: Buffer.from( - process.env.GITHUB_ORG || "amFudXMtcWU=", - "base64", - ).toString("utf8"), // Default repoOwner janus-qe + repoOwner: Buffer.from(process.env.GITHUB_ORG || "amFudXMtcWU=", "base64").toString("utf8"), // Default repoOwner janus-qe }; test.beforeAll(async ({ browser }, testInfo) => { @@ -72,16 +70,10 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { await uiHelper.clickButton("Choose"); await uiHelper.fillTextInputByLabel("Name", reactAppDetails.componentName); - await uiHelper.fillTextInputByLabel( - "Description", - reactAppDetails.description, - ); + 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.fillTextInputByLabel("Annotation", reactAppDetails.annotation); await uiHelper.clickButton("Next"); await uiHelper.fillTextInputByLabel("Owner", reactAppDetails.repoOwner); @@ -91,9 +83,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { 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({ + await expect(page.getByRole("link", { name: "Open in catalog" })).toBeVisible({ timeout: 60000, }); await uiHelper.clickLink("Open in catalog"); @@ -127,15 +117,9 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { const labelSelector = 'g[data-testid="label"]'; const nodeSelector = 'g[data-testid="node"]'; - await uiHelper.verifyTextInSelector( - labelSelector, - "scaffolderOf / scaffoldedFrom", - ); + await uiHelper.verifyTextInSelector(labelSelector, "scaffolderOf / scaffoldedFrom"); - await uiHelper.verifyPartialTextInSelector( - nodeSelector, - reactAppDetails.componentPartialName, - ); + await uiHelper.verifyPartialTextInSelector(nodeSelector, reactAppDetails.componentPartialName); }); test("Verify scaffolderOf relation on the template", async () => { @@ -143,9 +127,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { await uiHelper.selectMuiBox("Kind", "Template"); await uiHelper.searchInputPlaceholder("Create React App Template\n"); - await uiHelper.verifyRowInTableByUniqueText("Create React App Template", [ - "website", - ]); + await uiHelper.verifyRowInTableByUniqueText("Create React App Template", ["website"]); await uiHelper.clickLink("Create React App Template"); // Verify the scaffolderOf relation in the YAML view @@ -161,10 +143,7 @@ test.describe.serial("Test Scaffolder Relation Processor Plugin", () => { test.afterAll(async ({}, testInfo) => { await APIHelper.githubRequest( "DELETE", - GITHUB_API_ENDPOINTS.deleteRepo( - reactAppDetails.repoOwner, - reactAppDetails.repo, - ), + GITHUB_API_ENDPOINTS.deleteRepo(reactAppDetails.repoOwner, reactAppDetails.repo), ); await teardownBrowser(page, testInfo); }); 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 fd5da79396..66d485e8a6 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,49 +1,42 @@ import { test, expect } from "@support/coverage/test"; + import { Common } from "../../utils/common"; import { UIhelper } from "../../utils/ui-helper"; -test.describe( - "Test user settings info card", - { tag: "@layer3-equivalent" }, - () => { - test.beforeAll(async () => { - test.info().annotations.push({ - type: "component", - description: "plugins", - }); +test.describe("Test user settings info card", { tag: "@layer3-equivalent" }, () => { + test.beforeAll(async () => { + test.info().annotations.push({ + type: "component", + description: "plugins", }); + }); - let uiHelper: UIhelper; + let uiHelper: UIhelper; - test.beforeEach(async ({ page }) => { - const common = new Common(page); - await common.loginAsGuest(); + test.beforeEach(async ({ page }) => { + const common = new Common(page); + await common.loginAsGuest(); - uiHelper = new UIhelper(page); - }); + uiHelper = new UIhelper(page); + }); - test("Check if customized build info is rendered", async ({ page }) => { - await uiHelper.openSidebar("Home"); - await page.getByText("Guest").click(); - await page.getByRole("menuitem", { name: "Settings" }).click(); + test("Check if customized build info is rendered", async ({ 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(); + // Verify card header is visible + await expect(page.getByText("RHDH Build info")).toBeVisible(); - // Verify initial card content using text content - await expect(page.getByText("TechDocs builder: local")).toBeVisible(); - await expect( - page.getByText("Authentication provider: Github"), - ).toBeVisible(); + // Verify initial card content using text content + await expect(page.getByText("TechDocs builder: local")).toBeVisible(); + await expect(page.getByText("Authentication provider: Github")).toBeVisible(); - await page.getByTitle("Show more").click(); + await page.getByTitle("Show more").click(); - // Verify expanded card content shows RBAC status - await expect(page.getByText("TechDocs builder: local")).toBeVisible(); - await expect( - page.getByText("Authentication provider: Github"), - ).toBeVisible(); - await expect(page.getByText("RBAC: disabled")).toBeVisible(); - }); - }, -); + // 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(); + }); +}); diff --git a/e2e-tests/playwright/e2e/settings.spec.ts b/e2e-tests/playwright/e2e/settings.spec.ts index de33a0635c..773f3af28e 100644 --- a/e2e-tests/playwright/e2e/settings.spec.ts +++ b/e2e-tests/playwright/e2e/settings.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "@support/coverage/test"; + import { Common } from "../utils/common"; import { UIhelper } from "../utils/ui-helper"; import { getTranslations, getCurrentLanguage } from "./localization/locale"; @@ -60,25 +61,16 @@ test.describe(`Settings page`, { tag: "@layer3-equivalent" }, () => { 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.userEntity"] + ": Guest User", - ); - await uiHelper.verifyText( - t["user-settings"]["fr"]["identityCard.ownershipEntities"] + - ": Guest User, team-a", + 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 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.checkCheckbox(t["user-settings"]["fr"]["pinToggle.ariaLabelTitle"]); await uiHelper.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 7f2c94913e..dc8cf915d1 100644 --- a/e2e-tests/playwright/e2e/smoke-test.spec.ts +++ b/e2e-tests/playwright/e2e/smoke-test.spec.ts @@ -1,6 +1,7 @@ import { test } from "@support/coverage/test"; -import { UIhelper } from "../utils/ui-helper"; + import { Common } from "../utils/common"; +import { UIhelper } from "../utils/ui-helper"; test.describe("Smoke test", { tag: "@smoke" }, () => { let uiHelper: UIhelper; diff --git a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts index 7fa5079c58..6acb2f6214 100644 --- a/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts +++ b/e2e-tests/playwright/e2e/verify-redis-cache.spec.ts @@ -1,8 +1,14 @@ +import { ChildProcessWithoutNullStreams, exec, spawn } from "child_process"; + import { expect, test } from "@support/coverage/test"; -import { UIhelper } from "../utils/ui-helper"; -import { Common } from "../utils/common"; import Redis from "ioredis"; -import { ChildProcessWithoutNullStreams, exec, spawn } from "child_process"; + +import { Common } from "../utils/common"; +import { UIhelper } from "../utils/ui-helper"; + +function streamDataToString(data: Buffer | string): string { + return typeof data === "string" ? data : data.toString(); +} test.describe("Verify Redis Cache DB", () => { test.beforeAll(async () => { @@ -35,15 +41,16 @@ test.describe("Verify Redis Cache DB", () => { console.log("Waiting for port-forward to be ready..."); await new Promise((resolve, reject) => { - portForward.stdout.on("data", (data) => { - if (data.toString().includes("Forwarding from 127.0.0.1:6379")) { + portForward.stdout.on("data", (data: Buffer | string) => { + if (streamDataToString(data).includes("Forwarding from 127.0.0.1:6379")) { resolve(); } }); - portForward.stderr.on("data", (data) => { - console.error(`Port forwarding failed: ${data.toString()}`); - reject(new Error(`Port forwarding failed: ${data.toString()}`)); + portForward.stderr.on("data", (data: Buffer | string) => { + const message = streamDataToString(data); + console.error(`Port forwarding failed: ${message}`); + reject(new Error(`Port forwarding failed: ${message}`)); }); }); }); @@ -51,8 +58,8 @@ test.describe("Verify Redis Cache DB", () => { test("Open techdoc and verify the cache generated in redis db", async () => { test.setTimeout(120_000); - portForward.stdout.on("data", (data) => { - console.log(`Port-forward stdout: ${data.toString()}`); + portForward.stdout.on("data", (data: Buffer | string) => { + console.log(`Port-forward stdout: ${streamDataToString(data)}`); }); await uiHelper.openSidebarButton("Favorites"); @@ -73,9 +80,7 @@ test.describe("Verify Redis Cache DB", () => { ); console.log("Verifying Redis keys..."); await expect(async () => { - const keys = (await redis.keys("*")).filter((k) => - k.includes("techdocs"), - ); + const keys = (await redis.keys("*")).filter((k) => k.includes("techdocs")); expect(keys).toContainEqual(expect.stringContaining("techdocs")); const key = keys[0]; console.log(`Verifying key format: ${key}`); @@ -93,8 +98,6 @@ test.describe("Verify Redis Cache DB", () => { 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`, - ); + exec(`ps aux | grep 'kubectl port-forward' | grep -v grep | awk '{print $2}' | xargs kill -9`); }); }); diff --git a/e2e-tests/playwright/projects.ts b/e2e-tests/playwright/projects.ts index 6870064663..b6ca2618b2 100644 --- a/e2e-tests/playwright/projects.ts +++ b/e2e-tests/playwright/projects.ts @@ -34,5 +34,4 @@ export const PW_PROJECT = projectsJson as { }; // Type for project names -export type PlaywrightProjectName = - (typeof PW_PROJECT)[keyof typeof PW_PROJECT]; +export type PlaywrightProjectName = (typeof PW_PROJECT)[keyof typeof PW_PROJECT]; diff --git a/e2e-tests/playwright/support/api/github-structures.ts b/e2e-tests/playwright/support/api/github-structures.ts index 66eec1b2c2..f138e800df 100644 --- a/e2e-tests/playwright/support/api/github-structures.ts +++ b/e2e-tests/playwright/support/api/github-structures.ts @@ -2,10 +2,16 @@ export class GetOrganizationResponse { reposUrl: string; constructor(response: unknown) { - enum OrganizationResponseAttributes { - REPOS_URL = "repos_url", + if (typeof response !== "object" || response === null || !("repos_url" in response)) { + throw new Error("Invalid GitHub organization response"); } - this.reposUrl = response[OrganizationResponseAttributes.REPOS_URL]; + + const reposUrl = (response as { repos_url: unknown }).repos_url; + if (typeof reposUrl !== "string") { + throw new Error("Invalid GitHub organization response: missing repos_url"); + } + + this.reposUrl = reposUrl; } } diff --git a/e2e-tests/playwright/support/api/github.ts b/e2e-tests/playwright/support/api/github.ts index 62f1e4f499..8b1b8b6d8d 100644 --- a/e2e-tests/playwright/support/api/github.ts +++ b/e2e-tests/playwright/support/api/github.ts @@ -1,20 +1,14 @@ -import { JANUS_ORG } from "../../utils/constants"; -import { APIHelper } from "../../utils/api-helper"; 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 async getReposFromOrg(org = JANUS_ORG) { - return APIHelper.getGithubPaginatedRequest( - GITHUB_API_ENDPOINTS.orgRepos(org), - ); + return APIHelper.getGithubPaginatedRequest(GITHUB_API_ENDPOINTS.orgRepos(org)); } - public async fileExistsInRepo( - owner: string, - repo: string, - file: string, - ): Promise { + public async fileExistsInRepo(owner: string, repo: string, file: string): Promise { const resp = await APIHelper.githubRequest( "GET", `${GITHUB_API_ENDPOINTS.contents(owner, repo)}/${file}`, diff --git a/e2e-tests/playwright/support/api/rbac-api.ts b/e2e-tests/playwright/support/api/rbac-api.ts index 2c2d282f08..48b92f3f72 100644 --- a/e2e-tests/playwright/support/api/rbac-api.ts +++ b/e2e-tests/playwright/support/api/rbac-api.ts @@ -1,23 +1,24 @@ -import { - APIRequestContext, - APIResponse, - Page, - request, -} from "@playwright/test"; +import { APIRequestContext, APIResponse, Page, request } from "@playwright/test"; + import playwrightConfig from "../../../playwright.config"; import { Policy, Role } from "./rbac-api-structures"; import { RhdhAuthApiHack } from "./rhdh-auth-api-hack"; export default class RhdhRbacApi { - private readonly apiUrl = playwrightConfig.use.baseURL + "/api/permission/"; + private readonly apiUrl: string; private readonly authHeader: { Accept: "application/json"; Authorization: string; }; - private myContext: APIRequestContext; + private myContext!: APIRequestContext; private readonly roleRegex = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/; private constructor(private readonly token: string) { + const baseURL = playwrightConfig.use?.baseURL; + if (!baseURL) { + throw new Error("playwright.config use.baseURL is not defined"); + } + this.apiUrl = baseURL + "/api/permission/"; this.authHeader = { Accept: "application/json", Authorization: `Bearer ${this.token}`, @@ -36,11 +37,11 @@ export default class RhdhRbacApi { //Roles: public async getRoles(): Promise { - return await this.myContext.get("roles"); + return this.myContext.get("roles"); } public async getRole(role: string): Promise { - return await this.myContext.get(`roles/role/${role}`); + return this.myContext.get(`roles/role/${role}`); } public async updateRole( role: string /* shall be like: default/admin */, @@ -48,36 +49,36 @@ export default class RhdhRbacApi { newRole: Role, ): Promise { this.checkRoleFormat(role); - return await this.myContext.put(`roles/role/${role}`, { + return this.myContext.put(`roles/role/${role}`, { data: { oldRole, newRole }, }); } public async createRoles(role: Role): Promise { - return await this.myContext.post("roles", { data: role }); + return this.myContext.post("roles", { data: role }); } public async deleteRole(role: string): Promise { - return await this.myContext.delete(`roles/role/${role}`); + return this.myContext.delete(`roles/role/${role}`); } //Policies: public async getPolicies(): Promise { - return await this.myContext.get("policies"); + return this.myContext.get("policies"); } public async getPoliciesByRole(policy: string): Promise { - return await this.myContext.get(`policies/role/${policy}`); + return this.myContext.get(`policies/role/${policy}`); } public async getPoliciesByQuery( params: string | { [key: string]: string | number | boolean }, ): Promise { - return await this.myContext.get("policies", { params }); + return this.myContext.get("policies", { params }); } public async createPolicies(policy: Policy[]): Promise { - return await this.myContext.post("policies", { data: policy }); + return this.myContext.post("policies", { data: policy }); } public async updatePolicy( @@ -86,13 +87,13 @@ export default class RhdhRbacApi { newPolicy: Policy[], ): Promise { this.checkRoleFormat(role); - return await this.myContext.put(`policies/role/${role}`, { + return this.myContext.put(`policies/role/${role}`, { data: { oldPolicy, newPolicy }, }); } public async deletePolicy(policy: string, policies: Policy[]) { this.checkRoleFormat(policy); - return await this.myContext.delete(`policies/role/${policy}`, { + return this.myContext.delete(`policies/role/${policy}`, { data: policies, }); } @@ -100,21 +101,21 @@ export default class RhdhRbacApi { // Conditions public async getConditions(): Promise { - return await this.myContext.get("roles/conditions"); + return this.myContext.get("roles/conditions"); } public async getConditionByQuery( params: string | { [key: string]: string | number | boolean }, ): Promise { - return await this.myContext.get("roles/conditions", { params }); + return this.myContext.get("roles/conditions", { params }); } public async getConditionById(id: number): Promise { - return await this.myContext.get(`roles/conditions/${id}`); + return this.myContext.get(`roles/conditions/${id}`); } public async deleteConditionById(id: number): Promise { - return await this.myContext.delete(`roles/conditions/${id}`); + return this.myContext.delete(`roles/conditions/${id}`); } public async dispose(): Promise { @@ -123,9 +124,7 @@ export default class RhdhRbacApi { private checkRoleFormat(role: string) { if (!this.roleRegex.test(role)) - throw Error( - "roles passed to the Rbac api must have format like: default/admin", - ); + throw Error("roles passed to the Rbac api must have format like: default/admin"); } public static async buildRbacApi(page: Page): Promise { diff --git a/e2e-tests/playwright/support/api/rhdh-auth-api-hack.ts b/e2e-tests/playwright/support/api/rhdh-auth-api-hack.ts index 70e4bacac8..c9a02ead7d 100644 --- a/e2e-tests/playwright/support/api/rhdh-auth-api-hack.ts +++ b/e2e-tests/playwright/support/api/rhdh-auth-api-hack.ts @@ -1,10 +1,29 @@ import { Page } from "@playwright/test"; +interface BackstageRefreshResponse { + backstageIdentity?: { + token?: string; + }; +} + +function parseRefreshToken(body: unknown): string { + if (typeof body !== "object" || body === null) { + throw new Error("Token not found in response body"); + } + + const identity = (body as BackstageRefreshResponse).backstageIdentity; + if (identity && typeof identity.token === "string") { + return identity.token; + } + + throw new Error("Token not found in response body"); +} + // here, we spy on the request to get the Backstage token to use APIs -export class RhdhAuthApiHack { - static token: string; +export const RhdhAuthApiHack = { + token: undefined as string | undefined, - static async getToken(page: Page, provider: "oidc" = "oidc") { + async getToken(page: Page, provider: "oidc" = "oidc"): Promise { try { const response = await page.request.get( `/api/auth/${provider}/refresh?optional=&scope=&env=development`, @@ -19,22 +38,14 @@ export class RhdhAuthApiHack { throw new Error(`HTTP error! Status: ${response.status()}`); } - const body = await response.json(); - - if ( - body && - body.backstageIdentity && - typeof body.backstageIdentity.token === "string" - ) { - RhdhAuthApiHack.token = body.backstageIdentity.token; - return RhdhAuthApiHack.token; - } else { - throw new Error("Token not found in response body"); - } + const body: unknown = await response.json(); + const token = parseRefreshToken(body); + RhdhAuthApiHack.token = token; + return token; } catch (error) { console.error("Failed to retrieve the token:", error); throw error; } - } -} + }, +}; diff --git a/e2e-tests/playwright/support/api/rhdh-auth-hack.ts b/e2e-tests/playwright/support/api/rhdh-auth-hack.ts index d9b88a2709..e4c4f9c9f4 100644 --- a/e2e-tests/playwright/support/api/rhdh-auth-hack.ts +++ b/e2e-tests/playwright/support/api/rhdh-auth-hack.ts @@ -1,6 +1,7 @@ import { Page } from "@playwright/test"; -import { UIhelper } from "../../utils/ui-helper"; + import playwrightConfig from "../../../playwright.config"; +import { UIhelper } from "../../utils/ui-helper"; //https://redhatquickcourses.github.io/devhub-admin/devhub-admin/1/chapter2/rbac.html#_lab_rbac_rest_api export class RhdhAuthUiHack { @@ -18,20 +19,25 @@ export class RhdhAuthUiHack { async getApiToken(page: Page): Promise { if (!this.token) { - const apiToken = await this._getApiToken(page); + const apiToken = await this.fetchApiTokenFromPage(page); + if (!apiToken) { + throw new Error("Failed to obtain API token from page request"); + } this.token = apiToken; } return this.token; } - private async _getApiToken(page: Page) { + private async fetchApiTokenFromPage(page: Page): Promise { const uiHelper = new UIhelper(page); + const baseURL = playwrightConfig.use?.baseURL; + if (!baseURL) { + throw new Error("playwright.config use.baseURL is not defined"); + } const requestPromise = page.waitForRequest( (request) => - request.url() === - `${playwrightConfig.use.baseURL}/api/search/query?term=` && - request.method() === "GET", + request.url() === `${baseURL}/api/search/query?term=` && request.method() === "GET", { timeout: 15000 }, ); await uiHelper.openSidebar("Home"); 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 index 721e0d6f20..8b56623e29 100644 --- a/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts +++ b/e2e-tests/playwright/support/page-objects/catalog/catalog-users-obj.ts @@ -1,3 +1,4 @@ +/* oxlint-disable typescript/no-extraneous-class -- grouped static page-object helpers */ import { Page, Locator } from "@playwright/test"; export class CatalogUsersPO { diff --git a/e2e-tests/playwright/support/page-objects/global-obj.ts b/e2e-tests/playwright/support/page-objects/global-obj.ts index ee5b314785..c295b5012f 100644 --- a/e2e-tests/playwright/support/page-objects/global-obj.ts +++ b/e2e-tests/playwright/support/page-objects/global-obj.ts @@ -1,4 +1,6 @@ +/* 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-selectors"; /** @@ -30,8 +32,7 @@ export const UI_HELPER_ELEMENTS = { // ======================================== /** @deprecated Use SemanticSelectors.button(page, name) or getButton() */ - MuiButtonLabel: - 'span[class^="MuiButton-label"],button[class*="MuiButton-root"]', + 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) */ @@ -79,16 +80,14 @@ export const UI_HELPER_ELEMENTS = { * ✅ Preferred over MuiButtonLabel * @example UI_HELPER_ELEMENTS.getButton(page, 'Submit').click() */ - getButton: (page: Page, name: string | RegExp): Locator => - SemanticSelectors.button(page, name), + 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), + getLink: (page: Page, name: string | RegExp): Locator => SemanticSelectors.link(page, name), /** * Get a table element @@ -125,26 +124,21 @@ export const UI_HELPER_ELEMENTS = { * 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), + 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), + 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), + getDialog: (page: Page, name?: string | RegExp): Locator => SemanticSelectors.dialog(page, name), /** * Get a card by heading text (semantic alternative to MuiCard) @@ -196,8 +190,7 @@ export const UI_HELPER_ELEMENTS = { * ✅ Preferred over MuiAlert * @example await expect(UI_HELPER_ELEMENTS.getAlert(page, 'Error')).toBeVisible() */ - getAlert: (page: Page, name?: string | RegExp): Locator => - SemanticSelectors.alert(page, name), + getAlert: (page: Page, name?: string | RegExp): Locator => SemanticSelectors.alert(page, name), /** * Get navigation element diff --git a/e2e-tests/playwright/support/page-objects/page-obj.ts b/e2e-tests/playwright/support/page-objects/page-obj.ts index 553262770f..9a409e271c 100644 --- a/e2e-tests/playwright/support/page-objects/page-obj.ts +++ b/e2e-tests/playwright/support/page-objects/page-obj.ts @@ -1,9 +1,8 @@ +/* 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-selectors"; -import { - getTranslations, - getCurrentLanguage, -} from "../../e2e/localization/locale"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -123,8 +122,7 @@ export const KUBERNETES_COMPONENTS = { * Get pod logs label/button * @example KUBERNETES_COMPONENTS.getPodLogsButton(page).click() */ - getPodLogsButton: (page: Page): Locator => - page.locator('label[aria-label="get logs"]'), + getPodLogsButton: (page: Page): Locator => page.locator('label[aria-label="get logs"]'), /** * Get error/notification snackbar @@ -132,9 +130,7 @@ export const KUBERNETES_COMPONENTS = { * @example await expect(KUBERNETES_COMPONENTS.getNotification(page)).toContainText('Error') */ getNotification: (page: Page, message?: string | RegExp): Locator => - message - ? SemanticSelectors.alert(page, message) - : SemanticSelectors.alert(page), + message ? SemanticSelectors.alert(page, message) : SemanticSelectors.alert(page), }; /** @@ -158,8 +154,7 @@ export const BACKSTAGE_SHOWCASE_COMPONENTS = { * ✅ Already semantic, but wrapped for consistency * @example BACKSTAGE_SHOWCASE_COMPONENTS.getNextPageButton(page).click() */ - getNextPageButton: (page: Page): Locator => - page.getByRole("button", { name: "Next Page" }), + getNextPageButton: (page: Page): Locator => page.getByRole("button", { name: "Next Page" }), /** * Get previous page button @@ -172,23 +167,20 @@ export const BACKSTAGE_SHOWCASE_COMPONENTS = { * Get last page button * @example BACKSTAGE_SHOWCASE_COMPONENTS.getLastPageButton(page).click() */ - getLastPageButton: (page: Page): Locator => - page.getByRole("button", { name: "Last Page" }), + 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" }), + 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"), + getTableRows: (page: Page): Locator => SemanticSelectors.table(page).locator("tbody tr"), /** * Get specific table row by content @@ -211,8 +203,7 @@ export const SETTINGS_PAGE_COMPONENTS = { * Get user settings menu button * @example SETTINGS_PAGE_COMPONENTS.getUserSettingsMenu(page).click() */ - getUserSettingsMenu: (page: Page): Locator => - page.getByTestId("user-settings-menu"), + getUserSettingsMenu: (page: Page): Locator => page.getByTestId("user-settings-menu"), /** * Get sign out menu item @@ -234,8 +225,7 @@ export const ROLES_PAGE_COMPONENTS = { * Get edit role button * @example ROLES_PAGE_COMPONENTS.getEditRoleButton(page, 'admin').click() */ - getEditRoleButton: (page: Page, name: string): Locator => - page.getByTestId(`edit-role-${name}`), + getEditRoleButton: (page: Page, name: string): Locator => page.getByTestId(`edit-role-${name}`), /** * Get delete role button @@ -257,8 +247,7 @@ export const DELETE_ROLE_COMPONENTS = { * Get role name confirmation input * @example DELETE_ROLE_COMPONENTS.getRoleNameInput(page).fill('role-name') */ - getRoleNameInput: (page: Page): Locator => - page.locator('input[name="delete-role"]'), + getRoleNameInput: (page: Page): Locator => page.locator('input[name="delete-role"]'), }; /** @@ -273,13 +262,11 @@ export const ROLE_OVERVIEW_COMPONENTS_TEST_ID = { * Get update policies button * @example ROLE_OVERVIEW_COMPONENTS_TEST_ID.getUpdatePoliciesButton(page).click() */ - getUpdatePoliciesButton: (page: Page): Locator => - page.getByTestId("update-policies"), + 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"), + 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 index b2eb32f9c6..664afa6f71 100644 --- a/e2e-tests/playwright/support/page-objects/page.ts +++ b/e2e-tests/playwright/support/page-objects/page.ts @@ -1,4 +1,5 @@ import { Page } from "@playwright/test"; + import { UIhelper } from "../../utils/ui-helper"; export enum PagesUrl { diff --git a/e2e-tests/playwright/support/pages/catalog-import.ts b/e2e-tests/playwright/support/pages/catalog-import.ts index 3fef08ac87..2ab25067f7 100644 --- a/e2e-tests/playwright/support/pages/catalog-import.ts +++ b/e2e-tests/playwright/support/pages/catalog-import.ts @@ -1,14 +1,9 @@ import { Page, expect } from "@playwright/test"; -import { UIhelper } from "../../utils/ui-helper"; + +import { getTranslations, getCurrentLanguage } from "../../e2e/localization/locale"; import { APIHelper } from "../../utils/api-helper"; -import { - BACKSTAGE_SHOWCASE_COMPONENTS, - CATALOG_IMPORT_COMPONENTS, -} from "../page-objects/page-obj"; -import { - getTranslations, - getCurrentLanguage, -} from "../../e2e/localization/locale"; +import { UIhelper } from "../../utils/ui-helper"; +import { BACKSTAGE_SHOWCASE_COMPONENTS, CATALOG_IMPORT_COMPONENTS } from "../page-objects/page-obj"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -46,9 +41,7 @@ export class CatalogImport { * @returns boolean indicating if the component is already registered */ async isComponentAlreadyRegistered(): Promise { - return await this.uiHelper.isBtnVisible( - t["catalog-import"][lang]["stepReviewLocation.refresh"], - ); + return this.uiHelper.isBtnVisible(t["catalog-import"][lang]["stepReviewLocation.refresh"]); } /** @@ -58,31 +51,21 @@ export class CatalogImport { * @param url - The component URL to register * @param clickViewComponent - Whether to click "View Component" after import */ - async registerExistingComponent( - url: string, - clickViewComponent: boolean = true, - ) { + async registerExistingComponent(url: string, clickViewComponent: boolean = true) { await this.analyzeAndWait(url); - const isComponentAlreadyRegistered = - await this.isComponentAlreadyRegistered(); + const isComponentAlreadyRegistered = await this.isComponentAlreadyRegistered(); if (isComponentAlreadyRegistered) { - await this.uiHelper.clickButton( - t["catalog-import"][lang]["stepReviewLocation.refresh"], - ); + await this.uiHelper.clickButton(t["catalog-import"][lang]["stepReviewLocation.refresh"]); expect( await this.uiHelper.isBtnVisible( t["catalog-import"][lang]["stepFinishImportLocation.backButtonText"], ), ).toBeTruthy(); } else { - await this.uiHelper.clickButton( - t["catalog-import"][lang]["stepReviewLocation.import"], - ); + await this.uiHelper.clickButton(t["catalog-import"][lang]["stepReviewLocation.import"]); if (clickViewComponent) { await this.uiHelper.clickButton( - t["catalog-import"][lang][ - "stepFinishImportLocation.locations.viewButtonText" - ], + t["catalog-import"][lang]["stepFinishImportLocation.locations.viewButtonText"], ); } } @@ -91,9 +74,7 @@ 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 this.uiHelper.clickButton(t["catalog-import"][lang]["stepInitAnalyzeUrl.nextButtonText"]); } async inspectEntityAndVerifyYaml(text: string) { @@ -114,16 +95,8 @@ export class BackstageShowcase { this.uiHelper = new UIhelper(page); } - static async getShowcasePRs( - state: "open" | "closed" | "all", - paginated = false, - ) { - return await APIHelper.getGitHubPRs( - "redhat-developer", - "rhdh", - state, - paginated, - ); + static async getShowcasePRs(state: "open" | "closed" | "all", paginated = false) { + return APIHelper.getGitHubPRs("redhat-developer", "rhdh", state, paginated); } async clickNextPage() { @@ -138,7 +111,7 @@ export class BackstageShowcase { await this.page.click(BACKSTAGE_SHOWCASE_COMPONENTS.tableLastPage); } - async verifyPRRowsPerPage(rows, allPRs) { + 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, { @@ -146,9 +119,7 @@ export class BackstageShowcase { notVisible: true, }); - const tableRows = this.page.locator( - BACKSTAGE_SHOWCASE_COMPONENTS.tableRows, - ); + const tableRows = this.page.locator(BACKSTAGE_SHOWCASE_COMPONENTS.tableRows); await expect(tableRows).toHaveCount(rows); } @@ -163,21 +134,14 @@ export class BackstageShowcase { } async verifyAboutCardIsDisplayed() { - const url = - "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; - const isLinkVisible = await this.page - .locator(`a[href="${url}"]`) - .isVisible(); + const url = "https://github.com/redhat-developer/rhdh/tree/main/catalog-entities/components/"; + const isLinkVisible = await this.page.locator(`a[href="${url}"]`).isVisible(); if (!isLinkVisible) { throw new Error("About card is not displayed"); } } - async verifyPRRows( - allPRs: { title: string }[], - startRow: number, - lastRow: number, - ) { + 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-item.ts b/e2e-tests/playwright/support/pages/catalog-item.ts index 9d3f3628ea..df22f0de9c 100644 --- a/e2e-tests/playwright/support/pages/catalog-item.ts +++ b/e2e-tests/playwright/support/pages/catalog-item.ts @@ -1,4 +1,5 @@ import { expect, Page } from "@playwright/test"; + import { GITHUB_URL } from "../../utils/constants"; export class CatalogItem { diff --git a/e2e-tests/playwright/support/pages/catalog.ts b/e2e-tests/playwright/support/pages/catalog.ts index 7bfdab8fd5..910d93b8bd 100644 --- a/e2e-tests/playwright/support/pages/catalog.ts +++ b/e2e-tests/playwright/support/pages/catalog.ts @@ -1,4 +1,5 @@ import { Locator, Page } from "@playwright/test"; + import playwrightConfig from "../../../playwright.config"; import { UIhelper } from "../../utils/ui-helper"; @@ -11,7 +12,7 @@ export class Catalog { constructor(page: Page) { this.page = page; this.uiHelper = new UIhelper(page); - this.searchField = page.locator("#input-with-icon-adornment"); + this.searchField = page.getByRole("searchbox").first(); } async go() { @@ -30,10 +31,9 @@ export class Catalog { async search(s: string) { await this.searchField.clear(); + const baseURL = playwrightConfig.use?.baseURL ?? ""; const searchResponse = this.page.waitForResponse( - new RegExp( - `${playwrightConfig.use.baseURL}/api/catalog/entities/by-query/*`, - ), + new RegExp(`${baseURL}/api/catalog/entities/by-query/*`), ); await this.searchField.fill(s); await searchResponse; diff --git a/e2e-tests/playwright/support/pages/home-page.ts b/e2e-tests/playwright/support/pages/home-page.ts index 40a7703a9d..51ca08b8d6 100644 --- a/e2e-tests/playwright/support/pages/home-page.ts +++ b/e2e-tests/playwright/support/pages/home-page.ts @@ -1,10 +1,9 @@ -import { - HOME_PAGE_COMPONENTS, - SEARCH_OBJECTS_COMPONENTS, -} from "../page-objects/page-obj"; -import { UIhelper } from "../../utils/ui-helper"; import { Page, expect } from "@playwright/test"; +import { UIhelper } from "../../utils/ui-helper"; +/* oxlint-disable playwright/no-raw-locators -- MUI home page layout selectors */ +import { HOME_PAGE_COMPONENTS, SEARCH_OBJECTS_COMPONENTS } from "../page-objects/page-obj"; + export class HomePage { private page: Page; private uiHelper: UIhelper; @@ -14,20 +13,14 @@ export class HomePage { this.uiHelper = new UIhelper(page); } async verifyQuickSearchBar(text: string) { - const searchBar = this.page.locator( - SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch, - ); + const searchBar = this.page.locator(SEARCH_OBJECTS_COMPONENTS.ariaLabelSearch); await searchBar.waitFor(); await searchBar.fill(""); await searchBar.type(text + "\n"); // '\n' simulates pressing the Enter key await this.uiHelper.verifyLink(text); } - async verifyQuickAccess( - section: string, - items: string | string[], - expand = false, - ) { + async verifyQuickAccess(section: string, items: string | string[], expand = false) { await this.page.waitForSelector(HOME_PAGE_COMPONENTS.MuiAccordion, { state: "visible", }); @@ -38,7 +31,7 @@ export class HomePage { if (expand) { await sectionLocator.click(); - await this.page.waitForTimeout(500); + await expect(sectionLocator.locator('[class*="MuiAccordionDetails-root"]')).toBeVisible(); } for (const item of Array.isArray(items) ? items : [items]) { diff --git a/e2e-tests/playwright/support/pages/rbac.ts b/e2e-tests/playwright/support/pages/rbac.ts index 3ffd4246fb..5f5a7cdf50 100644 --- a/e2e-tests/playwright/support/pages/rbac.ts +++ b/e2e-tests/playwright/support/pages/rbac.ts @@ -1,4 +1,5 @@ import { APIResponse, Page, expect } from "@playwright/test"; + import { UIhelper } from "../../utils/ui-helper"; import { Policy, Role } from "../api/rbac-api-structures"; @@ -27,19 +28,14 @@ export class Roles { } static getPermissionPoliciesListCellsIdentifier() { - const policies = - /^(?:(Read|Create|Update|Delete)(?:, (?:Read|Create|Update|Delete))*|Use)$/; + const policies = /^(?:(Read|Create|Update|Delete)(?:, (?:Read|Create|Update|Delete))*|Use)$/; return [policies]; } //Depending on the version of the Backstage, it can be 'Permission Policies' or 'Accessible Plugins' // Accepts either term static getRolesListColumnsText() { - return [ - /^Name$/, - /^Users and groups$/, - /Permission Policies|Accessible plugins/, - ]; + return [/^Name$/, /^Users and groups$/, /Permission Policies|Accessible plugins/]; } static getUsersAndGroupsListColumnsText() { @@ -51,41 +47,30 @@ export class Roles { } } -export class Response { - static async removeMetadataFromResponse( - response: APIResponse, - ): Promise { - try { - const responseJson = await response.json(); - - // Validate that the response is an array - if (!Array.isArray(responseJson)) { - console.warn( - `Expected an array but received: ${JSON.stringify(responseJson)}`, - ); - return []; // Return an empty array as a fallback - } - - // Clean metadata from the response - const responseClean = responseJson.map((item: { metadata: unknown }) => { - if (item.metadata) { - delete item.metadata; - } - return item; - }); +export async function removeMetadataFromResponse(response: APIResponse): Promise { + try { + const responseJson: unknown = await response.json(); - return responseClean; - } catch (error) { - console.error("Error processing API response:", error); - throw new Error("Failed to process the API response"); + if (!Array.isArray(responseJson)) { + console.warn(`Expected an array but received: ${JSON.stringify(responseJson)}`); + return []; } - } - static async checkResponse( - response: APIResponse, - expected: Role[] | Policy[], - ) { - const cleanResponse = await this.removeMetadataFromResponse(response); - expect(cleanResponse).toEqual(expected); + 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/workflows.ts b/e2e-tests/playwright/support/pages/workflows.ts new file mode 100644 index 0000000000..269f1cfd82 --- /dev/null +++ b/e2e-tests/playwright/support/pages/workflows.ts @@ -0,0 +1,9 @@ +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/semantic-selectors.ts b/e2e-tests/playwright/support/selectors/semantic-selectors.ts index 06ec283e7d..113c6e6963 100644 --- a/e2e-tests/playwright/support/selectors/semantic-selectors.ts +++ b/e2e-tests/playwright/support/selectors/semantic-selectors.ts @@ -1,3 +1,4 @@ +/* oxlint-disable typescript/no-extraneous-class -- grouped semantic locator helpers */ import { Page, Locator } from "@playwright/test"; /** @@ -66,9 +67,7 @@ export class SemanticSelectors { * await expect(SemanticSelectors.tableCell(page, 'Active')).toBeVisible(); */ static tableCell(page: Page, text?: string | RegExp): Locator { - return text - ? page.getByRole("cell", { name: text }) - : page.getByRole("cell"); + return text ? page.getByRole("cell", { name: text }) : page.getByRole("cell"); } /** @@ -110,11 +109,7 @@ export class SemanticSelectors { * await expect(SemanticSelectors.heading(page, 'RBAC', 1)).toBeVisible(); * await expect(SemanticSelectors.heading(page, /settings/i)).toBeVisible(); */ - static heading( - page: Page, - name: string | RegExp, - level?: 1 | 2 | 3 | 4 | 5 | 6, - ): Locator { + static heading(page: Page, name: string | RegExp, level?: 1 | 2 | 3 | 4 | 5 | 6): Locator { return page.getByRole("heading", { name, level }); } @@ -195,9 +190,7 @@ export class SemanticSelectors { * await nav.getByRole('link', { name: 'Home' }).click(); */ static navigation(page: Page, name?: string | RegExp): Locator { - return name - ? page.getByRole("navigation", { name }) - : page.getByRole("navigation"); + return name ? page.getByRole("navigation", { name }) : page.getByRole("navigation"); } /** @@ -386,9 +379,7 @@ export class SemanticSelectors { | "listitem", name?: string | RegExp, ): Locator { - return name - ? container.getByRole(role, { name }) - : container.getByRole(role); + return name ? container.getByRole(role, { name }) : container.getByRole(role); } } @@ -403,11 +394,7 @@ export class SemanticSelectors { * const createdAtCell = findTableCell(page, 'timestamp-test', 7); * await expect(createdAtCell).toHaveText(/\d{1,2}\/\d{1,2}\/\d{4}/); */ -export function findTableCell( - page: Page, - rowText: string | RegExp, - cellIndex: number, -): Locator { +export function findTableCell(page: Page, rowText: string | RegExp, cellIndex: number): Locator { const row = SemanticSelectors.tableRow(page, rowText); return row.getByRole("cell").nth(cellIndex); } @@ -429,9 +416,7 @@ export async function findTableCellByColumn( columnName: string | RegExp, ): Promise { const header = SemanticSelectors.tableHeader(page, columnName); - const columnIndex = await header.evaluate( - (th: HTMLTableCellElement) => th.cellIndex, - ); + const columnIndex = await header.evaluate((th: HTMLTableCellElement) => th.cellIndex); return findTableCell(page, rowText, columnIndex); } @@ -467,9 +452,7 @@ export class WaitStrategies { await page.waitForResponse((response) => { const url = response.url(); const matchesUrl = - typeof urlPattern === "string" - ? url.includes(urlPattern) - : urlPattern.test(url); + typeof urlPattern === "string" ? url.includes(urlPattern) : urlPattern.test(url); return matchesUrl && response.status() === statusCode; }); } diff --git a/e2e-tests/playwright/utils/accessibility.ts b/e2e-tests/playwright/utils/accessibility.ts index 3230454dcd..0388f20c03 100644 --- a/e2e-tests/playwright/utils/accessibility.ts +++ b/e2e-tests/playwright/utils/accessibility.ts @@ -7,6 +7,7 @@ export async function runAccessibilityTests( attachName = "accessibility-scan-results.violations.json", ) { // 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; }) diff --git a/e2e-tests/playwright/utils/analytics/analytics.ts b/e2e-tests/playwright/utils/analytics/analytics.ts index 11a368cb95..7ce11d4c7a 100644 --- a/e2e-tests/playwright/utils/analytics/analytics.ts +++ b/e2e-tests/playwright/utils/analytics/analytics.ts @@ -5,18 +5,28 @@ export class Analytics { const context = await request.newContext(); const loadedPluginsEndpoint = "/api/dynamic-plugins-info/loaded-plugins"; - let plugins; + let plugins: { name: string }[] | undefined; await expect(async () => { const response = await context.get(loadedPluginsEndpoint, { headers: authHeader, }); expect(response.status()).toBe(200); - plugins = await response.json(); + const body: unknown = await response.json(); + if (!Array.isArray(body)) { + throw new Error("Expected loaded plugins response to be an array"); + } + plugins = body.filter( + (item): item is { name: string } => + typeof item === "object" && + item !== null && + "name" in item && + typeof Reflect.get(item, "name") === "string", + ); }).toPass({ intervals: [1_000], timeout: 10_000, }); - return plugins; + return plugins ?? []; } checkPluginListed(plugins: { name: string }[], expected: string) { diff --git a/e2e-tests/playwright/utils/api-endpoints.ts b/e2e-tests/playwright/utils/api-endpoints.ts index 9555508b74..7cd59863d9 100644 --- a/e2e-tests/playwright/utils/api-endpoints.ts +++ b/e2e-tests/playwright/utils/api-endpoints.ts @@ -1,8 +1,7 @@ const baseApiUrl = "https://api.github.com"; const perPage = 100; -const getRepoUrl = (owner: string, repo: string) => - `${baseApiUrl}/repos/${owner}/${repo}`; +const getRepoUrl = (owner: string, repo: string) => `${baseApiUrl}/repos/${owner}/${repo}`; const getOrgUrl = (owner: string) => `${baseApiUrl}/orgs/${owner}`; export const GITHUB_API_ENDPOINTS = { @@ -21,6 +20,5 @@ export const GITHUB_API_ENDPOINTS = { pull_files: (owner: string, repoName: string, pr: number) => `${getRepoUrl(owner, repoName)}/pulls/${pr}/files`, - contents: (owner: string, repoName: string) => - `${getRepoUrl(owner, repoName)}/contents`, + contents: (owner: string, repoName: string) => `${getRepoUrl(owner, repoName)}/contents`, }; diff --git a/e2e-tests/playwright/utils/api-helper.ts b/e2e-tests/playwright/utils/api-helper.ts index 0febb9007c..393997a25d 100644 --- a/e2e-tests/playwright/utils/api-helper.ts +++ b/e2e-tests/playwright/utils/api-helper.ts @@ -1,5 +1,6 @@ -import { request, APIResponse, expect } from "@playwright/test"; import { GroupEntity, UserEntity } from "@backstage/catalog-model"; +import { request, APIResponse, expect } from "@playwright/test"; + import { GITHUB_API_ENDPOINTS } from "./api-endpoints"; type FetchOptions = { @@ -12,10 +13,88 @@ type FetchOptions = { data?: string | object; }; +interface GitHubPullRequestFile { + filename: string; + raw_url: string; +} + +interface GuestTokenResponse { + backstageIdentity: { + token: string; + }; +} + +interface EntityMetadataResponse { + metadata?: { + uid?: string; + }; +} + +interface CatalogLocationEntry { + data?: { + target?: string; + id?: string; + }; +} + +function isGitHubPullRequestFile(value: unknown): value is GitHubPullRequestFile { + return ( + typeof value === "object" && + value !== null && + "filename" in value && + typeof value.filename === "string" && + "raw_url" in value && + typeof value.raw_url === "string" + ); +} + +function isGuestTokenResponse(value: unknown): value is GuestTokenResponse { + return ( + typeof value === "object" && + value !== null && + "backstageIdentity" in value && + typeof value.backstageIdentity === "object" && + value.backstageIdentity !== null && + "token" in value.backstageIdentity && + typeof value.backstageIdentity.token === "string" + ); +} + +function isEntityMetadataResponse(value: unknown): value is EntityMetadataResponse { + return typeof value === "object" && value !== null; +} + +function isCatalogLocationEntry(value: unknown): value is CatalogLocationEntry { + return typeof value === "object" && value !== null; +} + +function isUserEntity(value: unknown): value is UserEntity { + return isEntityMetadataResponse(value) && "kind" in value && value.kind === "User"; +} + +function isGroupEntity(value: unknown): value is GroupEntity { + return isEntityMetadataResponse(value) && "kind" in value && value.kind === "Group"; +} + +async function parseJsonResponse(response: APIResponse): Promise { + return response.json(); +} + +function toUnknownArray(value: unknown): unknown[] { + if (!Array.isArray(value)) { + throw new TypeError(`Expected array but got ${typeof value}: ${JSON.stringify(value)}`); + } + const items: unknown[] = []; + for (const item of value) { + items.push(item); + } + return items; +} + export class APIHelper { private static githubAPIVersion = "2022-11-28"; - private staticToken: string; - private baseUrl: string; + private staticToken = ""; + private baseUrl = ""; useStaticToken = false; static async githubRequest( @@ -44,35 +123,26 @@ export class APIHelper { static async getGithubPaginatedRequest( url: string, pageNo = 1, - response = [], - ) { + response: unknown[] = [], + ): Promise { const fullUrl = `${url}&page=${pageNo}`; const result = await this.githubRequest("GET", fullUrl); - const body = await result.json(); - - if (!Array.isArray(body)) { - throw new Error( - `Expected array but got ${typeof body}: ${JSON.stringify(body)}`, - ); - } + const body: unknown = await result.json(); + const pageItems = toUnknownArray(body); - if (body.length === 0) { + if (pageItems.length === 0) { return response; } - response = [...response, ...body]; + response = response.concat(pageItems); return await this.getGithubPaginatedRequest(url, pageNo + 1, response); } static async createGitHubRepo(owner: string, repoName: string) { - const response = await APIHelper.githubRequest( - "POST", - GITHUB_API_ENDPOINTS.createRepo(owner), - { - name: repoName, - private: false, - }, - ); + const response = await APIHelper.githubRequest("POST", GITHUB_API_ENDPOINTS.createRepo(owner), { + name: repoName, + private: false, + }); expect(response.status() === 201 || response.ok()).toBeTruthy(); } @@ -117,9 +187,9 @@ export class APIHelper { } static async initCommit(owner: string, repo: string, branch = "main") { - const content = Buffer.from( - "This is the initial commit for the repository.", - ).toString("base64"); + const content = Buffer.from("This is the initial commit for the repository.").toString( + "base64", + ); const response = await APIHelper.githubRequest( "PUT", `${GITHUB_API_ENDPOINTS.contents(owner, repo)}/initial-commit.md`, @@ -133,21 +203,11 @@ export class APIHelper { } static async deleteGitHubRepo(owner: string, repoName: string) { - await APIHelper.githubRequest( - "DELETE", - GITHUB_API_ENDPOINTS.deleteRepo(owner, repoName), - ); + await APIHelper.githubRequest("DELETE", GITHUB_API_ENDPOINTS.deleteRepo(owner, repoName)); } - static async mergeGitHubPR( - owner: string, - repoName: string, - pullNumber: number, - ) { - await APIHelper.githubRequest( - "PUT", - GITHUB_API_ENDPOINTS.mergePR(owner, repoName, pullNumber), - ); + static async mergeGitHubPR(owner: string, repoName: string, pullNumber: number) { + await APIHelper.githubRequest("PUT", GITHUB_API_ENDPOINTS.mergePR(owner, repoName, pullNumber)); } static async getGitHubPRs( @@ -158,10 +218,10 @@ export class APIHelper { ) { const url = GITHUB_API_ENDPOINTS.pull(owner, repoName, state); if (paginated) { - return await APIHelper.getGithubPaginatedRequest(url); + return APIHelper.getGithubPaginatedRequest(url); } const response = await APIHelper.githubRequest("GET", url); - return response.json(); + return parseJsonResponse(response); } static async getfileContentFromPR( @@ -174,12 +234,20 @@ export class APIHelper { "GET", GITHUB_API_ENDPOINTS.pull_files(owner, repoName, pr), ); - const fileRawUrl = (await response.json()).find( - (file: { filename: string }) => file.filename === filename, - ).raw_url; - const rawFileContent = await ( - await APIHelper.githubRequest("GET", fileRawUrl) - ).text(); + const files: unknown = await parseJsonResponse(response); + if (!Array.isArray(files)) { + throw new TypeError( + `Expected PR files array but got ${typeof files}: ${JSON.stringify(files)}`, + ); + } + const file = files.find( + (entry): entry is GitHubPullRequestFile => + isGitHubPullRequestFile(entry) && entry.filename === filename, + ); + if (!file) { + throw new Error(`File ${filename} not found in PR ${pr}`); + } + const rawFileContent = await (await APIHelper.githubRequest("GET", file.raw_url)).text(); return rawFileContent; } @@ -187,7 +255,10 @@ export class APIHelper { const context = await request.newContext(); const response = await context.post("/api/auth/guest/refresh"); expect(response.status()).toBe(200); - const data = await response.json(); + const data: unknown = await parseJsonResponse(response); + if (!isGuestTokenResponse(data)) { + throw new Error("Guest token not found in response body"); + } return data.backstageIdentity.token; } @@ -215,128 +286,102 @@ export class APIHelper { body?: string | object, ): Promise { const context = await request.newContext(); - const options = { + const options: { + method: string; + headers: { + Accept: string; + Authorization: string; + }; + data?: string | object; + } = { method: method, headers: { Accept: "application/json", - Authorization: `${staticToken}`, + Authorization: staticToken, }, }; if (body) { - options["data"] = body; + options.data = body; } const response = await context.fetch(url, options); return response; } - async getAllCatalogUsersFromAPI() { + async getAllCatalogUsersFromAPI(): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Duser`; const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - token, - ); - return response.json(); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, token); + return parseJsonResponse(response); } - async getAllCatalogLocationsFromAPI() { + async getAllCatalogLocationsFromAPI(): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dlocation`; const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - token, - ); - return response.json(); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, token); + return parseJsonResponse(response); } - async getAllCatalogGroupsFromAPI() { + async getAllCatalogGroupsFromAPI(): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=kind%3Dgroup`; const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - token, - ); - return response.json(); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, token); + return parseJsonResponse(response); } - async getGroupEntityFromAPI(group: string) { + async getGroupEntityFromAPI(group: string): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - token, - ); - return response.json(); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, token); + return parseJsonResponse(response); } - async getCatalogUserFromAPI(user: string) { + async getCatalogUserFromAPI(user: string): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-name/user/default/${user}`; const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - token, - ); - return response.json(); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, token); + const body: unknown = await parseJsonResponse(response); + if (!isUserEntity(body)) { + throw new TypeError(`Invalid catalog user response for ${user}`); + } + return body; } - async deleteUserEntityFromAPI(user: string) { - const r: UserEntity = await this.getCatalogUserFromAPI(user); - if (!r.metadata || !r.metadata.uid) { - return; + async deleteUserEntityFromAPI(user: string): Promise { + const r = await this.getCatalogUserFromAPI(user); + if (!r.metadata?.uid) { + return undefined; } const url = `${this.baseUrl}/api/catalog/entities/by-uid/${r.metadata.uid}`; const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.APIRequestWithStaticToken( - "DELETE", - url, - token, - ); - return response.statusText; + const response = await APIHelper.APIRequestWithStaticToken("DELETE", url, token); + return response.statusText(); } - async getCatalogGroupFromAPI(group: string) { + async getCatalogGroupFromAPI(group: string): Promise { const url = `${this.baseUrl}/api/catalog/entities/by-name/group/default/${group}`; const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.APIRequestWithStaticToken( - "GET", - url, - token, - ); - return response.json(); + const response = await APIHelper.APIRequestWithStaticToken("GET", url, token); + const body: unknown = await parseJsonResponse(response); + if (!isGroupEntity(body)) { + throw new TypeError(`Invalid catalog group response for ${group}`); + } + return body; } - async deleteGroupEntityFromAPI(group: string) { - const r: GroupEntity = await this.getCatalogGroupFromAPI(group); + async deleteGroupEntityFromAPI(group: string): Promise { + const r = await this.getCatalogGroupFromAPI(group); const url = `${this.baseUrl}/api/catalog/entities/by-uid/${r.metadata.uid}`; const token = this.useStaticToken ? this.staticToken : ""; - const response = await APIHelper.APIRequestWithStaticToken( - "DELETE", - url, - token, - ); - return response.statusText; + const response = await APIHelper.APIRequestWithStaticToken("DELETE", url, token); + return response.statusText(); } - async scheduleEntityRefreshFromAPI( - entity: string, - kind: string, - token: string, - ) { + async scheduleEntityRefreshFromAPI(entity: string, kind: string, token: string) { const url = `${this.baseUrl}/api/catalog/refresh`; const reqBody = { entityRef: `${kind}:default/${entity}` }; - const responseRefresh = await APIHelper.APIRequestWithStaticToken( - "POST", - url, - token, - reqBody, - ); + const responseRefresh = await APIHelper.APIRequestWithStaticToken("POST", url, token, reqBody); return responseRefresh.status(); } @@ -354,8 +399,11 @@ export class APIHelper { if (response.status() !== 200) { return undefined; } - const data = await response.json(); - return data?.metadata?.uid; + const data: unknown = await parseJsonResponse(response); + if (!isEntityMetadataResponse(data)) { + return undefined; + } + return data.metadata?.uid; } /** @@ -388,8 +436,11 @@ export class APIHelper { const context = await request.newContext(); const response = await context.get(url); if (response.status() === 200) { - const data = await response.json(); - return data?.metadata?.uid; + const data: unknown = await parseJsonResponse(response); + if (!isEntityMetadataResponse(data)) { + return undefined; + } + return data.metadata?.uid; } if (response.status() === 404) { return undefined; @@ -439,9 +490,7 @@ export class APIHelper { * @param target - The target URL of the location to search for. * @returns The ID string if found, otherwise undefined. */ - static async getLocationIdByTarget( - target: string, - ): Promise { + static async getLocationIdByTarget(target: string): Promise { const baseUrl = process.env.BASE_URL; const url = `${baseUrl}/api/catalog/locations`; const context = await request.newContext(); @@ -449,10 +498,13 @@ export class APIHelper { if (response.status() !== 200) { return undefined; } - const data = await response.json(); - // data is expected to be an array of objects with a 'data' property - const location = (Array.isArray(data) ? data : []).find( - (entry) => entry?.data?.target === target, + const data: unknown = await parseJsonResponse(response); + if (!Array.isArray(data)) { + return undefined; + } + const location = data.find( + (entry): entry is CatalogLocationEntry => + isCatalogLocationEntry(entry) && entry.data?.target === target, ); return location?.data?.id; } diff --git a/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts b/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts index ce83fb3cc8..33e377e784 100644 --- a/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/gitlab-helper.ts @@ -18,6 +18,19 @@ interface GitLabOAuthAppResponse { scopes?: string[]; } +function isGitLabOAuthAppResponse(value: unknown): value is GitLabOAuthAppResponse { + return ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "number" && + "application_id" in value && + typeof value.application_id === "string" && + "secret" in value && + typeof value.secret === "string" + ); +} + interface GitLabConfig { host: string; personalAccessToken: string; @@ -72,22 +85,17 @@ export class GitLabHelper { ); } - const app = await response.json(); + const app: unknown = await response.json(); // Validate required fields - if (!app.id || !app.application_id || !app.secret) { - // Log response without sensitive data - const safeApp = { ...app }; - if (safeApp.secret) safeApp.secret = "***"; - console.error("[GITLAB] Unexpected API response structure:", safeApp); + if (!isGitLabOAuthAppResponse(app)) { + console.error("[GITLAB] Unexpected API response structure:", app); throw new Error( "GitLab API response missing required fields (id, application_id, or secret)", ); } - console.log( - `[GITLAB] OAuth application created successfully with ID: ${app.id}`, - ); + console.log(`[GITLAB] OAuth application created successfully with ID: ${app.id}`); console.log( `[GITLAB] Application ID: ${app.application_id}, Secret: ${app.secret ? "***" : "not provided"}`, ); @@ -98,8 +106,7 @@ export class GitLabHelper { application_name: app.application_name || app.name || name, secret: app.secret, callback_url: app.callback_url || app.redirect_uri || redirectUri, - scopes: - app.scopes || (typeof scopes === "string" ? scopes.split(" ") : []), + scopes: app.scopes || (typeof scopes === "string" ? scopes.split(" ") : []), }; } catch (error) { console.error("[GITLAB] Failed to create OAuth application:", error); @@ -114,15 +121,12 @@ export class GitLabHelper { async deleteOAuthApplication(applicationId: number): Promise { try { console.log(`[GITLAB] Deleting OAuth application: ${applicationId}`); - const response = await fetch( - `${this.apiBaseUrl}/applications/${applicationId}`, - { - method: "DELETE", - headers: { - "PRIVATE-TOKEN": this.config.personalAccessToken, - }, + const response = await fetch(`${this.apiBaseUrl}/applications/${applicationId}`, { + method: "DELETE", + headers: { + "PRIVATE-TOKEN": this.config.personalAccessToken, }, - ); + }); if (!response.ok) { // 404 is acceptable if the app was already deleted @@ -138,14 +142,9 @@ export class GitLabHelper { ); } - console.log( - `[GITLAB] OAuth application ${applicationId} deleted successfully`, - ); + console.log(`[GITLAB] OAuth application ${applicationId} deleted successfully`); } catch (error) { - console.error( - `[GITLAB] Failed to delete OAuth application ${applicationId}:`, - error, - ); + console.error(`[GITLAB] Failed to delete OAuth application ${applicationId}:`, error); throw error; } } @@ -171,14 +170,18 @@ export class GitLabHelper { ); } - const apps = (await response.json()) as GitLabOAuthAppResponse[]; - console.log(`[GITLAB] Found ${apps.length} OAuth applications`); - return apps.map((app: GitLabOAuthAppResponse) => ({ + const apps: unknown = await response.json(); + if (!Array.isArray(apps)) { + throw new TypeError("Expected array of OAuth applications"); + } + const validatedApps = apps.filter(isGitLabOAuthAppResponse); + console.log(`[GITLAB] Found ${validatedApps.length} OAuth applications`); + return validatedApps.map((app) => ({ id: app.id, application_id: app.application_id, - application_name: app.application_name, + application_name: app.application_name ?? app.name ?? "", secret: app.secret, - callback_url: app.callback_url, + callback_url: app.callback_url ?? app.redirect_uri ?? "", scopes: app.scopes || [], })); } catch (error) { diff --git a/e2e-tests/playwright/utils/authentication-providers/keycloak-helper.ts b/e2e-tests/playwright/utils/authentication-providers/keycloak-helper.ts index 98ffbd3f3f..b432b524b7 100644 --- a/e2e-tests/playwright/utils/authentication-providers/keycloak-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/keycloak-helper.ts @@ -1,6 +1,7 @@ import KcAdminClient from "@keycloak/keycloak-admin-client"; -import UserRepresentation from "@keycloak/keycloak-admin-client/lib/defs/userRepresentation"; -import GroupRepresentation from "@keycloak/keycloak-admin-client/lib/defs/groupRepresentation"; + +type UserRepresentation = NonNullable[0]>; +type GroupRepresentation = NonNullable[0]>; interface KeycloakConfig { baseUrl: string; @@ -31,8 +32,8 @@ export class KeycloakHelper { // Refresh token every 58 minutes setInterval( - async () => { - await this.kcAdminClient.auth({ + () => { + void this.kcAdminClient.auth({ clientId: this.config.clientId, clientSecret: this.config.clientSecret, grantType: "client_credentials", @@ -83,15 +84,11 @@ export class KeycloakHelper { } } - async findUserByUsername( - username: string, - ): Promise { + async findUserByUsername(username: string): Promise { try { console.log(`[KEYCLOAK] Finding user by username: ${username}`); const users = await this.kcAdminClient.users.find({ username }); - console.log( - `[KEYCLOAK] Found ${users.length} users with username: ${username}`, - ); + console.log(`[KEYCLOAK] Found ${users.length} users with username: ${username}`); return users[0]; } catch (error) { console.error(`[KEYCLOAK] Failed to find user ${username}:`, error); @@ -112,10 +109,7 @@ export class KeycloakHelper { } } - async updateGroup( - groupId: string, - group: GroupRepresentation, - ): Promise { + async updateGroup(groupId: string, group: GroupRepresentation): Promise { try { console.log(`[KEYCLOAK] Updating group: ${groupId}`); await this.kcAdminClient.groups.update({ id: groupId }, group); @@ -142,14 +136,9 @@ export class KeycloakHelper { try { console.log(`[KEYCLOAK] Adding user ${userId} to group ${groupId}`); await this.kcAdminClient.users.addToGroup({ id: userId, groupId }); - console.log( - `[KEYCLOAK] User ${userId} added to group ${groupId} successfully`, - ); + console.log(`[KEYCLOAK] User ${userId} added to group ${groupId} successfully`); } catch (error) { - console.error( - `[KEYCLOAK] Failed to add user ${userId} to group ${groupId}:`, - error, - ); + console.error(`[KEYCLOAK] Failed to add user ${userId} to group ${groupId}:`, error); throw error; } } @@ -158,14 +147,9 @@ export class KeycloakHelper { try { console.log(`[KEYCLOAK] Removing user ${userId} from group ${groupId}`); await this.kcAdminClient.users.delFromGroup({ id: userId, groupId }); - console.log( - `[KEYCLOAK] User ${userId} removed from group ${groupId} successfully`, - ); + console.log(`[KEYCLOAK] User ${userId} removed from group ${groupId} successfully`); } catch (error) { - console.error( - `[KEYCLOAK] Failed to remove user ${userId} from group ${groupId}:`, - error, - ); + console.error(`[KEYCLOAK] Failed to remove user ${userId} from group ${groupId}:`, error); throw error; } } @@ -180,25 +164,20 @@ export class KeycloakHelper { } const sessions = await this.kcAdminClient.users.listSessions({ - id: user.id, + id: user.id!, }); - console.log( - `[KEYCLOAK] Found ${sessions.length} sessions for user ${username}`, - ); + console.log(`[KEYCLOAK] Found ${sessions.length} sessions for user ${username}`); for (const session of sessions) { await this.kcAdminClient.realms.removeSession({ realm: this.config.realmName, - sessionId: session.id, + sessionId: session.id!, }); } console.log(`[KEYCLOAK] All sessions cleared for user ${username}`); } catch (error) { - console.error( - `[KEYCLOAK] Failed to clear sessions for user ${username}:`, - error, - ); + console.error(`[KEYCLOAK] Failed to clear sessions for user ${username}:`, error); throw error; } } diff --git a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts index 318a7ec163..6315d34379 100644 --- a/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts +++ b/e2e-tests/playwright/utils/authentication-providers/msgraph-helper.ts @@ -1,13 +1,39 @@ +// oxlint-disable-next-line import/no-unassigned-import -- fetch polyfill required by Graph SDK import "isomorphic-fetch"; -import { ClientSecretCredential } from "@azure/identity"; -import { Client, PageCollection } from "@microsoft/microsoft-graph-client"; -import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js"; -import { User, Group } from "@microsoft/microsoft-graph-types"; import { NetworkManagementClient, NetworkSecurityGroupsGetResponse, SecurityRulesGetResponse, + type SecurityRule, } from "@azure/arm-network"; +import { ClientSecretCredential } from "@azure/identity"; +import { Client, PageCollection } from "@microsoft/microsoft-graph-client"; +import { TokenCredentialAuthenticationProvider } from "@microsoft/microsoft-graph-client/authProviders/azureTokenCredentials/index.js"; +import { User, Group } from "@microsoft/microsoft-graph-types"; + +import { getErrorMessage, hasStatusCode } from "../errors"; + +interface AzureApplicationWeb { + redirectUris?: string[]; +} + +interface AzureApplicationResponse { + web?: AzureApplicationWeb; +} + +interface IpifyResponse { + ip: string; +} + +function isAzureApplicationResponse(value: unknown): value is AzureApplicationResponse { + return typeof value === "object" && value !== null; +} + +function isIpifyResponse(value: unknown): value is IpifyResponse { + return ( + typeof value === "object" && value !== null && "ip" in value && typeof value.ip === "string" + ); +} export class MSClient { private clientSecretCredential: ClientSecretCredential | undefined; @@ -18,12 +44,7 @@ export class MSClient { private readonly clientSecret: string; private readonly subscriptionId?: string; - constructor( - clientId: string, - clientSecret: string, - tenantId: string, - subscriptionId?: string, - ) { + constructor(clientId: string, clientSecret: string, tenantId: string, subscriptionId?: string) { if (!clientId || !tenantId || !clientSecret) { console.error("Missing required credentials"); throw new Error("Client ID, Tenant ID, and Client Secret are required"); @@ -45,12 +66,9 @@ export class MSClient { } if (!this.appClient) { - const authProvider = new TokenCredentialAuthenticationProvider( - this.clientSecretCredential, - { - scopes: ["https://graph.microsoft.com/.default"], - }, - ); + const authProvider = new TokenCredentialAuthenticationProvider(this.clientSecretCredential, { + scopes: ["https://graph.microsoft.com/.default"], + }); this.appClient = Client.initWithMiddleware({ authProvider: authProvider, @@ -87,6 +105,32 @@ export class MSClient { } } + private getAppClient(): Client { + this.ensureInitialized(); + if (!this.appClient) { + throw new Error("Graph client not initialized"); + } + return this.appClient; + } + + /** Graph SDK requests return untyped data; narrow at call sites. */ + private async graphGet(request: (client: Client) => Promise): Promise { + const result: unknown = await request(this.getAppClient()); + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Graph SDK has no typed responses + return result as T; + } + + /** Graph SDK mutations return untyped data; narrow at call sites. */ + private async graphMutate(request: (client: Client) => Promise): Promise { + const result: unknown = await request(this.getAppClient()); + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Graph SDK has no typed responses + return result as T; + } + + private async graphDelete(request: (client: Client) => Promise): Promise { + await request(this.getAppClient()); + } + private ensureArmInitialized(): void { if (!this.armNetworkClient) { this.initializeArmNetworkClient(); @@ -106,28 +150,23 @@ export class MSClient { } async getGroupsAsync(): Promise { - this.ensureInitialized(); try { - return this.appClient - ?.api("/groups") - .select(["id", "displayName", "members", "owners"]) - .get(); + return await this.graphGet((client) => + client.api("/groups").select(["id", "displayName", "members", "owners"]).get(), + ); } catch (e) { console.error("Failed to get groups:", e); throw e; } } - async getGroupByNameAsync(groupName: string): Promise { - this.ensureInitialized(); + async getGroupByNameAsync(groupName: string): Promise { try { - return await this.appClient - ?.api("/groups") - .filter(`displayName eq '${groupName}'`) - .top(1) - .get(); + return await this.graphGet((client) => + client.api("/groups").filter(`displayName eq '${groupName}'`).top(1).get(), + ); } catch (e) { - if (e?.statusCode === 404) { + if (hasStatusCode(e) && e.statusCode === 404) { console.log(`Group ${groupName} not found`); return null; } @@ -137,19 +176,13 @@ export class MSClient { } async getGroupMembersAsync(groupId: string): Promise { - this.ensureInitialized(); try { - return this.appClient - ?.api(`/groups/${groupId}/members`) - .select([ - "displayName", - "id", - "mail", - "userPrincipalName", - "surname", - "firstname", - ]) - .get(); + return await this.graphGet((client) => + client + .api(`/groups/${groupId}/members`) + .select(["displayName", "id", "mail", "userPrincipalName", "surname", "firstname"]) + .get(), + ); } catch (e) { console.error("Failed to get group members:", e); throw e; @@ -157,10 +190,9 @@ export class MSClient { } async createUserAsync(user: User): Promise { - this.ensureInitialized(); try { console.log(`Creating user ${user.userPrincipalName}`); - return await this.appClient?.api("/users").post(user); + return await this.graphMutate((client) => client.api("/users").post(user)); } catch (e) { console.error("Failed to create user:", e); throw e; @@ -168,10 +200,9 @@ export class MSClient { } async createGroupAsync(group: Group): Promise { - this.ensureInitialized(); try { console.log(`Creating group ${group.displayName}`); - return await this.appClient?.api("/groups").post(group); + return await this.graphMutate((client) => client.api("/groups").post(group)); } catch (e) { console.error("Failed to create group:", e); throw e; @@ -179,43 +210,35 @@ export class MSClient { } async getUsersAsync(): Promise { - this.ensureInitialized(); try { - return this.appClient - ?.api("/users") - .select([ - "displayName", - "id", - "mail", - "userPrincipalName", - "surname", - "firstname", - ]) - .top(25) - .orderby("userPrincipalName") - .get(); + return await this.graphGet((client) => + client + .api("/users") + .select(["displayName", "id", "mail", "userPrincipalName", "surname", "firstname"]) + .top(25) + .orderby("userPrincipalName") + .get(), + ); } catch (e) { console.error("Failed to get users:", e); throw e; } } - async deleteUserByUpnAsync(upn: string): Promise { - this.ensureInitialized(); + async deleteUserByUpnAsync(upn: string): Promise { try { console.log(`Deleting user ${upn}`); - return this.appClient?.api("/users/" + upn).delete(); + await this.graphDelete((client) => client.api("/users/" + upn).delete()); } catch (e) { console.error("Failed to delete user:", e); throw e; } } - async deleteGroupByIdAsync(id: string): Promise { - this.ensureInitialized(); + async deleteGroupByIdAsync(id: string): Promise { try { console.log(`Deleting group ${id}`); - return this.appClient?.api("/groups/" + id).delete(); + await this.graphDelete((client) => client.api("/groups/" + id).delete()); } catch (e) { console.error("Failed to delete group:", e); throw e; @@ -223,11 +246,10 @@ export class MSClient { } async getUserByUpnAsync(upn: string): Promise { - this.ensureInitialized(); try { - return await this.appClient?.api("/users/" + upn).get(); + return await this.graphGet((client) => client.api("/users/" + upn).get()); } catch (e) { - if (e?.statusCode === 404) { + if (hasStatusCode(e) && e.statusCode === 404) { console.log(`User ${upn} not found`); return null; } @@ -236,52 +258,42 @@ export class MSClient { } } - async addUserToGroupAsync(user: User, group: Group): Promise { - this.ensureInitialized(); + async addUserToGroupAsync(user: User, group: Group): Promise { const userDirectoryObject = { - "@odata.id": - "https://graph.microsoft.com/v1.0/users/" + user.userPrincipalName, + "@odata.id": "https://graph.microsoft.com/v1.0/users/" + user.userPrincipalName, }; try { - console.log( - `Adding user ${user.userPrincipalName} to group ${group.displayName}`, + console.log(`Adding user ${user.userPrincipalName} to group ${group.displayName}`); + await this.graphMutate((client) => + client.api("/groups/" + group.id + "/members/$ref").post(userDirectoryObject), ); - return await this.appClient - ?.api("/groups/" + group.id + "/members/$ref") - .post(userDirectoryObject); } catch (e) { console.error("Failed to add user to group:", e); throw e; } } - async removeUserFromGroupAsync(user: User, group: Group): Promise { - this.ensureInitialized(); + async removeUserFromGroupAsync(user: User, group: Group): Promise { try { - console.log( - `Removing user ${user.userPrincipalName} from group ${group.displayName}`, + console.log(`Removing user ${user.userPrincipalName} from group ${group.displayName}`); + await this.graphDelete((client) => + client.api(`/groups/${group.id}/members/${user.id}/$ref`).delete(), ); - return await this.appClient - ?.api(`/groups/${group.id}/members/${user.id}/$ref`) - .delete(); } catch (e) { console.error("Failed to remove user from group:", e); throw e; } } - async addGroupToGroupAsync(subject: Group, target: Group): Promise { - this.ensureInitialized(); + async addGroupToGroupAsync(subject: Group, target: Group): Promise { const userDirectoryObject = { "@odata.id": "https://graph.microsoft.com/v1.0/groups/" + subject.id, }; try { - console.log( - `Adding group ${subject.displayName} to group ${target.displayName}`, + console.log(`Adding group ${subject.displayName} to group ${target.displayName}`); + await this.graphMutate((client) => + client.api("/groups/" + target.id + "/members/$ref").post(userDirectoryObject), ); - return await this.appClient - ?.api("/groups/" + target.id + "/members/$ref") - .post(userDirectoryObject); } catch (e) { console.error("Failed to add group to group:", e); throw e; @@ -289,12 +301,11 @@ export class MSClient { } async updateUserAsync(user: User, updatedUser: User): Promise { - this.ensureInitialized(); try { console.log(`Updating user ${user.userPrincipalName}`); - return await this.appClient - ?.api("/users/" + user.userPrincipalName) - .update(updatedUser); + return await this.graphMutate((client) => + client.api("/users/" + user.userPrincipalName).update(updatedUser), + ); } catch (e) { console.error("Failed to update user:", e); throw e; @@ -302,12 +313,11 @@ export class MSClient { } async updateGroupAsync(group: Group, updatedGroup: Group): Promise { - this.ensureInitialized(); try { console.log(`Updating group ${group.displayName}`); - return await this.appClient - ?.api("/groups/" + group.id) - .update(updatedGroup); + return await this.graphMutate((client) => + client.api("/groups/" + group.id).update(updatedGroup), + ); } catch (e) { console.error("Failed to update group:", e); throw e; @@ -315,13 +325,15 @@ export class MSClient { } async getAppRedirectUrlsAsync(): Promise { - this.ensureInitialized(); try { console.log(`[AZURE] Getting redirect URLs for app: ${this.clientId}`); - const app = await this.appClient - ?.api(`/applications(appId='{${this.clientId}}')`) - .get(); - const redirectUrls = app?.web?.redirectUris || []; + const app = await this.graphGet((client) => + client.api(`/applications(appId='{${this.clientId}}')`).get(), + ); + if (!isAzureApplicationResponse(app)) { + return []; + } + const redirectUrls = app.web?.redirectUris ?? []; console.log(`[AZURE] Found ${redirectUrls.length} redirect URLs`); return redirectUrls; } catch (e) { @@ -331,24 +343,19 @@ export class MSClient { } async addAppRedirectUrlsAsync(redirectUrls: string[]): Promise { - this.ensureInitialized(); try { - console.log( - `[AZURE] Adding ${redirectUrls.length} redirect URLs to app: ${this.clientId}`, - ); + console.log(`[AZURE] Adding ${redirectUrls.length} redirect URLs to app: ${this.clientId}`); const currentUrls = await this.getAppRedirectUrlsAsync(); const newUrls = [...new Set([...currentUrls, ...redirectUrls])]; - console.log( - `[AZURE] Updating app with ${newUrls.length} total redirect URLs`, - ); - await this.appClient - ?.api(`/applications(appId='{${this.clientId}}')`) - .update({ + console.log(`[AZURE] Updating app with ${newUrls.length} total redirect URLs`); + await this.graphMutate((client) => + client.api(`/applications(appId='{${this.clientId}}')`).update({ web: { redirectUris: newUrls, }, - }); + }), + ); console.log(`[AZURE] Successfully added redirect URLs to app`); } catch (e) { console.error("[AZURE] Failed to add app redirect URLs:", e); @@ -357,7 +364,6 @@ export class MSClient { } async removeAppRedirectUrlsAsync(redirectUrls: string[]): Promise { - this.ensureInitialized(); try { console.log( `[AZURE] Removing ${redirectUrls.length} redirect URLs from app: ${this.clientId}`, @@ -365,16 +371,14 @@ export class MSClient { const currentUrls = await this.getAppRedirectUrlsAsync(); const newUrls = currentUrls.filter((url) => !redirectUrls.includes(url)); - console.log( - `[AZURE] Updating app with ${newUrls.length} remaining redirect URLs`, - ); - await this.appClient - ?.api(`/applications(appId='{${this.clientId}}')`) - .update({ + console.log(`[AZURE] Updating app with ${newUrls.length} remaining redirect URLs`); + await this.graphMutate((client) => + client.api(`/applications(appId='{${this.clientId}}')`).update({ web: { redirectUris: newUrls, }, - }); + }), + ); console.log(`[AZURE] Successfully removed redirect URLs from app`); } catch (e) { console.error("[AZURE] Failed to remove app redirect URLs:", e); @@ -383,18 +387,17 @@ export class MSClient { } async updateAppRedirectUrlsAsync(redirectUrls: string[]): Promise { - this.ensureInitialized(); try { console.log( `[AZURE] Updating redirect URLs for app: ${this.clientId} with ${redirectUrls.length} URLs`, ); - await this.appClient - ?.api(`/applications(appId='{${this.clientId}}')`) - .update({ + await this.graphMutate((client) => + client.api(`/applications(appId='{${this.clientId}}')`).update({ web: { redirectUris: redirectUrls, }, - }); + }), + ); console.log(`[AZURE] Successfully updated redirect URLs for app`); } catch (e) { console.error("[AZURE] Failed to update app redirect URLs:", e); @@ -417,16 +420,15 @@ export class MSClient { `Getting network security group rule ${ruleName} from NSG ${nsgName} in resource group ${resourceGroupName}`, ); - return await this.armNetworkClient?.securityRules.get( + const rule = await this.armNetworkClient?.securityRules.get( resourceGroupName, nsgName, ruleName, ); + return rule ?? null; } catch (e) { - if (e?.statusCode === 404) { - console.log( - `Network security group rule ${ruleName} not found in NSG ${nsgName}`, - ); + if (hasStatusCode(e) && e.statusCode === 404) { + console.log(`Network security group rule ${ruleName} not found in NSG ${nsgName}`); return null; } console.error("Failed to get network security group rule:", e); @@ -440,12 +442,13 @@ export class MSClient { const response = await fetch("https://api.ipify.org?format=json"); if (!response.ok) { - throw new Error( - `Failed to fetch public IP: ${response.status} ${response.statusText}`, - ); + throw new Error(`Failed to fetch public IP: ${response.status} ${response.statusText}`); } - const data = await response.json(); + const data: unknown = await response.json(); + if (!isIpifyResponse(data)) { + throw new Error("Invalid ipify response: missing ip field"); + } const publicIp = data.ip; console.log(`Public IP address: ${publicIp}`); @@ -466,10 +469,14 @@ export class MSClient { `Getting network security group ${nsgName} from resource group ${resourceGroupName}`, ); - return await this.armNetworkClient?.networkSecurityGroups.get( + const nsg = await this.armNetworkClient?.networkSecurityGroups.get( resourceGroupName, nsgName, ); + if (!nsg) { + throw new Error(`Network security group ${nsgName} not found in ${resourceGroupName}`); + } + return nsg; } catch (e) { console.error("Failed to get network security group:", e); throw e; @@ -497,18 +504,13 @@ export class MSClient { // Step 2: Generate unique rule name const timestamp = Date.now(); - const randomSuffix = Math.random().toString(36).substring(2, 8); + const randomSuffix = Math.random().toString(36).slice(2, 8); const ruleName = `${baseRuleName}-${timestamp}-${randomSuffix}`; console.log(`[NSG] Generated unique rule name: ${ruleName}`); // Step 3: Verify NSG exists - console.log( - `[NSG] Verifying NSG exists: ${nsgName} in resource group: ${resourceGroupName}`, - ); - const nsg = await this.getNetworkSecurityGroupAsync( - resourceGroupName, - nsgName, - ); + console.log(`[NSG] Verifying NSG exists: ${nsgName} in resource group: ${resourceGroupName}`); + const nsg = await this.getNetworkSecurityGroupAsync(resourceGroupName, nsgName); console.log(`[NSG] NSG verified: ${nsg.name} (ID: ${nsg.id})`); // Step 4: Get existing rule to use as template @@ -520,9 +522,7 @@ export class MSClient { ); if (!templateRule) { - throw new Error( - `Template rule ${baseRuleName} not found in NSG ${nsgName}`, - ); + throw new Error(`Template rule ${baseRuleName} not found in NSG ${nsgName}`); } console.log( `[NSG] Template rule found: ${templateRule.name} (Priority: ${templateRule.priority})`, @@ -530,10 +530,7 @@ export class MSClient { // Step 5: Create new rule with wildcard IP (*) // Find an available priority to avoid conflicts - const existingRules = this.armNetworkClient?.securityRules.list( - resourceGroupName, - nsgName, - ); + const existingRules = this.armNetworkClient?.securityRules.list(resourceGroupName, nsgName); const existingPriorities = new Set(); if (existingRules) { @@ -552,12 +549,15 @@ export class MSClient { `[NSG] Template rule priority: ${templateRule.priority}, Using available priority: ${availablePriority}`, ); - const newRule = { - ...templateRule, - name: ruleName, + const newRule: SecurityRule = { + protocol: templateRule.protocol, + sourcePortRange: templateRule.sourcePortRange, + destinationPortRange: templateRule.destinationPortRange, + sourceAddressPrefix: "*", + destinationAddressPrefix: templateRule.destinationAddressPrefix, + access: templateRule.access, priority: availablePriority, - sourceAddressPrefix: "*", // Allow all IPs instead of specific public IP - sourceAddressPrefixes: null, // Use single IP instead of array + direction: templateRule.direction, description: `Temporary E2E test rule allowing all IPs - Created at ${new Date().toISOString()}`, }; @@ -566,13 +566,15 @@ export class MSClient { `[NSG] Rule details: Priority=${newRule.priority}, Protocol=${newRule.protocol}, Access=${newRule.access}`, ); - const rulePoller = - await this.armNetworkClient?.securityRules.beginCreateOrUpdate( - resourceGroupName, - nsgName, - ruleName, - newRule, - ); + if (!this.armNetworkClient) { + throw new Error("ARM network client not initialized"); + } + const rulePoller = await this.armNetworkClient.securityRules.beginCreateOrUpdate( + resourceGroupName, + nsgName, + ruleName, + newRule, + ); console.log(`[NSG] Waiting for rule creation to complete...`); const createdRule = await rulePoller.pollUntilDone(); @@ -599,21 +601,22 @@ export class MSClient { } console.log(`[NSG] Deleting rule: ${ruleName}`); - const deletePoller = - await this.armNetworkClient?.securityRules.beginDelete( - resourceGroupName, - nsgName, - ruleName, - ); + if (!this.armNetworkClient) { + throw new Error("ARM network client not initialized"); + } + const deletePoller = await this.armNetworkClient.securityRules.beginDelete( + resourceGroupName, + nsgName, + ruleName, + ); console.log(`[NSG] Waiting for rule deletion to complete...`); await deletePoller.pollUntilDone(); console.log(`[NSG] Rule deleted successfully: ${ruleName}`); } catch (error) { console.error(`[NSG] Failed to cleanup rule ${ruleName}:`, error); console.error(`[NSG] Cleanup error details:`, { - message: error.message, - statusCode: error.statusCode, - code: error.code, + message: getErrorMessage(error), + statusCode: hasStatusCode(error) ? error.statusCode : undefined, }); // Don't throw - cleanup failures shouldn't break tests } @@ -629,10 +632,8 @@ export class MSClient { } catch (error) { console.error(`[NSG] Failed to allow public IP in NSG:`, error); console.error(`[NSG] Error details:`, { - message: error.message, - statusCode: error.statusCode, - code: error.code, - body: error.body, + message: getErrorMessage(error), + statusCode: hasStatusCode(error) ? error.statusCode : undefined, }); throw error; } diff --git a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts index 77a192ed25..2416cb501f 100644 --- a/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts +++ b/e2e-tests/playwright/utils/authentication-providers/rhdh-deployment.ts @@ -1,40 +1,126 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import * as k8s from "@kubernetes/client-node"; -import * as yaml from "yaml"; +import { ChildProcess, spawn } from "child_process"; import { promises as fs } from "fs"; -import { fileURLToPath } from "url"; -import { dirname, join, resolve } from "path"; +import { join, resolve as resolvePath } from "path"; import stream from "stream"; + +import { GroupEntity, UserEntity } from "@backstage/catalog-model"; +import * as k8s from "@kubernetes/client-node"; import { expect } from "@playwright/test"; -import { ChildProcess, spawn } from "child_process"; import { v4 as uuidv4 } from "uuid"; +import * as yaml from "yaml"; + import { APIHelper } from "../api-helper"; -import { GroupEntity, UserEntity } from "@backstage/catalog-model"; +import { getErrorMessage, hasErrorResponse } from "../errors"; + +type YamlConfig = Record; + +interface DynamicPluginConfig { + package: string; + disabled?: boolean; +} + +type DynamicPluginsConfig = Record & { + plugins: DynamicPluginConfig[]; +}; + +interface BackstageCrSpec { + replicas?: number; + deployment?: unknown; +} + +interface BackstageCr { + apiVersion: string; + kind: string; + metadata: { name: string }; + spec: BackstageCrSpec; +} + +function sleep(ms: number): Promise { + return new Promise((resolvePromise) => { + setTimeout(() => { + resolvePromise(); + }, ms); + }); +} + +function isRecord(value: unknown): value is YamlConfig { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isBackstageCr(value: unknown): value is BackstageCr { + return ( + isRecord(value) && + typeof value.apiVersion === "string" && + typeof value.kind === "string" && + isRecord(value.metadata) && + typeof value.metadata.name === "string" && + isRecord(value.spec) + ); +} + +function isDynamicPluginsConfig(value: unknown): value is DynamicPluginsConfig { + if (!isRecord(value)) { + return false; + } + const { plugins } = value; + return ( + plugins === undefined || + (Array.isArray(plugins) && + plugins.every((plugin) => isRecord(plugin) && typeof plugin.package === "string")) + ); +} -const currentFileName = fileURLToPath(import.meta.url); -const currentDirName = dirname(currentFileName); -const rootDirName = resolve(currentDirName, "..", "..", "..", ".."); +function isUserEntity(value: unknown): value is UserEntity { + return isRecord(value) && value.kind === "User"; +} + +function isGroupEntity(value: unknown): value is GroupEntity { + return isRecord(value) && value.kind === "Group"; +} + +function getCatalogUsers(response: unknown): UserEntity[] { + if (!isRecord(response) || !Array.isArray(response.items)) { + return []; + } + return response.items.filter(isUserEntity); +} + +function getCatalogGroups(response: unknown): GroupEntity[] { + if (!isRecord(response) || !Array.isArray(response.items)) { + return []; + } + return response.items.filter(isGroupEntity); +} + +const currentDirName = import.meta.dirname; +const rootDirName = resolvePath(currentDirName, "..", "..", "..", ".."); const syncedLogRegex = /(Committed \d+ (Keycloak|msgraph|GitHub|LDAP|GitLab) users? and \d+ (Keycloak|msgraph|GitHub|LDAP|GitLab) groups? in \d+(\.\d+)? seconds|Scanned \d+ users? and processed \d+ users?)/; class RHDHDeployment { - instanceName: string; - private kc: k8s.KubeConfig; - private k8sApi: k8s.CoreV1Api; - private appsV1Api: k8s.AppsV1Api; + instanceName!: string; + private kc!: k8s.KubeConfig; + private k8sApi!: k8s.CoreV1Api; + private appsV1Api!: k8s.AppsV1Api; private namespace: string; private appConfigMap: string; private rbacConfigMap: string; private dynamicPluginsConfigMap: string; private secretName: string; - private appConfig: any = {}; - private dynamicPluginsConfig: any = {}; + private appConfig: YamlConfig = {}; + private dynamicPluginsConfig: DynamicPluginsConfig = { plugins: [] }; private rbacConfig: string = ""; - private secretData: any = {}; + private secretData: Record = {}; private isRunningLocal: boolean = false; private runningProcess: ChildProcess | null = null; private staticToken: string = ""; - private cr: any = {}; + private cr: BackstageCr = { + apiVersion: "", + kind: "", + metadata: { name: "" }, + spec: {}, + }; + private configReconcileBaselineGeneration: number | undefined; constructor( namespace: string, @@ -100,16 +186,14 @@ class RHDHDeployment { await this.k8sApi.createNamespace(namespaceObj); return this; } catch (e) { - if (e.response?.statusCode === 409) { + if (hasErrorResponse(e) && e.response?.statusCode === 409) { return this; } throw e; } } - async deleteNamespaceIfExists( - timeoutMs: number = 60000, - ): Promise { + async deleteNamespaceIfExists(timeoutMs: number = 60000): Promise { // Skip namespace deletion if running locally if (this.isRunningLocal) { console.log("Skipping namespace deletion as isRunningLocal is true."); @@ -123,43 +207,55 @@ class RHDHDeployment { while (Date.now() - startTime < timeoutMs) { try { await this.k8sApi.readNamespace(this.namespace); - await new Promise((resolve) => setTimeout(resolve, 1000)); + await sleep(1000); } catch (error) { - if (error.response?.statusCode === 404) { + if (hasErrorResponse(error) && error.response?.statusCode === 404) { return this; } throw error; } } - throw new Error( - `Timeout waiting for namespace to be deleted after ${timeoutMs}ms`, - ); + throw new Error(`Timeout waiting for namespace to be deleted after ${timeoutMs}ms`); } catch (e) { - if (e.response?.statusCode === 404) { + if (hasErrorResponse(e) && e.response?.statusCode === 404) { return this; } throw e; } } - setConfigProperty(config: any, path: string, value: unknown): RHDHDeployment { + setConfigProperty(config: Record, path: string, value: unknown): RHDHDeployment { const parts = path.split("."); - let current = config; + let current: Record = config; for (let i = 0; i < parts.length - 1; i++) { const part = parts[i]; + if (part === undefined) { + throw new Error(`Invalid config path: ${path}`); + } if (!(part in current)) { current[part] = {}; } - current = current[part]; + if (!isRecord(current[part])) { + current[part] = {}; + } + const next = current[part]; + if (!isRecord(next)) { + throw new Error(`Invalid config path: ${path}`); + } + current = next; } - current[parts[parts.length - 1]] = value; + const lastPart = parts.at(-1); + if (lastPart === undefined) { + throw new Error(`Invalid config path: ${path}`); + } + current[lastPart] = value; return this; } - getConfig(config: any): any { + getConfig>(config: T): T { return config; } @@ -167,34 +263,31 @@ class RHDHDeployment { return this.setConfigProperty(this.appConfig, path, value); } - getAppConfig(): any { + getAppConfig(): YamlConfig { return this.getConfig(this.appConfig); } - setDynamicPluginsConfigProperty( - path: string, - value: unknown, - ): RHDHDeployment { + setDynamicPluginsConfigProperty(path: string, value: unknown): RHDHDeployment { return this.setConfigProperty(this.dynamicPluginsConfig, path, value); } - getDynamicPluginsConfig(): any { - return this.getConfig(this.dynamicPluginsConfig); + getDynamicPluginsConfig(): DynamicPluginsConfig { + return this.dynamicPluginsConfig; } async loadBaseConfig(): Promise { const configPath = join(currentDirName, "yamls", "configmap.yaml"); const yamlContent = await fs.readFile(configPath, "utf8"); - const configData = yaml.parse(yamlContent); + const configData: unknown = yaml.parse(yamlContent); - if (configData) { + if (isRecord(configData)) { this.appConfig = configData; } return this; } - async applyCustomResource(resource: any): Promise { + async applyCustomResource(resource: BackstageCr): Promise { console.log("Applying CR."); try { const customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); @@ -212,12 +305,12 @@ class RHDHDeployment { } } - async readYamlToJson(filePath: string): Promise { + async readYamlToJson(filePath: string): Promise { const fileContent = await fs.readFile(filePath, "utf8"); return yaml.parse(fileContent); } - async createConfigMap(name: string, data: any): Promise { + async createConfigMap(name: string, data: Record): Promise { const configMap: k8s.V1ConfigMap = { apiVersion: "v1", kind: "ConfigMap", @@ -231,7 +324,7 @@ class RHDHDeployment { return this; } - async updateConfigMap(name: string, data: any): Promise { + async updateConfigMap(name: string, data: Record): Promise { if (this.isRunningLocal) { console.log("Skipping configmap update as isRunningLocal is true."); return this; @@ -292,10 +385,7 @@ class RHDHDeployment { } async deleteConfigMap(): Promise { - await this.k8sApi.deleteNamespacedConfigMap( - this.appConfigMap, - this.namespace, - ); + await this.k8sApi.deleteNamespacedConfigMap(this.appConfigMap, this.namespace); return this; } @@ -331,11 +421,7 @@ class RHDHDeployment { }, data: this.secretData, }; - await this.k8sApi.replaceNamespacedSecret( - this.secretName, - this.namespace, - secret, - ); + await this.k8sApi.replaceNamespacedSecret(this.secretName, this.namespace, secret); return this; } @@ -348,9 +434,56 @@ class RHDHDeployment { return this; } - async waitForDeploymentReady( - timeoutMs: number = 600000, - ): Promise { + private async getDeploymentGeneration(): Promise { + const labels = { + "app.kubernetes.io/name": "backstage", + "app.kubernetes.io/instance": this.instanceName, + }; + const labelSelector = Object.entries(labels) + .map(([key, value]) => `${key}=${value}`) + .join(","); + + const deployments = await this.appsV1Api.listNamespacedDeployment( + this.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].metadata?.generation ?? 0; + } + + async waitForConfigReconciled(timeoutMs: number = 60000): Promise { + if (this.isRunningLocal) { + return this; + } + + const baseline = + this.configReconcileBaselineGeneration ?? (await this.getDeploymentGeneration()); + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const currentGeneration = await this.getDeploymentGeneration(); + if (currentGeneration > baseline) { + console.log( + `[INFO] Config reconciled - deployment generation ${baseline} -> ${currentGeneration}`, + ); + return this; + } + await sleep(1000); + } + + console.log(`[INFO] No deployment generation change after ${timeoutMs}ms, proceeding`); + return this; + } + + async waitForDeploymentReady(timeoutMs: number = 600000): Promise { if (this.isRunningLocal) { console.log("Skipping deployment ready check as isRunningLocal is true."); return this; @@ -390,17 +523,14 @@ class RHDHDeployment { // Capture initial generation on first check if (initialGeneration === undefined) { initialGeneration = deployment.metadata?.generation || 0; - console.log( - `[INFO] Initial deployment generation: ${initialGeneration}`, - ); + console.log(`[INFO] Initial deployment generation: ${initialGeneration}`); } // Check if rollout has started (generation changed or progressing condition indicates rollout) const currentGeneration = deployment.metadata?.generation || 0; const observedGeneration = deployment.status?.observedGeneration || 0; const isProgressing = conditions.some( - (condition) => - condition.type === "Progressing" && condition.status === "True", + (condition) => condition.type === "Progressing" && condition.status === "True", ); // Rollout has started if: @@ -427,7 +557,7 @@ class RHDHDeployment { console.log( `[INFO] Waiting for rollout to start... (${Math.round(elapsedSinceStart / 1000)}s elapsed)`, ); - await new Promise((resolve) => setTimeout(resolve, 2000)); // Check every 2 seconds + await sleep(2000); // Check every 2 seconds continue; } else { // If no rollout detected but deployment is ready, assume no restart was needed @@ -439,8 +569,7 @@ class RHDHDeployment { } const isAvailable = conditions.some( - (condition) => - condition.type === "Available" && condition.status === "True", + (condition) => condition.type === "Available" && condition.status === "True", ); const isProgressingWithRollout = conditions.some( @@ -450,10 +579,8 @@ class RHDHDeployment { condition.reason !== "NewReplicaSetAvailable", ); - const replicas = deployment.spec.replicas; - const desiredReplicas = this.cr.spec.replicas - ? this.cr.spec.replicas - : 1; + const replicas = deployment.spec?.replicas; + const desiredReplicas = this.cr.spec.replicas ? this.cr.spec.replicas : 1; // Check replica counts to ensure rollout has completed const availableReplicas = deployment.status?.availableReplicas || 0; @@ -476,7 +603,7 @@ class RHDHDeployment { replicasMatch && observedGeneration >= currentGeneration ) { - await new Promise((resolve) => setTimeout(resolve, 5000)); + await sleep(5000); return this; } else if (isProgressingWithRollout || !replicasMatch) { console.log( @@ -484,25 +611,21 @@ class RHDHDeployment { ); } - await new Promise((resolve) => setTimeout(resolve, 5000)); + await sleep(5000); } catch (error) { if (Date.now() - startTime >= timeoutMs) { - throw new Error( - `Timeout waiting for deployment to be ready: ${error.message}`, - ); + throw new Error(`Timeout waiting for deployment to be ready: ${getErrorMessage(error)}`, { + cause: error, + }); } - await new Promise((resolve) => setTimeout(resolve, 5000)); + await sleep(5000); } } - throw new Error( - `Timeout waiting for deployment to be ready after ${timeoutMs}ms`, - ); + throw new Error(`Timeout waiting for deployment to be ready after ${timeoutMs}ms`); } - async waitForNamespaceActive( - timeoutMs: number = 30000, - ): Promise { + async waitForNamespaceActive(timeoutMs: number = 30000): Promise { const startTime = Date.now(); if (this.isRunningLocal) { console.log("Skipping namespace active check as isRunningLocal is true."); @@ -518,20 +641,18 @@ class RHDHDeployment { return this; } - await new Promise((resolve) => setTimeout(resolve, 1000)); + await sleep(1000); } catch (error) { if (Date.now() - startTime >= timeoutMs) { - throw new Error( - `Timeout waiting for namespace to be active: ${error.message}`, - ); + throw new Error(`Timeout waiting for namespace to be active: ${getErrorMessage(error)}`, { + cause: error, + }); } - await new Promise((resolve) => setTimeout(resolve, 1000)); + await sleep(1000); } } - throw new Error( - `Timeout waiting for namespace to be active after ${timeoutMs}ms`, - ); + throw new Error(`Timeout waiting for namespace to be active after ${timeoutMs}ms`); } async loadRbacConfig(): Promise { @@ -579,15 +700,11 @@ class RHDHDeployment { } async loadDynamicPluginsConfig(): Promise { - const configPath = join( - currentDirName, - "yamls", - "dynamic-plugins-config.yaml", - ); + const configPath = join(currentDirName, "yamls", "dynamic-plugins-config.yaml"); const yamlContent = await fs.readFile(configPath, "utf8"); - const configData = yaml.parse(yamlContent); + const configData: unknown = yaml.parse(yamlContent); - if (configData) { + if (isDynamicPluginsConfig(configData)) { this.dynamicPluginsConfig = configData; } @@ -596,21 +713,10 @@ class RHDHDeployment { async createDynamicPluginsConfig(): Promise { if (this.isRunningLocal) { - const dynamicPluginsConfigPath = join( - currentDirName, - "dynamic-plugins.test.yaml", - ); // Path to the local file - const dynamicPluginsConfigYaml = yaml.stringify( - this.dynamicPluginsConfig, - ); // Stringify the dynamic plugins config - await fs.writeFile( - dynamicPluginsConfigPath, - dynamicPluginsConfigYaml, - "utf8", - ); // Write the stringified YAML to the local file - console.log( - `Dynamic plugins config written to ${dynamicPluginsConfigPath}`, - ); + const dynamicPluginsConfigPath = join(currentDirName, "dynamic-plugins.test.yaml"); // Path to the local file + const dynamicPluginsConfigYaml = yaml.stringify(this.dynamicPluginsConfig); // Stringify the dynamic plugins config + await fs.writeFile(dynamicPluginsConfigPath, dynamicPluginsConfigYaml, "utf8"); // Write the stringified YAML to the local file + console.log(`Dynamic plugins config written to ${dynamicPluginsConfigPath}`); this.setAppConfigProperty( "dynamicPlugins.rootDirectory", rootDirName + "/dynamic-plugins-root", @@ -627,21 +733,10 @@ class RHDHDeployment { async updateDynamicPluginsConfig(): Promise { if (this.isRunningLocal) { - const dynamicPluginsConfigPath = join( - currentDirName, - "dynamic-plugins.test.yaml", - ); // Path to the local file - const dynamicPluginsConfigYaml = yaml.stringify( - this.dynamicPluginsConfig, - ); // Stringify the dynamic plugins config - await fs.writeFile( - dynamicPluginsConfigPath, - dynamicPluginsConfigYaml, - "utf8", - ); // Write the stringified YAML to the local file - console.log( - `Dynamic plugins config updated in ${dynamicPluginsConfigPath}`, - ); + const dynamicPluginsConfigPath = join(currentDirName, "dynamic-plugins.test.yaml"); // Path to the local file + const dynamicPluginsConfigYaml = yaml.stringify(this.dynamicPluginsConfig); // Stringify the dynamic plugins config + await fs.writeFile(dynamicPluginsConfigPath, dynamicPluginsConfigYaml, "utf8"); // Write the stringified YAML to the local file + console.log(`Dynamic plugins config updated in ${dynamicPluginsConfigPath}`); console.log( `Dynamic plugins config in ${dynamicPluginsConfigPath} has no effect on local deployment. Make sure to update the app-config.test.yaml file to use the dynamic-plugins-root directory and your plugin are already copied there.`, ); @@ -654,9 +749,13 @@ class RHDHDeployment { return this; } - async loadBackstageCR(): Promise { + async loadBackstageCR(): Promise { const configPath = join(currentDirName, "yamls", "backstage.yaml"); - const backstageConfig = await this.readYamlToJson(configPath); + const parsed: unknown = await this.readYamlToJson(configPath); + if (!isBackstageCr(parsed)) { + throw new Error("Invalid Backstage CR config"); + } + const backstageConfig = parsed; const imageRegistry = process.env.IMAGE_REGISTRY || "quay.io"; const imageRepo = process.env.IMAGE_REPO || process.env.QUAY_REPO; const tagName = process.env.TAG_NAME; @@ -682,7 +781,7 @@ class RHDHDeployment { }; console.log(`Setting Backstage CR image via deployment.patch to ${image}`); this.cr = backstageConfig; - this.instanceName = backstageConfig.metadata.name.toString(); + this.instanceName = backstageConfig.metadata.name; return backstageConfig; } @@ -704,20 +803,17 @@ class RHDHDeployment { ); return; } catch (error) { - console.log( - `Timeout waiting for Backstage CRD to be available: ${error.message}`, - ); + 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: ${error.message}`, + `Timeout waiting for Backstage CRD to be available: ${getErrorMessage(error)}`, + { cause: error }, ); } - await new Promise((resolve) => setTimeout(resolve, 5000)); + await sleep(5000); } } - throw new Error( - `Timeout waiting for Backstage CRD to be available after ${timeoutMs}ms`, - ); + throw new Error(`Timeout waiting for Backstage CRD to be available after ${timeoutMs}ms`); } async createBackstageDeployment(): Promise { @@ -736,20 +832,18 @@ class RHDHDeployment { ], { shell: true, - cwd: resolve(rootDirName), + cwd: resolvePath(rootDirName), detached: true, stdio: ["ignore", "pipe", "pipe"], env: process.env, }, ); this.runningProcess.unref(); - console.log( - `Local production server started with PID: ${this.runningProcess.pid}`, - ); + console.log(`Local production server started with PID: ${this.runningProcess.pid}`); return this; } await this.ensureBackstageCRIsAvailable(60000); - const backstageConfig: any = await this.loadBackstageCR(); + const backstageConfig = await this.loadBackstageCR(); await this.applyCustomResource(backstageConfig); await this.waitForDeploymentReady(); return this; @@ -760,17 +854,17 @@ class RHDHDeployment { } async killRunningProcess(): Promise { - if (this.runningProcess) { + if (this.runningProcess?.pid) { const killed = process.kill(-this.runningProcess.pid); console.log("Local production server process killed?", killed); // Wait for the process to actually terminate with a 5-second timeout - await new Promise((resolve) => { + await new Promise((resolvePromise) => { this.runningProcess?.on("exit", () => { setTimeout(() => { console.log("Process termination timeout reached after 5 seconds."); this.runningProcess = null; - resolve(); + resolvePromise(); }, 5000); }); }); @@ -780,9 +874,7 @@ class RHDHDeployment { try { const response = await fetch(baseUrl, { method: "HEAD" }); if (response.status === 200) { - throw new Error( - "Homepage is still accessible after process termination", - ); + throw new Error("Homepage is still accessible after process termination"); } } catch (error) { // Expected error - connection refused @@ -796,7 +888,7 @@ class RHDHDeployment { async followPodLogs( searchString: RegExp, podName?: string, - podLabels?: any, + podLabels?: Record, timeoutMs: number = 300000, ): Promise { const namespace = this.namespace; @@ -832,7 +924,9 @@ class RHDHDeployment { const pod = activePods[0]; podName = pod.metadata!.name!; } catch (error) { - throw new Error(`Error getting pod name: ${error.message}`); + throw new Error(`Error getting pod name: ${getErrorMessage(error)}`, { + cause: error, + }); } } @@ -843,22 +937,23 @@ class RHDHDeployment { const log = new k8s.Log(this.kc); const logStream = new stream.PassThrough(); - logStream.on("data", (chunk) => { - if (searchString.test(chunk.toString())) { + logStream.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString(); + if (searchString.test(text)) { process.stdout.write(chunk); found = true; } }); logStream.on("error", (error) => { - throw new Error(`Error getting pod name: ${error.message}`); + throw new Error(`Error getting pod name: ${getErrorMessage(error)}`); }); logStream.on("end", () => { console.log("Log stream ended."); }); - await log.log(namespace, podName, "backstage-backend", logStream, { + await log.log(namespace, podName!, "backstage-backend", logStream, { follow: true, tailLines: 1, pretty: false, @@ -867,8 +962,11 @@ class RHDHDeployment { // Keep the function alive to allow streaming - while (Date.now() - startTime < timeoutMs && !found) { - await new Promise((resolve) => setTimeout(resolve, 1000)); + while (Date.now() - startTime < timeoutMs) { + if (found) { + break; + } + await sleep(1000); } if (found) { logStream.end(); @@ -876,17 +974,16 @@ class RHDHDeployment { } return found; } catch (error) { - console.log(`Error: ${error.body.message}`); + const message = hasErrorResponse(error) ? error.body?.message : getErrorMessage(error); + console.log(`Error: ${message}`); throw new Error( - `Timeout waiting for string "${searchString}" in logs after ${timeoutMs}ms. Error: ${error.body.message}`, + `Timeout waiting for string "${searchString}" in logs after ${timeoutMs}ms. Error: ${message}`, + { cause: error }, ); } } - async followLocalLogs( - searchString: RegExp, - timeoutMs: number = 30000, - ): Promise { + async followLocalLogs(searchString: RegExp, timeoutMs: number = 30000): Promise { if (!this.isRunningLocal) { throw new Error("Not running in local mode. Cannot follow local logs."); } @@ -904,18 +1001,19 @@ class RHDHDeployment { // Pipe the stdout of the running process to the logStream this.runningProcess?.stdout?.pipe(logStream); - logStream.on("data", (chunk) => { + logStream.on("data", (chunk: Buffer | string) => { + const text = typeof chunk === "string" ? chunk : chunk.toString(); if (process.env.ISRUNNINGLOCAL && process.env.ISRUNNINGLOCALDEBUG) { - console.log(`\t${chunk.toString().replace(/\n/g, "\t")}`); + console.log(`\t${text.replaceAll(/\n/g, "\t")}`); } - if (searchString.test(chunk.toString())) { + if (searchString.test(text)) { console.log("Found string in local logs."); found = true; } }); logStream.on("error", (error) => { - throw new Error(`Error reading local logs: ${error.message}`); + throw new Error(`Error reading local logs: ${getErrorMessage(error)}`); }); logStream.on("end", () => { @@ -924,27 +1022,26 @@ class RHDHDeployment { // Keep the function alive to allow streaming const startTime = Date.now(); - while (Date.now() - startTime < timeoutMs && !found) { - await new Promise((resolve) => setTimeout(resolve, 1000)); + while (Date.now() - startTime < timeoutMs) { + if (found) { + break; + } + await sleep(1000); } return found; } - async followLogs( - searchString: RegExp, - timeoutMs: number = 300000, - ): Promise { + async followLogs(searchString: RegExp, timeoutMs: number = 300000): Promise { if (this.isRunningLocal) { return this.followLocalLogs(searchString, timeoutMs); - } else { - return this.followPodLogs( - searchString, - undefined, - { "rhdh.redhat.com/app": `backstage-${this.instanceName}` }, - timeoutMs, - ); } + return this.followPodLogs( + searchString, + undefined, + { "rhdh.redhat.com/app": `backstage-${this.instanceName}` }, + timeoutMs, + ); } async computeBackstageUrl(): Promise { @@ -970,7 +1067,7 @@ class RHDHDeployment { if (this.isRunningLocal) { return `http://localhost:7007`; } - return await this.computeBackstageUrl(); + return this.computeBackstageUrl(); } async loadAllConfigs(): Promise { @@ -1001,7 +1098,7 @@ class RHDHDeployment { const response = await fetch(baseUrl, { method: "HEAD" }); return response.status === 200; } catch (error: unknown) { - console.log(`Error: ${(error as Error).message}`); + console.log(`Error: ${getErrorMessage(error)}`); return false; } } @@ -1017,18 +1114,11 @@ class RHDHDeployment { // New method to enable or disable a dynamic plugin - setDynamicPluginEnabled( - pluginName: string, - enabled: boolean, - ): RHDHDeployment { - const plugin = this.dynamicPluginsConfig.plugins.find( - (p: any) => p.package == pluginName, - ); + setDynamicPluginEnabled(pluginName: string, enabled: boolean): RHDHDeployment { + const plugin = this.dynamicPluginsConfig.plugins.find((p) => p.package === pluginName); if (plugin) { plugin.disabled = !enabled; - console.log( - `Plugin ${pluginName} has been ${enabled ? "enabled" : "disabled"}.`, - ); + console.log(`Plugin ${pluginName} has been ${enabled ? "enabled" : "disabled"}.`); } else { this.dynamicPluginsConfig.plugins = [ ...this.dynamicPluginsConfig.plugins, @@ -1089,8 +1179,7 @@ class RHDHDeployment { clientId: "${RHBK_CLIENT_ID}", clientSecret: "${RHBK_CLIENT_SECRET}", prompt: "auto", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", }, }); this.setAppConfigProperty("auth.environment", "production"); @@ -1110,13 +1199,11 @@ class RHDHDeployment { // Enable the PingFederate OIDC login provider this.setAppConfigProperty("auth.providers.oidc", { production: { - metadataUrl: - "${PINGFEDERATE_BASE_URL}/.well-known/openid-configuration", + metadataUrl: "${PINGFEDERATE_BASE_URL}/.well-known/openid-configuration", clientId: "${PINGFEDERATE_CLIENT_ID}", clientSecret: "${PINGFEDERATE_CLIENT_SECRET}", prompt: "auto", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", }, }); this.setAppConfigProperty("auth.environment", "production"); @@ -1160,8 +1247,7 @@ class RHDHDeployment { { dn: "${LDAP_GROUPS_DN}", options: { - filter: - "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=2147483648))", // filter only security groups + filter: "(&(objectClass=group)(groupType:1.2.840.113556.1.4.803:=2147483648))", // filter only security groups scope: "sub", }, }, @@ -1181,8 +1267,7 @@ class RHDHDeployment { clientId: "${RHBK_LDAP_CLIENT_ID}", clientSecret: "${RHBK_LDAP_CLIENT_SECRET}", prompt: "auto", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/oidc/handler/frame", }, }); this.setAppConfigProperty("auth.environment", "production"); @@ -1235,8 +1320,7 @@ class RHDHDeployment { clientSecret: "${AUTH_PROVIDERS_AZURE_CLIENT_SECRET}", prompt: "auto", tenantId: "${AUTH_PROVIDERS_AZURE_TENANT_ID}", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/microsoft/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/microsoft/handler/frame", }, }); this.setAppConfigProperty("auth.environment", "production"); @@ -1313,8 +1397,7 @@ class RHDHDeployment { 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", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/github/handler/frame", }, }); @@ -1332,6 +1415,9 @@ class RHDHDeployment { } async updateAllConfigs(): Promise { + if (!this.isRunningLocal) { + this.configReconcileBaselineGeneration = await this.getDeploymentGeneration(); + } await this.updateAppConfig(); await this.updateDynamicPluginsConfig(); await this.updateRbacConfig(); @@ -1364,16 +1450,12 @@ class RHDHDeployment { resolver: string, dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, ): Promise { - this.setAppConfigProperty( - "auth.providers.oidc.production.signIn.resolvers", - [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: - dangerouslyAllowSignInWithoutUserInCatalog, - }, - ], - ); + this.setAppConfigProperty("auth.providers.oidc.production.signIn.resolvers", [ + { + resolver: resolver, + dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, + }, + ]); return this; } @@ -1381,16 +1463,12 @@ class RHDHDeployment { resolver: string, dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, ): Promise { - this.setAppConfigProperty( - "auth.providers.microsoft.production.signIn.resolvers", - [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: - dangerouslyAllowSignInWithoutUserInCatalog, - }, - ], - ); + this.setAppConfigProperty("auth.providers.microsoft.production.signIn.resolvers", [ + { + resolver: resolver, + dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, + }, + ]); return this; } @@ -1398,16 +1476,12 @@ class RHDHDeployment { resolver: string, dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, ): Promise { - this.setAppConfigProperty( - "auth.providers.github.production.signIn.resolvers", - [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: - dangerouslyAllowSignInWithoutUserInCatalog, - }, - ], - ); + this.setAppConfigProperty("auth.providers.github.production.signIn.resolvers", [ + { + resolver: resolver, + dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, + }, + ]); return this; } @@ -1466,8 +1540,7 @@ class RHDHDeployment { audience: "https://${AUTH_PROVIDERS_GITLAB_HOST}", clientId: "${AUTH_PROVIDERS_GITLAB_CLIENT_ID}", clientSecret: "${AUTH_PROVIDERS_GITLAB_CLIENT_SECRET}", - callbackUrl: - "${BASE_URL:-http://localhost:7007}/api/auth/gitlab/handler/frame", + callbackUrl: "${BASE_URL:-http://localhost:7007}/api/auth/gitlab/handler/frame", }, }); @@ -1481,102 +1554,80 @@ class RHDHDeployment { resolver: string, dangerouslyAllowSignInWithoutUserInCatalog: boolean = false, ): Promise { - this.setAppConfigProperty( - "auth.providers.gitlab.production.signIn.resolvers", - [ - { - resolver: resolver, - dangerouslyAllowSignInWithoutUserInCatalog: - dangerouslyAllowSignInWithoutUserInCatalog, - }, - ], - ); + this.setAppConfigProperty("auth.providers.gitlab.production.signIn.resolvers", [ + { + resolver: resolver, + dangerouslyAllowSignInWithoutUserInCatalog: dangerouslyAllowSignInWithoutUserInCatalog, + }, + ]); return this; } async waitForSynced(): Promise { const synced = await this.followLogs(syncedLogRegex, 120000); expect(synced).toBe(true); - await new Promise((resolve) => setTimeout(resolve, 2000)); + await sleep(2000); return this; } - parseGroupMemberFromEntity(group: GroupEntity) { + parseGroupMemberFromEntity(group: GroupEntity): string[] { if (!group.relations) { return []; } return group.relations - .filter((r) => { - if (r.type == "hasMember") { - return true; - } - }) + .filter((r) => r.type === "hasMember") .map((r) => r.targetRef.split("/")[1]); } - parseGroupChildrenFromEntity(group: GroupEntity) { + parseGroupChildrenFromEntity(group: GroupEntity): string[] { if (!group.relations) { return []; } return group.relations - .filter((r) => { - if (r.type == "parentOf") { - return true; - } - }) + .filter((r) => r.type === "parentOf") .map((r) => r.targetRef.split("/")[1]); } - parseGroupParentFromEntity(group: GroupEntity) { + parseGroupParentFromEntity(group: GroupEntity): string[] { if (!group.relations) { return []; } return group.relations - .filter((r) => { - if (r.type == "childOf") { - return true; - } - }) + .filter((r) => r.type === "childOf") .map((r) => r.targetRef.split("/")[1]); } - async checkUserIsIngestedInCatalog(users: string[]) { + async checkUserIsIngestedInCatalog(users: string[]): Promise { const api = new APIHelper(); await api.UseStaticToken(this.staticToken); await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const response = await api.getAllCatalogUsersFromAPI(); - const catalogUsers: UserEntity[] = - response && response.items ? response.items : []; + const response: unknown = await api.getAllCatalogUsersFromAPI(); + const catalogUsers = getCatalogUsers(response); expect(catalogUsers.length).toBeGreaterThan(0); const catalogUsersDisplayNames: string[] = catalogUsers - .filter((u) => u.spec.profile && u.spec.profile.displayName) - .map((u) => u.spec.profile.displayName); + .map((u) => u.spec.profile?.displayName) + .filter((name): name is string => name !== undefined); console.log( `Checking ${JSON.stringify(catalogUsersDisplayNames)} contains users ${JSON.stringify(users)}`, ); - const hasAllElems = users.every((elem) => - catalogUsersDisplayNames.includes(elem), - ); + const hasAllElems = users.every((elem) => catalogUsersDisplayNames.includes(elem)); return hasAllElems; } - async checkGroupIsIngestedInCatalog(groups: string[]) { + async checkGroupIsIngestedInCatalog(groups: string[]): Promise { const api = new APIHelper(); await api.UseStaticToken(this.staticToken); await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const response = await api.getAllCatalogGroupsFromAPI(); - const catalogGroups: GroupEntity[] = - response && response.items ? response.items : []; + const response: unknown = await api.getAllCatalogGroupsFromAPI(); + const catalogGroups = getCatalogGroups(response); expect(catalogGroups.length).toBeGreaterThan(0); const catalogGroupsDisplayNames: string[] = catalogGroups - .filter((u) => u.spec.profile && u.spec.profile.displayName) - .map((u) => u.spec.profile.displayName); + .map((u) => u.spec.profile?.displayName) + .filter((name): name is string => name !== undefined); console.log( `Checking ${JSON.stringify(catalogGroupsDisplayNames)} contains groups ${JSON.stringify(groups)}`, ); - const hasAllElems = groups.every((elem) => - catalogGroupsDisplayNames.includes(elem), - ); + const hasAllElems = groups.every((elem) => catalogGroupsDisplayNames.includes(elem)); return hasAllElems; } @@ -1584,38 +1635,39 @@ class RHDHDeployment { const api = new APIHelper(); await api.UseStaticToken(this.staticToken); await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const groupEntity: GroupEntity = await api.getGroupEntityFromAPI(group); - const members = this.parseGroupMemberFromEntity(groupEntity); - console.log( - `Checking group ${group} (${JSON.stringify(members)}) contains user ${user}`, - ); + const entity: unknown = await api.getGroupEntityFromAPI(group); + if (!isGroupEntity(entity)) { + throw new Error(`Invalid group entity for ${group}`); + } + const members = this.parseGroupMemberFromEntity(entity); + console.log(`Checking group ${group} (${JSON.stringify(members)}) contains user ${user}`); return members.includes(user); } - async checkGroupIsParentOfGroup( - parent: string, - child: string, - ): Promise { + async checkGroupIsParentOfGroup(parent: string, child: string): Promise { const api = new APIHelper(); await api.UseStaticToken(this.staticToken); await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const groupEntity: GroupEntity = await api.getGroupEntityFromAPI(parent); - const children = this.parseGroupChildrenFromEntity(groupEntity); + const entity: unknown = await api.getGroupEntityFromAPI(parent); + if (!isGroupEntity(entity)) { + throw new Error(`Invalid group entity for ${parent}`); + } + const children = this.parseGroupChildrenFromEntity(entity); console.log( `Checking children of ${parent} (${JSON.stringify(children)}) contain group ${child}`, ); return children.includes(child); } - async checkGroupIsChildOfGroup( - child: string, - parent: string, - ): Promise { + async checkGroupIsChildOfGroup(child: string, parent: string): Promise { const api = new APIHelper(); await api.UseStaticToken(this.staticToken); await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const groupEntity: GroupEntity = await api.getGroupEntityFromAPI(child); - const parents = this.parseGroupParentFromEntity(groupEntity); + const entity: unknown = await api.getGroupEntityFromAPI(child); + if (!isGroupEntity(entity)) { + throw new Error(`Invalid group entity for ${child}`); + } + const parents = this.parseGroupParentFromEntity(entity); console.log( `Checking parents of ${child} (${JSON.stringify(parents)}) contain group ${parent}`, ); @@ -1630,8 +1682,11 @@ class RHDHDeployment { const api = new APIHelper(); await api.UseStaticToken(this.staticToken); await api.UseBaseUrl(await this.computeBackstageBackendUrl()); - const userEntity: UserEntity = await api.getCatalogUserFromAPI(user); - const annotations = userEntity.metadata?.annotations || {}; + const entity: unknown = await api.getCatalogUserFromAPI(user); + if (!isUserEntity(entity)) { + throw new Error(`Invalid user entity for ${user}`); + } + const annotations = entity.metadata?.annotations || {}; const actualValue = annotations[annotationKey]; console.log( `Checking user ${user} has annotation ${annotationKey}=${expectedValue}, actual value: ${actualValue}`, diff --git a/e2e-tests/playwright/utils/common.ts b/e2e-tests/playwright/utils/common.ts index e251bc02cf..c987738437 100644 --- a/e2e-tests/playwright/utils/common.ts +++ b/e2e-tests/playwright/utils/common.ts @@ -1,29 +1,45 @@ -import { UIhelper } from "./ui-helper"; +import * as fs from "fs"; +import * as path from "path"; + +import { test, Browser, Cookie, expect, Page, TestInfo, Locator } from "@playwright/test"; import { authenticator } from "otplib"; -import { - test, - Browser, - expect, - Page, - TestInfo, - Locator, -} from "@playwright/test"; -import { SETTINGS_PAGE_COMPONENTS } from "../support/page-objects/page-obj"; + +import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; +import { startCoverageForPage, stopCoverageForPage } from "../support/coverage/test"; import { WAIT_OBJECTS } from "../support/page-objects/global-obj"; -import * as path from "path"; -import * as fs from "fs"; -import { - startCoverageForPage, - stopCoverageForPage, -} from "../support/coverage/test"; -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"; const t = getTranslations(); const lang = getCurrentLanguage(); +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; +} + export class Common { page: Page; uiHelper: UIhelper; @@ -44,9 +60,7 @@ export class Common { }); await this.uiHelper.verifyHeading(t["rhdh"][lang]["signIn.page.title"]); - await this.uiHelper.clickButton( - t["core-components"][lang]["signIn.guestProvider.enter"], - ); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.guestProvider.enter"]); await this.uiHelper.waitForSideBarVisible(); } @@ -70,16 +84,16 @@ export class Common { await this.page.waitForSelector("#login_field"); await this.page.fill("#login_field", userid); - switch (userid) { - case process.env.GH_USER_ID: - await this.page.fill("#password", process.env.GH_USER_PASS); - break; - case process.env.GH_USER2_ID: - await this.page.fill("#password", process.env.GH_USER2_PASS); - break; - default: - throw new Error("Invalid User ID"); + 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) { + 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)); @@ -88,19 +102,20 @@ export class Common { (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, - )) + (await this.uiHelper.isTextVisible("too many codes have been submitted", 3000)) ) { - await this.page.waitForTimeout(60000); + // 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.waitForTimeout(3_000); + 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(); @@ -111,18 +126,19 @@ export class Common { await popup.locator("#kc-login").click({ timeout: 5000 }); } catch (error) { // Popup likely closed - this is expected behavior - if (!error.message?.includes("Target closed")) { + 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, + userid: string = process.env.GH_USER_ID ?? "", + password: string = process.env.GH_USER_PASS ?? "", ) { await this.page.goto("/"); await this.waitForLoad(240000); @@ -131,31 +147,25 @@ export class Common { await this.uiHelper.waitForSideBarVisible(); } - async loginAsGithubUser(userid: string = process.env.GH_USER_ID) { + async loginAsGithubUser(userid: string = process.env.GH_USER_ID ?? "") { const sessionFileName = `authState_${userid}.json`; // Check if a session file for this specific user already exists if (fs.existsSync(sessionFileName)) { // Load and reuse existing authentication state - const cookies = JSON.parse( - fs.readFileSync(sessionFileName, "utf-8"), - ).cookies; + 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.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); await this.checkAndReauthorizeGithubApp(); } else { // Perform login if no session file exists, then save the state await this.logintoGithub(userid); await this.page.goto("/"); await this.waitForLoad(240000); - await this.uiHelper.clickButton( - t["core-components"][lang]["signIn.title"], - ); + await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); await this.checkAndReauthorizeGithubApp(); await this.uiHelper.waitForSideBarVisible(); await this.page.context().storageState({ path: sessionFileName }); @@ -164,24 +174,26 @@ export class Common { } 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(); - // Check for popup closure for up to 10 seconds before proceeding - for (let attempts = 0; attempts < 10 && !popup.isClosed(); attempts++) { - await this.page.waitForTimeout(1000); // Using page here because if the popup closes automatically, it throws an error during the wait - } + 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(() => {}); - const locator = popup.locator("button.js-oauth-authorize-btn"); - if (!popup.isClosed() && (await locator.isVisible())) { + if (!popup.isClosed() && (await authorizeButton.isVisible())) { await popup.locator("body").click(); - await locator.waitFor(); - await locator.click(); + await authorizeButton.waitFor(); + await authorizeButton.click(); } resolve(); }); }); + /* oxlint-enable playwright/no-raw-locators */ } async checkAndClickOnGHloginPopup(force = false) { @@ -202,23 +214,24 @@ export class Common { await this.uiHelper.clickButton( t["user-settings"][lang]["providerSettingsItem.buttonTitle.signIn"], ); - await this.uiHelper.clickButton( - t["core-components"][lang]["oauthRequestDialog.login"], - ); + 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.', - ); + console.log('"Log in" button is not visible. Skipping login popup actions.'); } } getGitHub2FAOTP(userid: string): string { - const secrets: { [key: string]: string | undefined } = { - [process.env.GH_USER_ID]: process.env.GH_2FA_SECRET, - [process.env.GH_USER2_ID]: process.env.GH_USER2_2FA_SECRET, - }; + const ghUserId = process.env.GH_USER_ID; + const ghUser2Id = process.env.GH_USER2_ID; + const secrets: Record = {}; + if (ghUserId) { + secrets[ghUserId] = process.env.GH_2FA_SECRET; + } + if (ghUser2Id) { + secrets[ghUser2Id] = process.env.GH_USER2_2FA_SECRET; + } const secret = secrets[userid]; if (!secret) { @@ -230,20 +243,22 @@ export class Common { getGoogle2FAOTP(): string { const secret = process.env.GOOGLE_2FA_SECRET; + if (!secret) { + throw new Error("GOOGLE_2FA_SECRET is not set"); + } return authenticator.generate(secret); } async keycloakLogin(username: string, password: string) { - let popup: Page; - this.page.once("popup", (asyncnewPage) => { - popup = asyncnewPage; - }); - await this.page.goto("/"); await this.page.waitForSelector( `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, ); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), + ]); // Wait for the popup to appear await expect(async () => { @@ -262,6 +277,7 @@ export class Common { // Popup didn't close, proceed with login } + /* oxlint-disable playwright/no-raw-locators -- Keycloak OIDC login popup (third-party) */ try { await popup.locator("#username").click(); await popup.locator("#username").fill(username); @@ -274,10 +290,10 @@ export class Common { if (await usernameError.isVisible()) { await popup.close(); return "User does not exist"; - } else { - throw e; } + throw e; } + /* oxlint-enable playwright/no-raw-locators */ } private async handleGitHubPopupLogin( @@ -302,6 +318,7 @@ export class Common { // Popup didn't close, proceed with login } + /* oxlint-disable playwright/no-raw-locators -- GitHub login popup (third-party) */ try { await popup.locator("#login_field").click({ timeout: 5000 }); await popup.locator("#login_field").fill(username, { timeout: 5000 }); @@ -326,10 +343,10 @@ export class Common { if (await authorization.isVisible()) { await authorization.click(); return "Login successful"; - } else { - throw e; } + throw e; } + /* oxlint-enable playwright/no-raw-locators */ } async githubLogin(username: string, password: string, twofactor: string) { @@ -346,11 +363,7 @@ export class Common { return this.handleGitHubPopupLogin(popup, username, password, twofactor); } - async githubLoginFromSettingsPage( - username: string, - password: string, - twofactor: string, - ) { + async githubLoginFromSettingsPage(username: string, password: string, twofactor: string) { await this.page.goto("/settings/auth-providers"); const [popup] = await Promise.all([ @@ -363,9 +376,7 @@ export class Common { ), ) .click(), - this.uiHelper.clickButton( - t["core-components"][lang]["oauthRequestDialog.login"], - ), + this.uiHelper.clickButton(t["core-components"][lang]["oauthRequestDialog.login"]), ]); return this.handleGitHubPopupLogin(popup, username, password, twofactor); @@ -392,6 +403,7 @@ export class Common { // Popup didn't close, proceed with login } + /* oxlint-disable playwright/no-raw-locators -- GitLab login popup (third-party) */ try { await popup.locator("#user_login").click({ timeout: 5000 }); await popup.locator("#user_login").fill(username, { timeout: 5000 }); @@ -400,11 +412,9 @@ export class Common { await popup.getByTestId("sign-in-button").click({ timeout: 5000 }); // Wait for navigation after sign-in (either to 2FA, authorization, or close) - await popup - .waitForLoadState("domcontentloaded", { timeout: 10000 }) - .catch(() => { - // Continue if load state check fails - }); + await popup.waitForLoadState("domcontentloaded", { timeout: 10000 }).catch(() => { + // Continue if load state check fails + }); // Handle 2FA if present const twoFactorInput = popup.locator("#user_otp_attempt"); @@ -421,21 +431,15 @@ export class Common { const authorizationByText = popup.locator('button:has-text("Authorize")'); // Wait for button to appear with retry logic - let buttonToClick: Locator | null = null; + let buttonToClick: Locator | undefined; await expect(async () => { // Check data-testid first - if ( - await authorization.isVisible({ timeout: 2000 }).catch(() => false) - ) { + if (await authorization.isVisible({ timeout: 2000 }).catch(() => false)) { buttonToClick = authorization; return true; } // Fallback to text-based selector - if ( - await authorizationByText - .isVisible({ timeout: 2000 }) - .catch(() => false) - ) { + if (await authorizationByText.isVisible({ timeout: 2000 }).catch(() => false)) { buttonToClick = authorizationByText; return true; } @@ -449,6 +453,8 @@ export class Common { throw new Error("Failed to find authorization button"); } + const authorizeButton = buttonToClick; + // Click on document/body first to potentially dismiss any overlays (similar to GitHub flow) await popup .getByRole("document") @@ -458,18 +464,16 @@ export class Common { }); // Wait for button to be enabled and clickable - await buttonToClick.waitFor({ state: "visible", timeout: 5000 }); - await expect(buttonToClick).toBeEnabled({ timeout: 10000 }); - await buttonToClick.scrollIntoViewIfNeeded({ timeout: 5000 }); - // Small delay to ensure any animations/transitions complete - await popup.waitForTimeout(1000); + await authorizeButton.waitFor({ state: "visible", timeout: 5000 }); + await expect(authorizeButton).toBeEnabled({ timeout: 10000 }); + await authorizeButton.scrollIntoViewIfNeeded({ timeout: 5000 }); try { - await buttonToClick.click({ timeout: 5000 }); + await authorizeButton.click({ timeout: 5000 }); } catch { - // If regular click fails, try force click - // eslint-disable-next-line playwright/no-force-option - await buttonToClick.click({ force: true, timeout: 5000 }); + // Force click fallback when overlay blocks the authorization button. + // oxlint-disable-next-line playwright/no-force-option -- overlay dismissal is unreliable in CI + await authorizeButton.click({ force: true, timeout: 5000 }); } await popup.waitForEvent("close", { timeout: 20000 }); @@ -482,6 +486,7 @@ export class Common { // Re-throw other errors throw e; } + /* oxlint-enable playwright/no-raw-locators */ } async gitlabLogin(username: string, password: string) { @@ -499,16 +504,15 @@ export class Common { } async MicrosoftAzureLogin(username: string, password: string) { - let popup: Page; - this.page.once("popup", (asyncnewPage) => { - popup = asyncnewPage; - }); - await this.page.goto("/"); await this.page.waitForSelector( `p:has-text("${t["rhdh"][lang]["signIn.providers.microsoft.message"]}")`, ); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), + ]); // Wait for the popup to appear await expect(async () => { @@ -527,43 +531,37 @@ export class Common { // Popup didn't close, proceed with login } + /* oxlint-disable playwright/no-raw-locators -- Microsoft Azure login popup (third-party) */ try { await popup.locator("[name=loginfmt]").click(); await popup.locator("[name=loginfmt]").fill(username, { timeout: 5000 }); - await popup - .locator('[type=submit]:has-text("Next")') - .click({ timeout: 5000 }); + await popup.locator('[type=submit]:has-text("Next")').click({ timeout: 5000 }); await popup.locator("[name=passwd]").click(); await popup.locator("[name=passwd]").fill(password, { timeout: 5000 }); - await popup - .locator('[type=submit]:has-text("Sign in")') - .click({ timeout: 5000 }); - await popup - .locator('[type=button]:has-text("No")') - .click({ timeout: 15000 }); + await popup.locator('[type=submit]:has-text("Sign in")').click({ timeout: 5000 }); + await popup.locator('[type=button]:has-text("No")').click({ timeout: 15000 }); return "Login successful"; } catch (e) { const usernameError = popup.locator("id=usernameError"); if (await usernameError.isVisible()) { return "User does not exist"; - } else { - throw e; } + throw e; } + /* oxlint-enable playwright/no-raw-locators */ } async pingFederateLogin(username: string, password: string) { - let popup: Page; - this.page.once("popup", (asyncnewPage) => { - popup = asyncnewPage; - }); - await this.page.goto("/"); await this.page.waitForSelector( `p:has-text("${t["rhdh"][lang]["signIn.providers.oidc.message"]}")`, ); - await this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]); + + const [popup] = await Promise.all([ + this.page.waitForEvent("popup"), + this.uiHelper.clickButton(t["core-components"][lang]["signIn.title"]), + ]); // Wait for the popup to appear await expect(async () => { @@ -582,8 +580,8 @@ export class Common { // Popup didn't close, proceed with login } + /* oxlint-disable playwright/no-raw-locators -- PingFederate login popup (third-party) */ try { - // Using raw locators for PingFederate login form (third-party page we don't control) // Fill in username await popup.locator("#username").click(); await popup.locator("#username").fill(username, { timeout: 5000 }); @@ -606,10 +604,10 @@ export class Common { if (await errorElement.isVisible()) { await popup.close(); return "Login failed - invalid credentials"; - } else { - throw e; } + throw e; } + /* oxlint-enable playwright/no-raw-locators */ } } @@ -641,10 +639,7 @@ export async function setupBrowser(browser: Browser, testInfo: TestInfo) { // Flush V8 JS coverage collected during the test run and close the page. // Pair with setupBrowser() in afterAll to ensure coverage data is written. -export async function teardownBrowser( - page: Page, - testInfo: TestInfo, -): Promise { +export async function teardownBrowser(page: Page, testInfo: TestInfo): Promise { await stopCoverageForPage(page, testInfo); await page.close(); } diff --git a/e2e-tests/playwright/utils/constants.ts b/e2e-tests/playwright/utils/constants.ts index cd18582063..33e8bb3a8b 100644 --- a/e2e-tests/playwright/utils/constants.ts +++ b/e2e-tests/playwright/utils/constants.ts @@ -59,14 +59,11 @@ export const IS_OPENSHIFT_VALUES = { FALSE: "false", } as const; -export type JobNamePattern = - (typeof JOB_NAME_PATTERNS)[keyof typeof JOB_NAME_PATTERNS]; +export type JobNamePattern = (typeof JOB_NAME_PATTERNS)[keyof typeof JOB_NAME_PATTERNS]; export type JobNameRegexPattern = (typeof JOB_NAME_REGEX_PATTERNS)[keyof typeof JOB_NAME_REGEX_PATTERNS]; -export type JobTypePattern = - (typeof JOB_TYPE_PATTERNS)[keyof typeof JOB_TYPE_PATTERNS]; -export type IsOpenShiftValue = - (typeof IS_OPENSHIFT_VALUES)[keyof typeof IS_OPENSHIFT_VALUES]; +export type JobTypePattern = (typeof JOB_TYPE_PATTERNS)[keyof typeof JOB_TYPE_PATTERNS]; +export type IsOpenShiftValue = (typeof IS_OPENSHIFT_VALUES)[keyof typeof IS_OPENSHIFT_VALUES]; /** * Kubernetes deployment-level label selectors for backstage. diff --git a/e2e-tests/playwright/utils/errors.ts b/e2e-tests/playwright/utils/errors.ts new file mode 100644 index 0000000000..2ef1db332d --- /dev/null +++ b/e2e-tests/playwright/utils/errors.ts @@ -0,0 +1,22 @@ +/** Safely extract a message from an unknown caught value. */ +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** Type guard for errors with an HTTP-style response (Kubernetes client, etc.). */ +export function hasErrorResponse(error: unknown): error is { + response?: { statusCode?: number }; + body?: { message?: string }; +} { + return typeof error === "object" && error !== null; +} + +/** Type guard for errors with a top-level statusCode (Microsoft Graph SDK, etc.). */ +export function hasStatusCode(error: unknown): error is { statusCode: number } { + return ( + typeof error === "object" && + error !== null && + "statusCode" in error && + typeof (error as { statusCode: unknown }).statusCode === "number" + ); +} diff --git a/e2e-tests/playwright/utils/helper.ts b/e2e-tests/playwright/utils/helper.ts index 69e646f7e3..04e7a976ca 100644 --- a/e2e-tests/playwright/utils/helper.ts +++ b/e2e-tests/playwright/utils/helper.ts @@ -1,5 +1,7 @@ -import { type Page, type Locator } from "@playwright/test"; import fs from "fs"; + +import { type Page, type Locator } from "@playwright/test"; + import { BACKSTAGE_DEPLOY_SELECTOR, type JobNamePattern, @@ -12,19 +14,15 @@ export async function downloadAndReadFile( page: Page, locator: Locator, ): Promise { - const [download] = await Promise.all([ - page.waitForEvent("download"), - locator.click(), - ]); + const [download] = await Promise.all([page.waitForEvent("download"), locator.click()]); const filePath = await download.path(); if (filePath) { return fs.readFileSync(filePath, "utf-8"); - } else { - console.error("Download failed or path is not available"); - return undefined; } + console.error("Download failed or path is not available"); + return undefined; } /** @@ -58,9 +56,7 @@ export function skipIfJobName(jobNamePattern: JobNamePattern): boolean { * * @see https://prow.ci.openshift.org/configured-jobs/redhat-developer/rhdh */ -export function skipIfJobNameRegex( - jobNameRegexPattern: JobNameRegexPattern, -): boolean { +export function skipIfJobNameRegex(jobNameRegexPattern: JobNameRegexPattern): boolean { const jobName = process.env.JOB_NAME; if (!jobName) { return false; diff --git a/e2e-tests/playwright/utils/keycloak/keycloak.ts b/e2e-tests/playwright/utils/keycloak/keycloak.ts index bdc3b50e8a..ef9cb572f6 100644 --- a/e2e-tests/playwright/utils/keycloak/keycloak.ts +++ b/e2e-tests/playwright/utils/keycloak/keycloak.ts @@ -1,13 +1,40 @@ -import fetch from "node-fetch"; -import User from "./user"; -import Group from "./group"; import { expect, Page } from "@playwright/test"; -import { UIhelper } from "../ui-helper"; +import fetch from "node-fetch"; + 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) { + throw new Error(`Missing required environment variable: ${name}`); + } + return Buffer.from(value, "base64").toString(); +} + class Keycloak { private readonly baseURL: string; private readonly realm: string; @@ -15,22 +42,10 @@ class Keycloak { private readonly clientSecret: string; constructor() { - this.baseURL = Buffer.from( - process.env.KEYCLOAK_AUTH_BASE_URL, - "base64", - ).toString(); - this.realm = Buffer.from( - process.env.KEYCLOAK_AUTH_REALM, - "base64", - ).toString(); - this.clientSecret = Buffer.from( - process.env.KEYCLOAK_AUTH_CLIENT_SECRET, - "base64", - ).toString(); - this.clientId = Buffer.from( - process.env.KEYCLOAK_AUTH_CLIENTID, - "base64", - ).toString(); + 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 { @@ -48,26 +63,30 @@ class Keycloak { ); if (response.status !== 200) throw new Error("Failed to authenticate"); - const data = (await response.json()) as AuthResponse; + 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}`, - }, + 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}`); } - return (await response.json()) as Promise; + 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 { @@ -83,11 +102,13 @@ class Keycloak { if (response.status !== 200) { const errorText = await response.text(); - throw new Error( - `Failed to get groups of user: ${response.status} - ${errorText}`, - ); + 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 (await response.json()) as Promise; + return data; } async checkUserDetails( @@ -100,9 +121,7 @@ class Keycloak { await CatalogUsersPO.visitUserPage(page, keycloakUser.username); const emailLink = CatalogUsersPO.getEmailLink(page); await expect(emailLink).toBeVisible(); - await uiHelper.verifyDivHasText( - `${keycloakUser.firstName} ${keycloakUser.lastName}`, - ); + await uiHelper.verifyDivHasText(`${keycloakUser.firstName} ${keycloakUser.lastName}`); const groups = await keycloak.getGroupsOfUser(token, keycloakUser.id); for (const group of groups) { diff --git a/e2e-tests/playwright/utils/kube-client.ts b/e2e-tests/playwright/utils/kube-client.ts index e1d145789b..adbed047e3 100644 --- a/e2e-tests/playwright/utils/kube-client.ts +++ b/e2e-tests/playwright/utils/kube-client.ts @@ -1,17 +1,13 @@ +import * as stream from "stream"; + import * as k8s from "@kubernetes/client-node"; import { V1ConfigMap } from "@kubernetes/client-node"; import * as yaml from "js-yaml"; -import * as stream from "stream"; -/** - * Interface representing the structure of Kubernetes API errors. - * Used for type-safe error handling without exposing sensitive data. - */ -interface KubeApiError { - body?: { message?: string; reason?: string; code?: number }; - statusCode?: number; - message?: string; - response?: { statusCode?: number; statusMessage?: string }; +import { getErrorMessage, hasErrorResponse, hasStatusCode } from "./errors"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } /** @@ -25,11 +21,51 @@ interface PodFailureResult { containerName?: string; } -/** - * Type guard to check if an unknown error is a KubeApiError. - */ -function isKubeApiError(error: unknown): error is KubeApiError { - return error !== null && typeof error === "object"; +function getErrorStatusCode(error: unknown): number | undefined { + if (hasErrorResponse(error) && error.response?.statusCode !== undefined) { + return error.response.statusCode; + } + if (hasStatusCode(error)) { + return error.statusCode; + } + return undefined; +} + +function getEventSortTimestamp(event: k8s.CoreV1Event): number { + if (event.firstTimestamp) { + return typeof event.firstTimestamp === "string" + ? new Date(event.firstTimestamp).getTime() + : event.firstTimestamp.getTime(); + } + if (event.eventTime) { + return typeof event.eventTime === "string" + ? new Date(event.eventTime).getTime() + : event.eventTime.getTime(); + } + return 0; +} + +function formatEventTimestamp(event: k8s.CoreV1Event): string { + if (event.firstTimestamp) { + return typeof event.firstTimestamp === "string" + ? new Date(event.firstTimestamp).toISOString() + : event.firstTimestamp.toISOString(); + } + if (event.eventTime) { + return typeof event.eventTime === "string" + ? new Date(event.eventTime).toISOString() + : event.eventTime.toISOString(); + } + return "unknown"; +} + +function formatContainerStartedAt(startedAt: Date | string | undefined): string { + if (!startedAt) { + return "unknown"; + } + return typeof startedAt === "string" + ? new Date(startedAt).toISOString() + : startedAt.toISOString(); } /** @@ -38,34 +74,36 @@ function isKubeApiError(error: unknown): error is KubeApiError { * the Authorization header with the bearer token. This function extracts only safe information. */ function getKubeApiErrorMessage(error: unknown): string { - if (isKubeApiError(error)) { - const err = error; - - // Kubernetes API errors have a body with message, reason, and code - if (err.body?.message) { - const parts = [err.body.message]; - if (err.body.reason) parts.push(`reason: ${err.body.reason}`); - if (err.body.code) parts.push(`code: ${err.body.code}`); + if (hasErrorResponse(error)) { + const body: unknown = error.body; + if (isRecord(body) && typeof body.message === "string") { + const parts = [body.message]; + if (typeof body.reason === "string") { + parts.push(`reason: ${body.reason}`); + } + if (typeof body.code === "number") { + parts.push(`code: ${String(body.code)}`); + } return parts.join(", "); } - // Fallback to statusCode and statusMessage from response - if (err.response?.statusCode) { - return `HTTP ${err.response.statusCode}: ${err.response.statusMessage || "Unknown error"}`; + const response: unknown = error.response; + if (isRecord(response) && typeof response.statusCode === "number") { + const statusMessage = + typeof response.statusMessage === "string" ? response.statusMessage : "Unknown error"; + return `HTTP ${String(response.statusCode)}: ${statusMessage}`; } + } - // Fallback to statusCode on error object - if (err.statusCode) { - return `HTTP ${err.statusCode}`; - } + if (hasStatusCode(error)) { + return `HTTP ${String(error.statusCode)}`; + } - // Fallback to error message (safe as it doesn't contain request details) - if (err.message) { - return err.message; - } + if (error instanceof Error) { + return error.message; } - return "Unknown Kubernetes API error"; + return getErrorMessage(error) || "Unknown Kubernetes API error"; } /** @@ -95,14 +133,14 @@ export class KubeClient { clusters: [ { name: "my-openshift-cluster", - server: process.env.K8S_CLUSTER_URL, + server: process.env.K8S_CLUSTER_URL ?? "", skipTLSVerify: true, }, ], users: [ { name: "ci-user", - token: process.env.K8S_CLUSTER_TOKEN, + token: process.env.K8S_CLUSTER_TOKEN ?? "", }, ], contexts: [ @@ -119,24 +157,19 @@ export class KubeClient { this.coreV1Api = this.kc.makeApiClient(k8s.CoreV1Api); this.customObjectsApi = this.kc.makeApiClient(k8s.CustomObjectsApi); } catch (e) { - console.log( - `Error initializing KubeClient: ${getKubeApiErrorMessage(e)}`, - ); + console.log(`Error initializing KubeClient: ${getKubeApiErrorMessage(e)}`); throw e; } } async getConfigMap(configmapName: string, namespace: string) { try { + console.log(`Getting configmap ${configmapName} from namespace ${namespace}`); + return await this.coreV1Api.readNamespacedConfigMap(configmapName, namespace); + } catch (e) { console.log( - `Getting configmap ${configmapName} from namespace ${namespace}`, + hasErrorResponse(e) && e.body?.message ? e.body.message : getKubeApiErrorMessage(e), ); - return await this.coreV1Api.readNamespacedConfigMap( - configmapName, - namespace, - ); - } catch (e) { - console.log(e.body?.message); throw e; } } @@ -146,7 +179,9 @@ export class KubeClient { console.log(`Listing configmaps in namespace ${namespace}`); return await this.coreV1Api.listNamespacedConfigMap(namespace); } catch (e) { - console.error(e.body?.message); + console.error( + hasErrorResponse(e) && e.body?.message ? e.body.message : getKubeApiErrorMessage(e), + ); throw e; } } @@ -164,9 +199,7 @@ export class KubeClient { const configMapsResponse = await this.listConfigMaps(namespace); const configMaps = configMapsResponse.body.items; - console.log( - `Found ${configMaps.length} ConfigMaps in namespace ${namespace}`, - ); + console.log(`Found ${configMaps.length} ConfigMaps in namespace ${namespace}`); configMaps.forEach((cm) => { console.log(`ConfigMap: ${cm.metadata?.name}`); }); @@ -183,24 +216,16 @@ export class KubeClient { for (const cm of configMaps) { if ( cm.data && - Object.keys(cm.data).some( - (key) => key.includes("app-config") && key.endsWith(".yaml"), - ) + Object.keys(cm.data).some((key) => key.includes("app-config") && key.endsWith(".yaml")) ) { - console.log( - `Found ConfigMap with app-config data: ${cm.metadata?.name}`, - ); + console.log(`Found ConfigMap with app-config data: ${cm.metadata?.name}`); return cm.metadata?.name || ""; } } - throw new Error( - `No suitable app-config ConfigMap found in namespace ${namespace}`, - ); + throw new Error(`No suitable app-config ConfigMap found in namespace ${namespace}`); } catch (error) { - console.error( - `Error finding app config ConfigMap: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error finding app config ConfigMap: ${getKubeApiErrorMessage(error)}`); throw error; } } @@ -209,7 +234,7 @@ export class KubeClient { try { return (await this.coreV1Api.readNamespace(name)).body; } catch (e) { - console.log(`Error getting namespace ${name}: ${e.body?.message}`); + console.log(`Error getting namespace ${name}: ${getKubeApiErrorMessage(e)}`); throw e; } } @@ -239,26 +264,27 @@ export class KubeClient { }, }, ); - console.log( - `Deployment ${deploymentName} scaled to ${replicas} replicas.`, - ); + console.log(`Deployment ${deploymentName} scaled to ${replicas} replicas.`); return; } catch (error) { - const statusCode = error.response?.statusCode || error.statusCode; + const statusCode = getErrorStatusCode(error); const isNotFound = statusCode === 404; - const isRetryable = - isNotFound || statusCode === 503 || statusCode === 429; + const isRetryable = isNotFound || statusCode === 503 || statusCode === 429; if (isRetryable && attempt < maxRetries) { const delay = attempt * 2000; // 2s, 4s, 6s, 8s... console.log( `Deployment ${deploymentName} not ready (${statusCode}). Retry ${attempt}/${maxRetries} after ${delay}ms...`, ); - await new Promise((resolve) => setTimeout(resolve, delay)); + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, delay); + }); } else { console.error( `Failed to scale deployment ${deploymentName} after ${attempt} attempts:`, - error.body?.message || error.message, + getKubeApiErrorMessage(error), ); throw error; } @@ -271,16 +297,14 @@ export class KubeClient { console.log(`Getting secret ${secretName} from namespace ${namespace}`); return await this.coreV1Api.readNamespacedSecret(secretName, namespace); } catch (e) { - console.log(e.body.message); + console.log( + hasErrorResponse(e) && e.body?.message ? e.body.message : getKubeApiErrorMessage(e), + ); throw e; } } - async updateConfigMap( - configmapName: string, - namespace: string, - patch: object, - ) { + async updateConfigMap(configmapName: string, namespace: string, patch: object) { try { console.log("updateConfigMap called"); console.log("Namespace: ", namespace); @@ -288,9 +312,7 @@ export class KubeClient { const options = { headers: { "Content-type": k8s.PatchUtils.PATCH_FORMAT_JSON_PATCH }, }; - console.log( - `Updating configmap ${configmapName} in namespace ${namespace}`, - ); + console.log(`Updating configmap ${configmapName} in namespace ${namespace}`); await this.coreV1Api.patchNamespacedConfigMap( configmapName, namespace, @@ -308,11 +330,7 @@ export class KubeClient { } } - async updateConfigMapTitle( - configMapName: string, - namespace: string, - newTitle: string, - ) { + async updateConfigMapTitle(configMapName: string, namespace: string, newTitle: string) { try { // If the provided configMapName doesn't exist, try to find the correct one dynamically let actualConfigMapName = configMapName; @@ -320,26 +338,19 @@ export class KubeClient { await this.getConfigMap(configMapName, namespace); console.log(`Using provided ConfigMap name: ${configMapName}`); } catch (error) { - if (error.response?.statusCode === 404) { - console.log( - `ConfigMap ${configMapName} not found, searching for alternatives...`, - ); + if (hasErrorResponse(error) && error.response?.statusCode === 404) { + console.log(`ConfigMap ${configMapName} not found, searching for alternatives...`); actualConfigMapName = await this.findAppConfigMap(namespace); } else { throw error; } } - const configMapResponse = await this.getConfigMap( - actualConfigMapName, - namespace, - ); + const configMapResponse = await this.getConfigMap(actualConfigMapName, namespace); const configMap = configMapResponse.body; console.log(`Using ConfigMap: ${actualConfigMapName}`); - console.log( - `Available data keys: ${Object.keys(configMap.data || {}).join(", ")}`, - ); + console.log(`Available data keys: ${Object.keys(configMap.data || {}).join(", ")}`); // Find the correct data key dynamically let dataKey: string | undefined; @@ -360,9 +371,7 @@ export class KubeClient { // If none of the patterns match, look for any .yaml file containing app-config if (!dataKey) { - dataKey = dataKeys.find( - (key) => key.endsWith(".yaml") && key.includes("app-config"), - ); + dataKey = dataKeys.find((key) => key.endsWith(".yaml") && key.includes("app-config")); } // Last resort: use any .yaml file @@ -377,47 +386,44 @@ export class KubeClient { } console.log(`Using data key: ${dataKey}`); + if (!configMap.data) { + throw new Error(`ConfigMap '${actualConfigMapName}' has no data section`); + } const appConfigYaml = configMap.data[dataKey]; if (!appConfigYaml) { - throw new Error( - `Data key '${dataKey}' is empty in ConfigMap '${actualConfigMapName}'`, - ); + throw new Error(`Data key '${dataKey}' is empty in ConfigMap '${actualConfigMapName}'`); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const appConfigObj = yaml.load(appConfigYaml) as any; - - if (!appConfigObj || !appConfigObj.app) { + const parsedConfig: unknown = yaml.load(appConfigYaml); + if (!isRecord(parsedConfig) || !isRecord(parsedConfig.app)) { throw new Error( `Invalid app-config structure in ConfigMap '${actualConfigMapName}'. Expected 'app' section not found.`, ); } - console.log(`Current title: ${appConfigObj.app.title}`); - appConfigObj.app.title = newTitle; + const appSection = parsedConfig.app; + const currentTitle = typeof appSection.title === "string" ? appSection.title : undefined; + console.log(`Current title: ${currentTitle ?? "(none)"}`); + appSection.title = newTitle; console.log(`New title: ${newTitle}`); - configMap.data[dataKey] = yaml.dump(appConfigObj); + configMap.data[dataKey] = yaml.dump(parsedConfig); - delete configMap.metadata.creationTimestamp; - delete configMap.metadata.resourceVersion; + if (configMap.metadata) { + delete configMap.metadata.creationTimestamp; + delete configMap.metadata.resourceVersion; + } - await this.coreV1Api.replaceNamespacedConfigMap( - actualConfigMapName, - namespace, - configMap, - ); + await this.coreV1Api.replaceNamespacedConfigMap(actualConfigMapName, namespace, configMap); console.log( `ConfigMap '${actualConfigMapName}' updated successfully with new title: '${newTitle}'`, ); } catch (error) { - console.error( - `Error updating ConfigMap: ${getKubeApiErrorMessage(error)}`, - ); - throw new Error( - `Failed to update ConfigMap: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error updating ConfigMap: ${getKubeApiErrorMessage(error)}`); + throw new Error(`Failed to update ConfigMap: ${getKubeApiErrorMessage(error)}`, { + cause: error, + }); } } @@ -441,19 +447,21 @@ export class KubeClient { options, ); } catch (e) { - console.log(e.statusCode, e.body.message); + console.log(getKubeApiErrorMessage(e)); throw e; } } async createCongifmap(namespace: string, body: V1ConfigMap) { try { - console.log( - `Creating configmap ${body.metadata.name} in namespace ${namespace}`, - ); + const configMapName = body.metadata?.name; + if (!configMapName) { + throw new Error("ConfigMap metadata.name is required"); + } + console.log(`Creating configmap ${configMapName} in namespace ${namespace}`); return await this.coreV1Api.createNamespacedConfigMap(namespace, body); } catch (err) { - console.log(err.body.message); + console.log(getKubeApiErrorMessage(err)); throw err; } } @@ -474,14 +482,13 @@ export class KubeClient { resolve(); } }, - (err) => { - if (err && err.statusCode === 404) { + (err: unknown) => { + if (hasStatusCode(err) && err.statusCode === 404) { // Namespace was already deleted or does not exist console.log(`Namespace '${namespace}' is already deleted.`); resolve(); } else { reject(err); - throw err; } }, ); @@ -496,15 +503,15 @@ export class KubeClient { async createNamespaceIfNotExists(namespace: string) { const nsList = await this.coreV1Api.listNamespace(); - const ns = nsList.body.items.map((ns) => ns.metadata.name); + const ns = nsList.body.items + .map((item) => item.metadata?.name) + .filter((name): name is string => name !== undefined); if (ns.includes(namespace)) { console.log(`Delete and re-create namespace ${namespace}`); try { await this.deleteNamespaceAndWait(namespace); } catch (err) { - console.log( - `Error deleting namespace ${namespace}: ${getKubeApiErrorMessage(err)}`, - ); + console.log(`Error deleting namespace ${namespace}: ${getKubeApiErrorMessage(err)}`); throw err; } } @@ -515,9 +522,10 @@ export class KubeClient { name: namespace, }, }); - console.log(`Created namespace ${createNamespaceRes.body.metadata.name}`); + const createdName = createNamespaceRes.body.metadata?.name; + console.log(`Created namespace ${createdName ?? namespace}`); } catch (err) { - console.log(err.body.message); + console.log(getKubeApiErrorMessage(err)); throw err; } } @@ -525,11 +533,11 @@ export class KubeClient { async createSecret(secret: k8s.V1Secret, namespace: string) { try { console.log( - `Creating secret ${secret.metadata.name} in namespace ${namespace}`, + `Creating secret ${secret.metadata?.name ?? "unknown"} in namespace ${namespace}`, ); await this.coreV1Api.createNamespacedSecret(namespace, secret); } catch (err) { - console.log(err.body.message); + console.log(getKubeApiErrorMessage(err)); throw err; } } @@ -538,33 +546,24 @@ export class KubeClient { * Create or update a Kubernetes secret (upsert pattern). * Tries to update the secret first; if it doesn't exist, creates it. */ - async createOrUpdateSecret( - secret: k8s.V1Secret, - namespace: string, - ): Promise { + async createOrUpdateSecret(secret: k8s.V1Secret, namespace: string): Promise { const secretName = secret.metadata?.name; if (!secretName) { throw new Error("Secret metadata.name is required"); } try { - const existing = await this.coreV1Api.readNamespacedSecret( - secretName, - namespace, - ); + const existing = await this.coreV1Api.readNamespacedSecret(secretName, namespace); const body = existing.body; // Merge new keys into existing data to preserve keys not in the update // (e.g., RHDH_RUNTIME_URL when updating only DB credentials) - body.data = { ...(body.data ?? {}), ...(secret.data ?? {}) }; + body.data = { ...body.data, ...secret.data }; await this.coreV1Api.replaceNamespacedSecret(secretName, namespace, body); console.log(`Secret ${secretName} updated in namespace ${namespace}`); } catch (err: unknown) { - const statusCode = (err as { response?: { statusCode?: number } }) - ?.response?.statusCode; + const statusCode = getErrorStatusCode(err); if (statusCode === 404) { - console.log( - `Secret ${secretName} not found, creating in namespace ${namespace}`, - ); + console.log(`Secret ${secretName} not found, creating in namespace ${namespace}`); await this.createSecret(secret, namespace); console.log(`Secret ${secretName} created in namespace ${namespace}`); } else { @@ -612,14 +611,10 @@ export class KubeClient { // Check pod conditions for issues const conditions = pod.status?.conditions || []; for (const condition of conditions) { - if ( - condition.type === "PodScheduled" && - condition.status === "False" - ) { + if (condition.type === "PodScheduled" && condition.status === "False") { const msg = condition.message || ""; const isTransientPvc = - msg.includes("ephemeral volume") || - msg.includes("persistentvolumeclaim"); + msg.includes("ephemeral volume") || msg.includes("persistentvolumeclaim"); if (isTransientPvc) { console.log( `Pod ${podName} waiting for PVC creation (transient): ${condition.reason} - ${msg}`, @@ -637,11 +632,7 @@ export class KubeClient { condition.reason !== "ContainersNotReady" ) { // Only report if it's a specific error reason, not just "not ready yet" - const errorReasons = [ - "Unhealthy", - "ReadinessGatesNotReady", - "PodHasNoResources", - ]; + const errorReasons = ["Unhealthy", "ReadinessGatesNotReady", "PodHasNoResources"]; if (errorReasons.includes(condition.reason)) { return { message: `Pod ${podName} is not ready: ${condition.reason} - ${condition.message}`, @@ -707,9 +698,7 @@ export class KubeClient { return null; // No failure states detected } catch (error) { - console.error( - `Error checking pod failure states: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error checking pod failure states: ${getKubeApiErrorMessage(error)}`); return null; // Don't fail the check if we can't retrieve pod info } } @@ -724,18 +713,12 @@ export class KubeClient { ) { const endTime = Date.now() + timeout; - const podSelector = await this.getDeploymentPodSelector( - deploymentName, - namespace, - ); + const podSelector = await this.getDeploymentPodSelector(deploymentName, namespace); const finalLabelSelector = labelSelector ?? podSelector; while (Date.now() < endTime) { try { - const response = await this.appsApi.readNamespacedDeployment( - deploymentName, - namespace, - ); + const response = await this.appsApi.readNamespacedDeployment(deploymentName, namespace); const availableReplicas = response.body.status?.availableReplicas || 0; const readyReplicas = response.body.status?.readyReplicas || 0; const updatedReplicas = response.body.status?.updatedReplicas || 0; @@ -746,17 +729,11 @@ export class KubeClient { console.log(`Ready replicas: ${readyReplicas}`); console.log(`Updated replicas: ${updatedReplicas}`); console.log(`Desired replicas: ${replicas}`); - console.log( - "Deployment conditions:", - JSON.stringify(conditions, null, 2), - ); + console.log("Deployment conditions:", JSON.stringify(conditions, null, 2)); // Check for pod failure states when expecting replicas > 0 if (expectedReplicas > 0 && podSelector) { - const podFailure = await this.checkPodFailureStates( - namespace, - podSelector, - ); + const podFailure = await this.checkPodFailureStates(namespace, podSelector); if (podFailure) { console.error( `Pod failure detected: ${podFailure.message}. Logging events and pod logs...`, @@ -765,14 +742,8 @@ export class KubeClient { await this.logReplicaSetStatus(deploymentName, namespace); await this.logPodEvents(namespace, finalLabelSelector); await this.logPodConditions(namespace, finalLabelSelector); - await this.logPodContainerLogs( - namespace, - finalLabelSelector, - podFailure.containerName, - ); - throw new Error( - `Deployment ${deploymentName} failed to start: ${podFailure.message}`, - ); + await this.logPodContainerLogs(namespace, finalLabelSelector, podFailure.containerName); + throw new Error(`Deployment ${deploymentName} failed to start: ${podFailure.message}`); } } @@ -781,9 +752,7 @@ export class KubeClient { // Check if the expected replicas match if (availableReplicas === expectedReplicas) { - console.log( - `Deployment ${deploymentName} is ready with ${availableReplicas} replicas.`, - ); + console.log(`Deployment ${deploymentName} is ready with ${availableReplicas} replicas.`); return; } @@ -794,22 +763,22 @@ export class KubeClient { ); } } catch (error) { - console.error( - `Error checking deployment status: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error checking deployment status: ${getKubeApiErrorMessage(error)}`); // If we threw an error about pod failure, re-throw it - if (error.message?.includes("failed to start")) { + if (error instanceof Error && error.message.includes("failed to start")) { throw error; } } - await new Promise((resolve) => setTimeout(resolve, checkInterval)); + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, checkInterval); + }); } // On timeout, collect final diagnostics - console.error( - `Timeout waiting for deployment ${deploymentName}. Collecting diagnostics...`, - ); + console.error(`Timeout waiting for deployment ${deploymentName}. Collecting diagnostics...`); await this.logDeploymentEvents(deploymentName, namespace); await this.logReplicaSetStatus(deploymentName, namespace); await this.logPodEvents(namespace, finalLabelSelector); @@ -821,9 +790,7 @@ export class KubeClient { async restartDeployment(deploymentName: string, namespace: string) { try { - console.log( - `Starting deployment restart for ${deploymentName} in namespace ${namespace}`, - ); + console.log(`Starting deployment restart for ${deploymentName} in namespace ${namespace}`); // Scale down deployment to 0 replicas console.log(`Scaling down deployment ${deploymentName} to 0 replicas.`); @@ -834,7 +801,11 @@ export class KubeClient { // Wait a bit for pods to be fully terminated console.log("Waiting for pods to be fully terminated..."); - await new Promise((resolve) => setTimeout(resolve, 10000)); // 10 seconds + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 10000); + }); // 10 seconds // Scale up deployment to 1 replica console.log(`Scaling up deployment ${deploymentName} to 1 replica.`); @@ -842,9 +813,7 @@ export class KubeClient { await this.waitForDeploymentReady(deploymentName, namespace, 1, 600000); // 10 minutes for scale up - console.log( - `Restart of deployment ${deploymentName} completed successfully.`, - ); + console.log(`Restart of deployment ${deploymentName} completed successfully.`); } catch (error) { console.error( `Error during deployment restart: Deployment '${deploymentName}' in namespace '${namespace}': ${getKubeApiErrorMessage(error)}`, @@ -853,6 +822,7 @@ export class KubeClient { await this.logDeploymentEvents(deploymentName, namespace); throw new Error( `Failed to restart deployment '${deploymentName}' in namespace '${namespace}': ${getKubeApiErrorMessage(error)}`, + { cause: error }, ); } } @@ -864,10 +834,7 @@ export class KubeClient { deploymentName: string, namespace: string, ): Promise { - const response = await this.appsApi.readNamespacedDeployment( - deploymentName, - namespace, - ); + const response = await this.appsApi.readNamespacedDeployment(deploymentName, namespace); const matchLabels = response.body.spec?.selector?.matchLabels || {}; const entries = Object.entries(matchLabels); if (entries.length === 0) { @@ -882,15 +849,9 @@ export class KubeClient { * Logs pod conditions for pods belonging to a specific deployment. * Resolves the pod selector from the deployment's matchLabels. */ - async logPodConditionsForDeployment( - deploymentName: string, - namespace: string, - ) { + async logPodConditionsForDeployment(deploymentName: string, namespace: string) { try { - const selector = await this.getDeploymentPodSelector( - deploymentName, - namespace, - ); + const selector = await this.getDeploymentPodSelector(deploymentName, namespace); await this.logPodConditions(namespace, selector); } catch (error) { console.warn( @@ -918,10 +879,7 @@ export class KubeClient { const podName = pod.metadata?.name || "unknown"; const phase = pod.status?.phase; console.log(`Pod: ${podName} (Phase: ${phase})`); - console.log( - "Conditions:", - JSON.stringify(pod.status?.conditions, null, 2), - ); + console.log("Conditions:", JSON.stringify(pod.status?.conditions, null, 2)); // Log container statuses const containerStatuses = [ @@ -938,12 +896,10 @@ export class KubeClient { const terminated = containerStatus.state?.terminated; if (waiting) { - console.log( - ` ${containerName}: Waiting - ${waiting.reason}: ${waiting.message}`, - ); + console.log(` ${containerName}: Waiting - ${waiting.reason}: ${waiting.message}`); } else if (running) { console.log( - ` ${containerName}: Running (started: ${running.startedAt})`, + ` ${containerName}: Running (started: ${formatContainerStartedAt(running.startedAt)})`, ); } else if (terminated) { console.log( @@ -963,11 +919,7 @@ export class KubeClient { } } - async logPodContainerLogs( - namespace: string, - labelSelector?: string, - containerName?: string, - ) { + async logPodContainerLogs(namespace: string, labelSelector?: string, containerName?: string) { const selector = labelSelector || "app.kubernetes.io/component=backstage,app.kubernetes.io/instance=rhdh,app.kubernetes.io/name=backstage"; @@ -995,17 +947,12 @@ export class KubeClient { // Otherwise, get logs from all containers (including init containers) const containers = containerName ? [{ name: containerName }] - : [ - ...(pod.spec?.initContainers || []), - ...(pod.spec?.containers || []), - ]; + : [...(pod.spec?.initContainers || []), ...(pod.spec?.containers || [])]; for (const container of containers) { const cn = container.name; try { - console.log( - `\n=== Pod ${podName} - Container ${cn} Logs (last 100 lines) ===`, - ); + console.log(`\n=== Pod ${podName} - Container ${cn} Logs (last 100 lines) ===`); const logs = await this.coreV1Api.readNamespacedPodLog( podName, namespace, @@ -1028,16 +975,12 @@ export class KubeClient { } catch (logError) { const errorMsg = getKubeApiErrorMessage(logError); // Log error but don't try to get previous container logs (API doesn't support it easily) - console.warn( - `Could not retrieve logs for pod ${podName} container ${cn}: ${errorMsg}`, - ); + console.warn(`Could not retrieve logs for pod ${podName} container ${cn}: ${errorMsg}`); } } } } catch (error) { - console.error( - `Error retrieving pod logs: ${getKubeApiErrorMessage(error)}`, - ); + console.error(`Error retrieving pod logs: ${getKubeApiErrorMessage(error)}`); } } @@ -1061,8 +1004,7 @@ export class KubeClient { const allPodsResponse = await this.coreV1Api.listNamespacedPod(namespace); // Get all events in the namespace - const eventsResponse = - await this.coreV1Api.listNamespacedEvent(namespace); + const eventsResponse = await this.coreV1Api.listNamespacedEvent(namespace); // Get pod names from both responses const podNames = new Set(); @@ -1070,71 +1012,35 @@ export class KubeClient { if (pod.metadata?.name) podNames.add(pod.metadata.name); }); allPodsResponse.body.items.forEach((pod) => { - if ( - pod.metadata?.name && - pod.metadata.name.includes("backstage-developer-hub") - ) { + if (pod.metadata?.name && pod.metadata.name.includes("backstage-developer-hub")) { podNames.add(pod.metadata.name); } }); // Filter events related to pods (check by name pattern too) - const podEvents = eventsResponse.body.items + const podEvents = [...eventsResponse.body.items] .filter((event) => { const involvedObject = event.involvedObject; if (involvedObject?.kind !== "Pod") return false; const podName = involvedObject.name; // Match if it's in our pod list OR if it matches our deployment pattern return ( - podNames.has(podName) || - (podName && podName.includes("backstage-developer-hub")) + (podName !== undefined && podNames.has(podName)) || + (podName !== undefined && podName.includes("backstage-developer-hub")) ); }) - .sort((a, b) => { - // Handle both Date objects and string timestamps - const getTimestamp = (event: { - firstTimestamp?: string | Date; - eventTime?: string | { getTime?: () => number }; - }): number => { - if (event.firstTimestamp) { - return typeof event.firstTimestamp === "string" - ? new Date(event.firstTimestamp).getTime() - : event.firstTimestamp.getTime(); - } - if (event.eventTime) { - return typeof event.eventTime === "string" - ? new Date(event.eventTime).getTime() - : event.eventTime?.getTime - ? event.eventTime.getTime() - : 0; - } - return 0; - }; - const aTime = getTimestamp(a); - const bTime = getTimestamp(b); - return bTime - aTime; // Most recent first - }) + // oxlint-disable-next-line unicorn/no-array-sort -- es2022 lib has no Array#toSorted + .sort( + (a: k8s.CoreV1Event, b: k8s.CoreV1Event) => + getEventSortTimestamp(b) - getEventSortTimestamp(a), + ) .slice(0, 30); // Limit to last 30 events if (podEvents.length > 0) { console.log(`Recent pod events (last ${podEvents.length}):`); for (const event of podEvents) { const podName = event.involvedObject?.name || "unknown"; - // Handle both Date objects and string timestamps - let timestamp = "unknown"; - if (event.firstTimestamp) { - timestamp = - typeof event.firstTimestamp === "string" - ? new Date(event.firstTimestamp).toISOString() - : event.firstTimestamp.toISOString(); - } else if (event.eventTime) { - timestamp = - typeof event.eventTime === "string" - ? new Date(event.eventTime).toISOString() - : event.eventTime?.toISOString - ? event.eventTime.toISOString() - : String(event.eventTime); - } + const timestamp = formatEventTimestamp(event); console.log( ` [${timestamp}] Pod ${podName}: [${event.type}] ${event.reason}: ${event.message}`, ); @@ -1216,10 +1122,7 @@ export class KubeClient { async logReplicaSetStatus(deploymentName: string, namespace: string) { try { // Get the deployment to find associated ReplicaSets - const deployment = await this.appsApi.readNamespacedDeployment( - deploymentName, - namespace, - ); + const deployment = await this.appsApi.readNamespacedDeployment(deploymentName, namespace); // List ReplicaSets with the deployment's labels const labelSelector = deployment.body.spec?.selector?.matchLabels; @@ -1246,11 +1149,14 @@ export class KubeClient { ); // Sort by creation timestamp (newest first) - const sortedReplicaSets = rsResponse.body.items.sort((a, b) => { - const aTime = a.metadata?.creationTimestamp?.getTime() || 0; - const bTime = b.metadata?.creationTimestamp?.getTime() || 0; - return bTime - aTime; - }); + // oxlint-disable-next-line unicorn/no-array-sort -- es2022 lib has no Array#toSorted + const sortedReplicaSets = [...rsResponse.body.items].sort( + (a: k8s.V1ReplicaSet, b: k8s.V1ReplicaSet) => { + const aTime = a.metadata?.creationTimestamp?.getTime() ?? 0; + const bTime = b.metadata?.creationTimestamp?.getTime() ?? 0; + return bTime - aTime; + }, + ); for (const rs of sortedReplicaSets) { const rsName = rs.metadata?.name || "unknown"; @@ -1282,9 +1188,7 @@ export class KubeClient { console.log(` Events for ReplicaSet ${rsName}:`); rsEvents.body.items.slice(0, 10).forEach((event) => { // Limit to last 10 events - console.log( - ` [${event.type}] ${event.reason}: ${event.message}`, - ); + console.log(` [${event.type}] ${event.reason}: ${event.message}`); }); } else { console.log(` No events found for ReplicaSet ${rsName}`); @@ -1302,10 +1206,7 @@ export class KubeClient { } } - async getServiceByLabel( - namespace: string, - labelSelector: string, - ): Promise { + async getServiceByLabel(namespace: string, labelSelector: string): Promise { try { const response = await this.coreV1Api.listNamespacedService( namespace, @@ -1383,6 +1284,7 @@ export class KubeClient { } catch (error) { throw new Error( `Failed to execute command in pod ${podName}: ${getKubeApiErrorMessage(error)}`, + { cause: error }, ); } } diff --git a/e2e-tests/playwright/utils/postgres-config.ts b/e2e-tests/playwright/utils/postgres-config.ts index d7c0805ff9..eb5b8d8fbf 100644 --- a/e2e-tests/playwright/utils/postgres-config.ts +++ b/e2e-tests/playwright/utils/postgres-config.ts @@ -11,7 +11,9 @@ */ import { readFileSync, existsSync } from "fs"; + import { Client } from "pg"; + import { KubeClient } from "./kube-client"; /** @@ -19,7 +21,7 @@ import { KubeClient } from "./kube-client"; * Environment variables from Vault often have literal \n instead of newlines. */ function unescapeNewlines(value: string): string { - return value.replace(/\\n/g, "\n"); + return value.replaceAll(/\\n/g, "\n"); } /** @@ -27,9 +29,7 @@ function unescapeNewlines(value: string): string { * @param filePath - Path to the certificate file * @returns Certificate content with escaped newlines converted, or null if file doesn't exist */ -export function readCertificateFile( - filePath: string | undefined, -): string | null { +export function readCertificateFile(filePath: string | undefined): string | null { if (!filePath) { return null; } @@ -78,18 +78,14 @@ export async function configurePostgresCredentials( POSTGRES_HOST: Buffer.from(credentials.host).toString("base64"), POSTGRES_PORT: Buffer.from(credentials.port || "5432").toString("base64"), PGSSLMODE: Buffer.from(credentials.sslMode || "require").toString("base64"), - NODE_EXTRA_CA_CERTS: Buffer.from( - "/opt/app-root/src/postgres-crt.pem", - ).toString("base64"), + NODE_EXTRA_CA_CERTS: Buffer.from("/opt/app-root/src/postgres-crt.pem").toString("base64"), }; if (credentials.user) { data.POSTGRES_USER = Buffer.from(credentials.user).toString("base64"); } if (credentials.password) { - data.POSTGRES_PASSWORD = Buffer.from(credentials.password).toString( - "base64", - ); + data.POSTGRES_PASSWORD = Buffer.from(credentials.password).toString("base64"); } if (credentials.database) { data.POSTGRES_DB = Buffer.from(credentials.database).toString("base64"); @@ -190,8 +186,7 @@ export async function clearDatabase(credentials: { success = true; break; } catch (error) { - const errorMsg = - error instanceof Error ? error.message : String(error); + const errorMsg = error instanceof Error ? error.message : String(error); const isRetryable = errorMsg.includes("being accessed by other users") || errorMsg.includes("in use") || @@ -202,7 +197,11 @@ export async function clearDatabase(credentials: { console.log( `Retry ${attempt}/${maxRetries} for database ${db} after ${delay}ms (${errorMsg})`, ); - await new Promise((resolve) => setTimeout(resolve, delay)); + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, delay); + }); } else { console.warn(`Warning: Failed to drop database ${db}:`, errorMsg); break; @@ -217,9 +216,7 @@ export async function clearDatabase(credentials: { } } - console.log( - `Database cleanup completed: ${succeeded.length} dropped, ${failed.length} failed`, - ); + console.log(`Database cleanup completed: ${succeeded.length} dropped, ${failed.length} failed`); if (succeeded.length > 0) { console.log(`Successfully dropped: ${succeeded.join(", ")}`); } @@ -227,10 +224,7 @@ export async function clearDatabase(credentials: { console.log(`Failed to drop: ${failed.join(", ")}`); } } catch (error) { - console.error( - "Failed to connect to database or retrieve database list:", - error, - ); + console.error("Failed to connect to database or retrieve database list:", error); throw error; } finally { await client.end(); diff --git a/e2e-tests/playwright/utils/ui-helper.ts b/e2e-tests/playwright/utils/ui-helper.ts index 325c2d2a3c..972d421da0 100644 --- a/e2e-tests/playwright/utils/ui-helper.ts +++ b/e2e-tests/playwright/utils/ui-helper.ts @@ -1,10 +1,9 @@ import { expect, Locator, Page } from "@playwright/test"; + +import { getTranslations, getCurrentLanguage } from "../e2e/localization/locale"; import { UI_HELPER_ELEMENTS } from "../support/page-objects/global-obj"; import { SEARCH_OBJECTS_COMPONENTS } from "../support/page-objects/page-obj"; -import { - getTranslations, - getCurrentLanguage, -} from "../e2e/localization/locale"; +import { getErrorMessage } from "./errors"; const t = getTranslations(); const lang = getCurrentLanguage(); @@ -15,6 +14,12 @@ export class UIhelper { this.page = page; } + private getGlobalHeader(): Locator { + return this.page.getByRole("navigation").filter({ + has: this.page.getByTestId("KeyboardArrowDownOutlinedIcon"), + }); + } + async verifyComponentInCatalog(kind: string, expectedRows: string[]) { await this.openSidebar("Catalog"); await this.selectMuiBox("Kind", kind); @@ -35,10 +40,7 @@ export class UIhelper { * @param searchText - The text to be entered into the search input field. */ async searchInputPlaceholder(searchText: string) { - await this.page.fill( - SEARCH_OBJECTS_COMPONENTS.placeholderSearch, - searchText, - ); + await this.page.fill(SEARCH_OBJECTS_COMPONENTS.placeholderSearch, searchText); } async searchInputAriaLabel(searchText: string) { @@ -70,14 +72,12 @@ export class UIhelper { force: false, }, ) { - const button = this.page - .getByRole("button", { name: label, exact: options.exact }) - .first(); + const button = this.page.getByRole("button", { name: label, exact: options.exact }).first(); await expect(button).toBeVisible(); if (options?.force) { - // eslint-disable-next-line playwright/no-force-option + // oxlint-disable-next-line playwright/no-force-option -- MUI overlay blocks native click in CI await button.click({ force: true }); } else { await button.click(); @@ -141,7 +141,7 @@ export class UIhelper { }); if (options.force) { - // eslint-disable-next-line playwright/no-force-option + // oxlint-disable-next-line playwright/no-force-option -- MUI overlay blocks native click in CI await buttonElement.click({ force: true }); } else { await buttonElement.click(); @@ -159,7 +159,7 @@ export class UIhelper { async markAllNotificationsAsReadIfVisible() { try { // Check if "Mark all read" div is visible - const markAllReadDiv = this.page.locator('div[title="Mark all read"]'); + const markAllReadDiv = this.page.getByTitle("Mark all read"); const isVisible = await markAllReadDiv.isVisible(); if (isVisible) { @@ -174,7 +174,7 @@ export class UIhelper { } catch (error) { console.log( "Mark all read functionality not available or already processed: ", - error, + getErrorMessage(error), ); } } @@ -186,10 +186,7 @@ export class UIhelper { * @param elementType - The type of element (default: 'div'). * @returns Promise - Returns true if element was clicked, false if not visible. */ - async clickByTitleIfVisible( - title: string, - elementType: string = "div", - ): Promise { + async clickByTitleIfVisible(title: string, elementType: string = "div"): Promise { try { const element = this.page.locator(`${elementType}[title="${title}"]`); const isVisible = await element.isVisible(); @@ -202,27 +199,25 @@ export class UIhelper { } catch (error) { console.log( `Element with title "${title}" not found or not clickable: `, - error, + getErrorMessage(error), ); return false; } } async verifyDivHasText(divText: string | RegExp) { - await expect(this.page.locator(`div`).getByText(divText)).toBeVisible(); + await expect(this.page.getByText(divText)).toBeVisible(); } async clickLink(options: string | { href: string } | { ariaLabel: string }) { let linkLocator: Locator; if (typeof options === "string") { - linkLocator = this.page.locator("a").filter({ hasText: options }).first(); + linkLocator = this.page.getByRole("link", { name: options }).first(); } else if ("href" in options) { linkLocator = this.page.locator(`a[href="${options.href}"]`).first(); } else { - linkLocator = this.page - .locator(`div[aria-label='${options.ariaLabel}'] a`) - .first(); + linkLocator = this.page.locator(`div[aria-label='${options.ariaLabel}'] a`).first(); } await linkLocator.waitFor({ state: "visible" }); @@ -230,7 +225,7 @@ export class UIhelper { } async openProfileDropdown() { - const header = this.page.locator("nav[id='global-header']"); + const header = this.getGlobalHeader(); await expect(header).toBeVisible(); await header.getByTestId("KeyboardArrowDownOutlinedIcon").click(); } @@ -244,7 +239,7 @@ export class UIhelper { } async goToMyProfilePage() { - await expect(this.page.locator("nav[id='global-header']")).toBeVisible(); + await expect(this.getGlobalHeader()).toBeVisible(); await this.openProfileDropdown(); await this.clickLink( // TODO: RHDHBUGS-2552 - Strings not getting translated @@ -254,7 +249,7 @@ export class UIhelper { } async goToSettingsPage() { - await expect(this.page.locator("nav[id='global-header']")).toBeVisible(); + await expect(this.getGlobalHeader()).toBeVisible(); await this.openProfileDropdown(); const settingsItem = this.page.getByRole("menuitem", { name: t["plugin.global-header"][lang]["profile.settings"], @@ -283,11 +278,8 @@ export class UIhelper { let linkLocator: Locator; let notVisibleCheck: boolean; - if (typeof arg != "object") { - linkLocator = this.page - .locator("a") - .getByText(arg, { exact: options.exact }) - .first(); + if (typeof arg !== "object") { + linkLocator = this.page.getByRole("link", { name: arg, exact: options.exact }).first(); notVisibleCheck = options?.notVisible ?? false; } else { @@ -322,24 +314,20 @@ export class UIhelper { async isBtnVisibleByTitle(text: string): Promise { const locator = `BUTTON[title="${text}"]`; - return await this.isElementVisible(locator); + return this.isElementVisible(locator); } async isBtnVisible(text: string): Promise { const locator = `button:has-text("${text}")`; - return await this.isElementVisible(locator); + return this.isElementVisible(locator); } async isTextVisible(text: string, timeout = 10000): Promise { const locator = `:has-text("${text}")`; - return await this.isElementVisible(locator, timeout); + return this.isElementVisible(locator, timeout); } - async verifyTextVisible( - text: string, - exact = false, - timeout = 10000, - ): Promise { + async verifyTextVisible(text: string, exact = false, timeout = 10000): Promise { const locator = this.page.getByText(text, { exact }); await expect(locator).toBeVisible({ timeout }); } @@ -354,22 +342,16 @@ export class UIhelper { } async openSidebar(navBarText: string) { - const navLink = this.page - .locator(`nav a:has-text("${navBarText}")`) - .first(); + const navLink = this.page.locator(`nav a:has-text("${navBarText}")`).first(); await navLink.waitFor({ state: "visible", timeout: 15_000 }); await navLink.dispatchEvent("click"); } async openCatalogSidebar(kind: string) { await this.openSidebar(t["rhdh"][lang]["menuItem.catalog"]); - await this.selectMuiBox( - `${t["catalog-react"][lang]["entityKindPicker.title"]}`, - kind, - ); + await this.selectMuiBox(t["catalog-react"][lang]["entityKindPicker.title"], kind); await expect(async () => { await this.clickByDataTestId("user-picker-all"); - await this.page.waitForTimeout(1_500); await this.verifyHeading(new RegExp(`all ${kind}`, "i")); }).toPass({ intervals: [3_000], @@ -378,9 +360,7 @@ export class UIhelper { } async openSidebarButton(navBarButtonLabel: string) { - const navLink = this.page.locator( - `nav button[aria-label="${navBarButtonLabel}"]`, - ); + const navLink = this.page.locator(`nav button[aria-label="${navBarButtonLabel}"]`); await navLink.waitFor({ state: "visible" }); await navLink.click(); } @@ -388,7 +368,7 @@ export class UIhelper { async selectMuiBox(label: string, value: string, notVisible?: boolean) { // Wait for any overlaying dialogs to close before interacting await this.page - .locator('[role="presentation"].MuiDialog-root') + .getByRole("dialog") .waitFor({ state: "detached", timeout: 3000 }) .catch(() => {}); // Ignore if no dialog exists @@ -412,10 +392,7 @@ export class UIhelper { } } - async verifyRowsInTable( - rowTexts: (string | RegExp)[], - exact: boolean = true, - ) { + async verifyRowsInTable(rowTexts: (string | RegExp)[], exact: boolean = true) { for (const rowText of rowTexts) { await this.verifyTextInLocator(`tr>td`, rowText, exact); } @@ -425,11 +402,7 @@ export class UIhelper { await this.page.waitForSelector(`text=${text}`, { state: "detached" }); } - async verifyText( - text: string | RegExp, - exact: boolean = true, - timeout: number = 5000, - ) { + async verifyText(text: string | RegExp, exact: boolean = true, timeout: number = 5000) { await this.verifyTextInLocator("", text, exact, timeout); } @@ -449,18 +422,13 @@ export class UIhelper { try { await elementLocator.scrollIntoViewIfNeeded(); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn( - `Warning: Could not scroll element into view. Error: ${message}`, - ); + console.warn(`Warning: Could not scroll element into view. Error: ${getErrorMessage(error)}`); } await expect(elementLocator).toBeVisible(); } async verifyTextInSelector(selector: string, expectedText: string) { - const elementLocator = this.page - .locator(selector) - .getByText(expectedText, { exact: true }); + const elementLocator = this.page.locator(selector).getByText(expectedText, { exact: true }); try { await elementLocator.waitFor({ state: "visible" }); @@ -474,13 +442,9 @@ export class UIhelper { `Expected text "${expectedText}" not found. Actual content: "${actualText}".`, ); } - console.log( - `Text "${expectedText}" verified successfully in selector: ${selector}`, - ); + console.log(`Text "${expectedText}" verified successfully in selector: ${selector}`); } catch (error) { - const allTextContent = await this.page - .locator(selector) - .allTextContents(); + const allTextContent = await this.page.locator(selector).allTextContents(); console.error( `Verification failed for text: Expected "${expectedText}". Selector content: ${allTextContent.join(", ")}`, ); @@ -496,9 +460,7 @@ export class UIhelper { for (let i = 0; i < count; i++) { const textContent = await elements.nth(i).textContent(); if (textContent && textContent.includes(partialText)) { - console.log( - `Found partial text: ${partialText} in element: ${textContent}`, - ); + console.log(`Found partial text: ${partialText} in element: ${textContent}`); return; } } @@ -507,19 +469,15 @@ export class UIhelper { `Verification failed: Partial text "${partialText}" not found in any elements matching selector "${selector}".`, ); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(message); + console.error(getErrorMessage(error)); throw error; } } - async verifyColumnHeading( - rowTexts: string[] | RegExp[], - exact: boolean = true, - ) { + async verifyColumnHeading(rowTexts: string[] | RegExp[], exact: boolean = true) { for (const rowText of rowTexts) { const rowLocator = this.page - .locator(`tr>th`) + .getByRole("columnheader") .getByText(rowText, { exact: exact }) .first(); await rowLocator.waitFor({ state: "visible" }); @@ -529,20 +487,14 @@ export class UIhelper { } async verifyHeading(heading: string | RegExp, timeout: number = 20000) { - const headingLocator = this.page - .getByRole("heading") - .filter({ hasText: heading }) - .first(); + const headingLocator = this.page.getByRole("heading").filter({ hasText: heading }).first(); await headingLocator.waitFor({ state: "visible", timeout: timeout }); await expect(headingLocator).toBeVisible(); } async verifyParagraph(paragraph: string) { - const headingLocator = this.page - .locator("p") - .filter({ hasText: paragraph }) - .first(); + const headingLocator = this.page.getByText(paragraph).first(); await headingLocator.waitFor({ state: "visible", timeout: 20000 }); await expect(headingLocator).toBeVisible(); } @@ -625,16 +577,11 @@ export class UIhelper { * await verifyRowInTableByUniqueText('Developer-hub', ['service', 'active']); */ - async verifyRowInTableByUniqueText( - uniqueRowText: string, - cellTexts: string[] | RegExp[], - ) { + async verifyRowInTableByUniqueText(uniqueRowText: string, cellTexts: string[] | RegExp[]) { const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); await row.waitFor(); for (const cellText of cellTexts) { - await expect( - row.locator("td").filter({ hasText: cellText }).first(), - ).toBeVisible(); + await expect(row.getByRole("cell").filter({ hasText: cellText }).first()).toBeVisible(); } } @@ -651,11 +598,7 @@ export class UIhelper { ) { const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); await row.waitFor(); - await row - .locator("a") - .getByText(linkText, { exact: exact }) - .first() - .click(); + await row.getByRole("link").getByText(linkText, { exact: exact }).first().click(); } /** @@ -663,16 +606,11 @@ export class UIhelper { * @param {string} uniqueRowText - The unique text present in one of the cells within the row. This is used to identify the specific row. * @param {string | RegExp} textOrLabel - The text of the button or the `aria-label` attribute, can be a string or a regular expression. */ - async clickOnButtonInTableByUniqueText( - uniqueRowText: string, - textOrLabel: string | RegExp, - ) { + async clickOnButtonInTableByUniqueText(uniqueRowText: string, textOrLabel: string | RegExp) { const row = this.page.locator(UI_HELPER_ELEMENTS.rowByText(uniqueRowText)); await row.waitFor(); await row - .locator( - `button:has-text("${textOrLabel}"), button[aria-label="${textOrLabel}"]`, - ) + .locator(`button:has-text("${textOrLabel}"), button[aria-label="${textOrLabel}"]`) .first() .click(); } @@ -680,7 +618,7 @@ export class UIhelper { async verifyLinkinCard(cardHeading: string, linkText: string, exact = true) { const link = this.page .locator(UI_HELPER_ELEMENTS.MuiCard(cardHeading)) - .locator("a") + .getByRole("link") .getByText(linkText, { exact: exact }) .first(); await link.scrollIntoViewIfNeeded(); @@ -688,21 +626,12 @@ export class UIhelper { } async clickBtnInCard(cardText: string, btnText: string, exact = true) { - const cardLocator = this.page - .locator(UI_HELPER_ELEMENTS.MuiCardRoot(cardText)) - .first(); + const cardLocator = this.page.locator(UI_HELPER_ELEMENTS.MuiCardRoot(cardText)).first(); await cardLocator.scrollIntoViewIfNeeded(); - await cardLocator - .getByRole("button", { name: btnText, exact: exact }) - .first() - .click(); + await cardLocator.getByRole("button", { name: btnText, exact: exact }).first().click(); } - async verifyTextinCard( - cardHeading: string, - text: string | RegExp, - exact = true, - ) { + async verifyTextinCard(cardHeading: string, text: string | RegExp, exact = true) { const locator = this.page .locator(UI_HELPER_ELEMENTS.MuiCard(cardHeading)) .getByText(text, { exact: exact }) @@ -747,9 +676,7 @@ export class UIhelper { const expectedRgbColor = this.toRgb(expectedColor); for (let i = 0; i < count; i++) { - const color = await elements - .nth(i) - .evaluate((el) => window.getComputedStyle(el).color); + const color = await elements.nth(i).evaluate((el) => window.getComputedStyle(el).color); expect(color).toBe(expectedRgbColor); } } @@ -795,20 +722,21 @@ export class UIhelper { timeout: 20 * 1000, }); - await expect( - this.page.locator(`button[title="Schedule entity refresh"]`), - ).toHaveCount(1); + const refreshButton = this.page.getByRole("button", { + name: "Schedule entity refresh", + }); + await expect(refreshButton).toHaveCount(1); - await this.page.locator(`button[title="Schedule entity refresh"]`).click(); + await refreshButton.click(); await this.verifyAlertErrorMessage("Refresh scheduled"); - const moreButton = this.page.locator("button[aria-label='more']").first(); + const moreButton = this.page.getByRole("button", { name: "more" }).first(); await moreButton.waitFor({ state: "visible", timeout: 4000 }); await moreButton.waitFor({ state: "attached", timeout: 4000 }); await moreButton.click(); const unregisterItem = this.page - .locator("li[role='menuitem']") + .getByRole("menuitem") .filter({ hasText: "Unregister entity" }) .first(); await unregisterItem.waitFor({ state: "visible", timeout: 4000 }); @@ -819,13 +747,13 @@ export class UIhelper { async clickUnregisterButtonForDisplayedEntity( buttonName: "Delete Entity" | "Unregister Location" = "Delete Entity", ) { - const moreButton = this.page.locator("button[aria-label='more']").first(); + const moreButton = this.page.getByRole("button", { name: "more" }).first(); await moreButton.waitFor({ state: "visible" }); await moreButton.waitFor({ state: "attached" }); await moreButton.click(); const unregisterItem = this.page - .locator("li[role='menuitem']") + .getByRole("menuitem") .filter({ hasText: "Unregister entity" }) .first(); await unregisterItem.waitFor({ state: "visible" }); @@ -846,18 +774,14 @@ export class UIhelper { * @param expectedEnabled - Expected value for the Enabled column ("Yes" or "No"). * @param expectedPreinstalled - Expected value for the Preinstalled column ("Yes" or "No"). */ - async verifyPluginRow( - text: string, - expectedEnabled: string, - expectedPreinstalled: string, - ) { + async verifyPluginRow(text: string, expectedEnabled: string, expectedPreinstalled: string) { // Locate the row based on the text in the Name column const rowSelector = `tr:has(td:text-is("${text}"))`; const row = this.page.locator(rowSelector); // Locate the "Enabled" (3rd column) and "Preinstalled" (4th column) cells by their index - const enabledColumn = row.locator("td").nth(2); // Index 2 for "Enabled" - const preinstalledColumn = row.locator("td").nth(3); // Index 3 for "Preinstalled" + const enabledColumn = row.getByRole("cell").nth(2); // Index 2 for "Enabled" + const preinstalledColumn = row.getByRole("cell").nth(3); // Index 3 for "Preinstalled" await expect(enabledColumn).toHaveText(expectedEnabled); await expect(preinstalledColumn).toHaveText(expectedPreinstalled); @@ -879,8 +803,7 @@ export class UIhelper { }; private getQuickstartHideButton() { - const label = - UIhelper.quickstartHideLabel[lang] ?? UIhelper.quickstartHideLabel["en"]; + const label = UIhelper.quickstartHideLabel[lang] ?? UIhelper.quickstartHideLabel["en"]; return this.page.getByRole("button", { name: label }); } diff --git a/e2e-tests/tsconfig.json b/e2e-tests/tsconfig.json index 9e7a32705d..370837dcad 100644 --- a/e2e-tests/tsconfig.json +++ b/e2e-tests/tsconfig.json @@ -1,17 +1,17 @@ { "compilerOptions": { - "target": "es2020", - "lib": ["es2020", "dom"], + "target": "es2022", + "lib": ["es2022", "dom"], "types": ["@playwright/test", "node"], "esModuleInterop": true, - "skipLibCheck": true /* Skip type checking all .d.ts files. */, + "skipLibCheck": true, + "strict": true, "module": "ESNext", - "moduleResolution": "node", + "moduleResolution": "bundler", "noEmit": true, "resolveJsonModule": true, - "baseUrl": ".", "paths": { - "@support/*": ["playwright/support/*"] + "@support/*": ["./playwright/support/*"] } }, "include": ["**/*.ts"] diff --git a/e2e-tests/yarn.lock b/e2e-tests/yarn.lock index bc421bf805..3f02c757c8 100644 --- a/e2e-tests/yarn.lock +++ b/e2e-tests/yarn.lock @@ -5,13 +5,6 @@ __metadata: version: 8 cacheKey: 10c0 -"@aashutoshrathi/word-wrap@npm:^1.2.3": - version: 1.2.6 - resolution: "@aashutoshrathi/word-wrap@npm:1.2.6" - checksum: 10c0/53c2b231a61a46792b39a0d43bc4f4f776bb4542aa57ee04930676802e5501282c2fc8aac14e4cd1f1120ff8b52616b6ff5ab539ad30aa2277d726444b71619f - languageName: node - linkType: hard - "@axe-core/playwright@npm:4.11.2": version: 4.11.2 resolution: "@axe-core/playwright@npm:4.11.2" @@ -309,112 +302,6 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.8.0": - version: 4.9.0 - resolution: "@eslint-community/eslint-utils@npm:4.9.0" - dependencies: - eslint-visitor-keys: "npm:^3.4.3" - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/8881e22d519326e7dba85ea915ac7a143367c805e6ba1374c987aa2fbdd09195cc51183d2da72c0e2ff388f84363e1b220fd0d19bef10c272c63455162176817 - languageName: node - linkType: hard - -"@eslint-community/eslint-utils@npm:^4.9.1": - version: 4.9.1 - resolution: "@eslint-community/eslint-utils@npm:4.9.1" - dependencies: - eslint-visitor-keys: "npm:^3.4.3" - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10c0/dc4ab5e3e364ef27e33666b11f4b86e1a6c1d7cbf16f0c6ff87b1619b3562335e9201a3d6ce806221887ff780ec9d828962a290bb910759fd40a674686503f02 - languageName: node - linkType: hard - -"@eslint-community/regexpp@npm:^4.12.1": - version: 4.12.1 - resolution: "@eslint-community/regexpp@npm:4.12.1" - checksum: 10c0/a03d98c246bcb9109aec2c08e4d10c8d010256538dcb3f56610191607214523d4fb1b00aa81df830b6dffb74c5fa0be03642513a289c567949d3e550ca11cdf6 - languageName: node - linkType: hard - -"@eslint-community/regexpp@npm:^4.12.2": - version: 4.12.2 - resolution: "@eslint-community/regexpp@npm:4.12.2" - checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d - languageName: node - linkType: hard - -"@eslint/config-array@npm:^0.21.2": - version: 0.21.2 - resolution: "@eslint/config-array@npm:0.21.2" - dependencies: - "@eslint/object-schema": "npm:^2.1.7" - debug: "npm:^4.3.1" - minimatch: "npm:^3.1.5" - checksum: 10c0/89dfe815d18456177c0a1f238daf4593107fd20298b3598e0103054360d3b8d09d967defd8318f031185d68df1f95cfa68becf1390a9c5c6887665f1475142e3 - languageName: node - linkType: hard - -"@eslint/config-helpers@npm:^0.4.2": - version: 0.4.2 - resolution: "@eslint/config-helpers@npm:0.4.2" - dependencies: - "@eslint/core": "npm:^0.17.0" - checksum: 10c0/92efd7a527b2d17eb1a148409d71d80f9ac160b565ac73ee092252e8bf08ecd08670699f46b306b94f13d22e88ac88a612120e7847570dd7cdc72f234d50dcb4 - languageName: node - linkType: hard - -"@eslint/core@npm:^0.17.0": - version: 0.17.0 - resolution: "@eslint/core@npm:0.17.0" - dependencies: - "@types/json-schema": "npm:^7.0.15" - checksum: 10c0/9a580f2246633bc752298e7440dd942ec421860d1946d0801f0423830e67887e4aeba10ab9a23d281727a978eb93d053d1922a587d502942a713607f40ed704e - languageName: node - linkType: hard - -"@eslint/eslintrc@npm:^3.3.5": - version: 3.3.5 - resolution: "@eslint/eslintrc@npm:3.3.5" - dependencies: - ajv: "npm:^6.14.0" - debug: "npm:^4.3.2" - espree: "npm:^10.0.1" - globals: "npm:^14.0.0" - ignore: "npm:^5.2.0" - import-fresh: "npm:^3.2.1" - js-yaml: "npm:^4.1.1" - minimatch: "npm:^3.1.5" - strip-json-comments: "npm:^3.1.1" - checksum: 10c0/9fb9f1ca65e46d6173966e3aaa5bd353e3a65d7f1f582bebf77f578fab7d7960a399fac1ecfb1e7d52bd61f5cefd6531087ca52a3a3c388f2e1b4f1ebd3da8b7 - languageName: node - linkType: hard - -"@eslint/js@npm:9.39.4": - version: 9.39.4 - resolution: "@eslint/js@npm:9.39.4" - checksum: 10c0/5aa7dea2cbc5decf7f5e3b0c6f86a084ccee0f792d288ca8e839f8bc1b64e03e227068968e49b26096e6f71fd857ab6e42691d1b993826b9a3883f1bdd7a0e46 - languageName: node - linkType: hard - -"@eslint/object-schema@npm:^2.1.7": - version: 2.1.7 - resolution: "@eslint/object-schema@npm:2.1.7" - checksum: 10c0/936b6e499853d1335803f556d526c86f5fe2259ed241bc665000e1d6353828edd913feed43120d150adb75570cae162cf000b5b0dfc9596726761c36b82f4e87 - languageName: node - linkType: hard - -"@eslint/plugin-kit@npm:^0.4.1": - version: 0.4.1 - resolution: "@eslint/plugin-kit@npm:0.4.1" - dependencies: - "@eslint/core": "npm:^0.17.0" - levn: "npm:^0.4.1" - checksum: 10c0/51600f78b798f172a9915dffb295e2ffb44840d583427bc732baf12ecb963eb841b253300e657da91d890f4b323d10a1bd12934bf293e3018d8bb66fdce5217b - languageName: node - linkType: hard - "@felipecrs/decompress-tarxz@npm:5.0.4": version: 5.0.4 resolution: "@felipecrs/decompress-tarxz@npm:5.0.4" @@ -427,44 +314,6 @@ __metadata: languageName: node linkType: hard -"@humanfs/core@npm:^0.19.1": - version: 0.19.1 - resolution: "@humanfs/core@npm:0.19.1" - checksum: 10c0/aa4e0152171c07879b458d0e8a704b8c3a89a8c0541726c6b65b81e84fd8b7564b5d6c633feadc6598307d34564bd53294b533491424e8e313d7ab6c7bc5dc67 - languageName: node - linkType: hard - -"@humanfs/node@npm:^0.16.6": - version: 0.16.6 - resolution: "@humanfs/node@npm:0.16.6" - dependencies: - "@humanfs/core": "npm:^0.19.1" - "@humanwhocodes/retry": "npm:^0.3.0" - checksum: 10c0/8356359c9f60108ec204cbd249ecd0356667359b2524886b357617c4a7c3b6aace0fd5a369f63747b926a762a88f8a25bc066fa1778508d110195ce7686243e1 - languageName: node - linkType: hard - -"@humanwhocodes/module-importer@npm:^1.0.1": - version: 1.0.1 - resolution: "@humanwhocodes/module-importer@npm:1.0.1" - checksum: 10c0/909b69c3b86d482c26b3359db16e46a32e0fb30bd306a3c176b8313b9e7313dba0f37f519de6aa8b0a1921349e505f259d19475e123182416a506d7f87e7f529 - languageName: node - linkType: hard - -"@humanwhocodes/retry@npm:^0.3.0": - version: 0.3.1 - resolution: "@humanwhocodes/retry@npm:0.3.1" - checksum: 10c0/f0da1282dfb45e8120480b9e2e275e2ac9bbe1cf016d046fdad8e27cc1285c45bb9e711681237944445157b430093412b4446c1ab3fc4bb037861b5904101d3b - languageName: node - linkType: hard - -"@humanwhocodes/retry@npm:^0.4.2": - version: 0.4.3 - resolution: "@humanwhocodes/retry@npm:0.4.3" - checksum: 10c0/3775bb30087d4440b3f7406d5a057777d90e4b9f435af488a4923ef249e93615fb78565a85f173a186a076c7706a81d0d57d563a2624e4de2c5c9c66c486ce42 - languageName: node - linkType: hard - "@ioredis/commands@npm:1.5.1": version: 1.5.1 resolution: "@ioredis/commands@npm:1.5.1" @@ -936,6 +785,314 @@ __metadata: 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" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@oxfmt/binding-android-arm64@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-android-arm64@npm:0.56.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@oxfmt/binding-darwin-arm64@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-darwin-arm64@npm:0.56.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxfmt/binding-darwin-x64@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-darwin-x64@npm:0.56.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxfmt/binding-freebsd-x64@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-freebsd-x64@npm:0.56.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@oxfmt/binding-linux-arm-gnueabihf@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-arm-gnueabihf@npm:0.56.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxfmt/binding-linux-arm-musleabihf@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-arm-musleabihf@npm:0.56.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxfmt/binding-linux-arm64-gnu@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-arm64-gnu@npm:0.56.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-arm64-musl@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-arm64-musl@npm:0.56.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxfmt/binding-linux-ppc64-gnu@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-ppc64-gnu@npm:0.56.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-riscv64-gnu@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-riscv64-gnu@npm:0.56.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-riscv64-musl@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-riscv64-musl@npm:0.56.0" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@oxfmt/binding-linux-s390x-gnu@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-s390x-gnu@npm:0.56.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-x64-gnu@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-x64-gnu@npm:0.56.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxfmt/binding-linux-x64-musl@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-linux-x64-musl@npm:0.56.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxfmt/binding-openharmony-arm64@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-openharmony-arm64@npm:0.56.0" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@oxfmt/binding-win32-arm64-msvc@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-win32-arm64-msvc@npm:0.56.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxfmt/binding-win32-ia32-msvc@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-win32-ia32-msvc@npm:0.56.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@oxfmt/binding-win32-x64-msvc@npm:0.56.0": + version: 0.56.0 + resolution: "@oxfmt/binding-win32-x64-msvc@npm:0.56.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/darwin-arm64@npm:0.23.0": + version: 0.23.0 + resolution: "@oxlint-tsgolint/darwin-arm64@npm:0.23.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/darwin-x64@npm:0.23.0": + version: 0.23.0 + resolution: "@oxlint-tsgolint/darwin-x64@npm:0.23.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/linux-arm64@npm:0.23.0": + version: 0.23.0 + resolution: "@oxlint-tsgolint/linux-arm64@npm:0.23.0" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/linux-x64@npm:0.23.0": + version: 0.23.0 + resolution: "@oxlint-tsgolint/linux-x64@npm:0.23.0" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/win32-arm64@npm:0.23.0": + version: 0.23.0 + resolution: "@oxlint-tsgolint/win32-arm64@npm:0.23.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint-tsgolint/win32-x64@npm:0.23.0": + version: 0.23.0 + resolution: "@oxlint-tsgolint/win32-x64@npm:0.23.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/binding-android-arm-eabi@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-android-arm-eabi@npm:1.71.0" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@oxlint/binding-android-arm64@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-android-arm64@npm:1.71.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/binding-darwin-arm64@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-darwin-arm64@npm:1.71.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/binding-darwin-x64@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-darwin-x64@npm:1.71.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/binding-freebsd-x64@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-freebsd-x64@npm:1.71.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@oxlint/binding-linux-arm-gnueabihf@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-arm-gnueabihf@npm:1.71.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxlint/binding-linux-arm-musleabihf@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-arm-musleabihf@npm:1.71.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@oxlint/binding-linux-arm64-gnu@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-arm64-gnu@npm:1.71.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-arm64-musl@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-arm64-musl@npm:1.71.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/binding-linux-ppc64-gnu@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-ppc64-gnu@npm:1.71.0" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-riscv64-gnu@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-riscv64-gnu@npm:1.71.0" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-riscv64-musl@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-riscv64-musl@npm:1.71.0" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/binding-linux-s390x-gnu@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-s390x-gnu@npm:1.71.0" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-x64-gnu@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-x64-gnu@npm:1.71.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@oxlint/binding-linux-x64-musl@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-linux-x64-musl@npm:1.71.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@oxlint/binding-openharmony-arm64@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-openharmony-arm64@npm:1.71.0" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/binding-win32-arm64-msvc@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-win32-arm64-msvc@npm:1.71.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@oxlint/binding-win32-ia32-msvc@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-win32-ia32-msvc@npm:1.71.0" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@oxlint/binding-win32-x64-msvc@npm:1.71.0": + version: 1.71.0 + resolution: "@oxlint/binding-win32-x64-msvc@npm:1.71.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@pkgjs/parseargs@npm:^0.11.0": version: 0.11.0 resolution: "@pkgjs/parseargs@npm:0.11.0" @@ -954,13 +1111,6 @@ __metadata: languageName: node linkType: hard -"@reteps/dockerfmt@npm:^0.5.1": - version: 0.5.2 - resolution: "@reteps/dockerfmt@npm:0.5.2" - checksum: 10c0/c0af5dcc9c8c9d51c9eec4cfb454c85f31c760373288b9254054709b29a4accc1df10bd765712128dd173e53bfb8b880a94b0aa8b6c2d4294866d800dbdd14e1 - languageName: node - linkType: hard - "@tokenizer/inflate@npm:^0.2.6": version: 0.2.7 resolution: "@tokenizer/inflate@npm:0.2.7" @@ -986,17 +1136,20 @@ __metadata: languageName: node linkType: hard -"@types/estree@npm:^1.0.6": - version: 1.0.8 - resolution: "@types/estree@npm:1.0.8" - checksum: 10c0/39d34d1afaa338ab9763f37ad6066e3f349444f9052b9676a7cc0252ef9485a41c6d81c9c4e0d26e9077993354edf25efc853f3224dd4b447175ef62bdcc86a5 +"@types/js-yaml@npm:^4.0.9": + version: 4.0.9 + resolution: "@types/js-yaml@npm:4.0.9" + checksum: 10c0/24de857aa8d61526bbfbbaa383aa538283ad17363fcd5bb5148e2c7f604547db36646440e739d78241ed008702a8920665d1add5618687b6743858fae00da211 languageName: node linkType: hard -"@types/json-schema@npm:^7.0.15": - version: 7.0.15 - resolution: "@types/json-schema@npm:7.0.15" - checksum: 10c0/a996a745e6c5d60292f36731dd41341339d4eeed8180bb09226e5c8d23759067692b1d88e5d91d72ee83dfc00d3aca8e7bd43ea120516c17922cbcb7c3e252db +"@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 @@ -1036,157 +1189,6 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/eslint-plugin@npm:8.59.4" - dependencies: - "@eslint-community/regexpp": "npm:^4.12.2" - "@typescript-eslint/scope-manager": "npm:8.59.4" - "@typescript-eslint/type-utils": "npm:8.59.4" - "@typescript-eslint/utils": "npm:8.59.4" - "@typescript-eslint/visitor-keys": "npm:8.59.4" - ignore: "npm:^7.0.5" - natural-compare: "npm:^1.4.0" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - "@typescript-eslint/parser": ^8.59.4 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/53639bb5cbb5cb22d5e8d52c404a217cb1af4b1c3a8f6f3bb15824807b4db4bed49008d3b3f7688295285e764c7aff3b682b56dece3013a81de83f47bdf2b36c - languageName: node - linkType: hard - -"@typescript-eslint/parser@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/parser@npm:8.59.4" - dependencies: - "@typescript-eslint/scope-manager": "npm:8.59.4" - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/typescript-estree": "npm:8.59.4" - "@typescript-eslint/visitor-keys": "npm:8.59.4" - debug: "npm:^4.4.3" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/7dccab1bec898aee2c8aa8e08560ce6d439ef174358e98d5d92ee3f8a9fc0b044534ce0eecf57521f284858f937ec968941200c1df9ffd0baa0795bffa3de97d - languageName: node - linkType: hard - -"@typescript-eslint/project-service@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/project-service@npm:8.59.4" - dependencies: - "@typescript-eslint/tsconfig-utils": "npm:^8.59.4" - "@typescript-eslint/types": "npm:^8.59.4" - debug: "npm:^4.4.3" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/ba466e3b4091f79bd9ae8c29591d4858760293c2bc5d355642b9bf04b9c6fcd4418ff255485aaaf005edb84f6aaefeb53a3c1627bbbb70a905a4786d20f0b06a - languageName: node - linkType: hard - -"@typescript-eslint/scope-manager@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/scope-manager@npm:8.59.4" - dependencies: - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/visitor-keys": "npm:8.59.4" - checksum: 10c0/0e4701f8c3384c7406f372cb06762d6bf943aba3afe2c231e4e942ee2e8b4cd4e9e7667ec503502dc4a159b826892dbe1487e2a8d143e190c850744b2a329857 - languageName: node - linkType: hard - -"@typescript-eslint/tsconfig-utils@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.59.4" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/ef6cf20eb93cb5e12439bc9713f5d9c619d516aefd3ecd4f111d9b23ef9f36e5c13f1bbcd55faa6a4b788b146b2a8724a418504107d4d377d0463f419fe9e1f3 - languageName: node - linkType: hard - -"@typescript-eslint/tsconfig-utils@npm:^8.59.4": - version: 8.60.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.60.0" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/701eae9a5064c5501e9dccd5a8e0baf365ef9a09da4d523873df303ef139644fad43e3d91b03f9a6ebbb141c0e066fc26ad0c40d5113b7c0d6c9ba69450c2520 - languageName: node - linkType: hard - -"@typescript-eslint/type-utils@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/type-utils@npm:8.59.4" - dependencies: - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/typescript-estree": "npm:8.59.4" - "@typescript-eslint/utils": "npm:8.59.4" - debug: "npm:^4.4.3" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/93b1a96c395b22da81990655d2fc86d627f5ad815d33faa474b83463c27d34de86a8efedce6cd911d479fcfdc5a758476efa350933f5f97a4181fd226c4ccb6d - languageName: node - linkType: hard - -"@typescript-eslint/types@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/types@npm:8.59.4" - checksum: 10c0/5bb831f9acf98057b3dce6ebfc1df5f1796e701cdf035e71fdee6d0bb7f7e7d9c428bac38f46db4e08381ad8903424fcfbe55bcae223a6244b9133de8e0be190 - languageName: node - linkType: hard - -"@typescript-eslint/types@npm:^8.59.4": - version: 8.60.0 - resolution: "@typescript-eslint/types@npm:8.60.0" - checksum: 10c0/d2b6d46081a6521f204fda30e8f03712480b788d80b62b311e0f33764752d3db3bd415dd4e1f8d28495931316da1dfb5ee259e40c5de970367fbaa1efe97223f - languageName: node - linkType: hard - -"@typescript-eslint/typescript-estree@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/typescript-estree@npm:8.59.4" - dependencies: - "@typescript-eslint/project-service": "npm:8.59.4" - "@typescript-eslint/tsconfig-utils": "npm:8.59.4" - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/visitor-keys": "npm:8.59.4" - debug: "npm:^4.4.3" - minimatch: "npm:^10.2.2" - semver: "npm:^7.7.3" - tinyglobby: "npm:^0.2.15" - ts-api-utils: "npm:^2.5.0" - peerDependencies: - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/2f427f9ba3ea1c7d1f476883f9769827c7082ff3cefcb189dcdb2dc33b16fa459e40894152d42583df90d0ed1041a1043830ecba5326c0b1de6becb9cf22fcee - languageName: node - linkType: hard - -"@typescript-eslint/utils@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/utils@npm:8.59.4" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.9.1" - "@typescript-eslint/scope-manager": "npm:8.59.4" - "@typescript-eslint/types": "npm:8.59.4" - "@typescript-eslint/typescript-estree": "npm:8.59.4" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/f2e7f6237defd49e578731762e8736e7316e4873e326d48ec56651dcd0204962367f3e91692939e1636f443a8ded524336b7ee0874b6267940e77f5dc8fce175 - languageName: node - linkType: hard - -"@typescript-eslint/visitor-keys@npm:8.59.4": - version: 8.59.4 - resolution: "@typescript-eslint/visitor-keys@npm:8.59.4" - dependencies: - "@typescript-eslint/types": "npm:8.59.4" - eslint-visitor-keys: "npm:^5.0.0" - checksum: 10c0/fcef4078988d725f0e56104038cc903d78cb5527e10e4da2c29ae7cb65e5b46c6a8f3f20d2be3e83b4cbaf27a723d1d2b31027006b5f1d43bf1fb0baed8e7641 - languageName: node - linkType: hard - "@typespec/ts-http-runtime@npm:^0.2.2": version: 0.2.3 resolution: "@typespec/ts-http-runtime@npm:0.2.3" @@ -1238,15 +1240,6 @@ __metadata: languageName: node linkType: hard -"acorn-jsx@npm:^5.3.2": - version: 5.3.2 - resolution: "acorn-jsx@npm:5.3.2" - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - checksum: 10c0/4c54868fbef3b8d58927d5e33f0a4de35f59012fe7b12cf9dfbb345fb8f46607709e1c4431be869a23fb63c151033d84c4198fa9f79385cec34fcb1dd53974c1 - languageName: node - linkType: hard - "acorn-loose@npm:^8.5.2": version: 8.5.2 resolution: "acorn-loose@npm:8.5.2" @@ -1312,18 +1305,6 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^6.14.0": - version: 6.15.0 - resolution: "ajv@npm:6.15.0" - dependencies: - fast-deep-equal: "npm:^3.1.1" - fast-json-stable-stringify: "npm:^2.0.0" - json-schema-traverse: "npm:^0.4.1" - uri-js: "npm:^4.2.2" - checksum: 10c0/67966499dd272ecde1c2e467084411132891523d057487587879d39ac04207f4351b7b2324c83198013967fbfa632c1612adc960114a30770fbe07a0773b32c2 - languageName: node - linkType: hard - "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -1338,7 +1319,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": +"ansi-styles@npm:^4.0.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" dependencies: @@ -1454,13 +1435,6 @@ __metadata: languageName: node linkType: hard -"balanced-match@npm:^4.0.2": - version: 4.0.4 - resolution: "balanced-match@npm:4.0.4" - checksum: 10c0/07e86102a3eb2ee2a6a1a89164f29d0dbaebd28f2ca3f5ca786f36b8b23d9e417eb3be45a4acf754f837be5ac0a2317de90d3fcb7f4f4dc95720a1f36b26a17b - languageName: node - linkType: hard - "bare-events@npm:^2.5.4, bare-events@npm:^2.7.0": version: 2.8.2 resolution: "bare-events@npm:2.8.2" @@ -1584,16 +1558,6 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^1.1.7": - version: 1.1.11 - resolution: "brace-expansion@npm:1.1.11" - dependencies: - balanced-match: "npm:^1.0.0" - concat-map: "npm:0.0.1" - checksum: 10c0/695a56cd058096a7cb71fb09d9d6a7070113c7be516699ed361317aca2ec169f618e28b8af352e02ab4233fb54eb0168460a40dc320bab0034b36ab59aaad668 - languageName: node - linkType: hard - "brace-expansion@npm:^2.0.1": version: 2.0.1 resolution: "brace-expansion@npm:2.0.1" @@ -1603,15 +1567,6 @@ __metadata: languageName: node linkType: hard -"brace-expansion@npm:^5.0.2": - version: 5.0.4 - resolution: "brace-expansion@npm:5.0.4" - dependencies: - balanced-match: "npm:^4.0.2" - checksum: 10c0/359cbcfa80b2eb914ca1f3440e92313fbfe7919ee6b274c35db55bec555aded69dac5ee78f102cec90c35f98c20fa43d10936d0cd9978158823c249257e1643a - languageName: node - linkType: hard - "braces@npm:^3.0.3": version: 3.0.3 resolution: "braces@npm:3.0.3" @@ -1737,13 +1692,6 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0": - version: 3.1.0 - resolution: "callsites@npm:3.1.0" - checksum: 10c0/fff92277400eb06c3079f9e74f3af120db9f8ea03bad0e84d9aede54bbe2d44a56cccb5f6cf12211f93f52306df87077ecec5b712794c5a9b5dac6d615a3f301 - languageName: node - linkType: hard - "camelize-ts@npm:^3.0.0": version: 3.0.0 resolution: "camelize-ts@npm:3.0.0" @@ -1758,16 +1706,6 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^4.0.0": - version: 4.1.2 - resolution: "chalk@npm:4.1.2" - dependencies: - ansi-styles: "npm:^4.1.0" - supports-color: "npm:^7.1.0" - checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880 - languageName: node - linkType: hard - "chownr@npm:^2.0.0": version: 2.0.0 resolution: "chownr@npm:2.0.0" @@ -1858,7 +1796,7 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.6, combined-stream@npm:~1.0.6": +"combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8, combined-stream@npm:~1.0.6": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" dependencies: @@ -1881,13 +1819,6 @@ __metadata: languageName: node linkType: hard -"concat-map@npm:0.0.1": - version: 0.0.1 - resolution: "concat-map@npm:0.0.1" - checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f - languageName: node - linkType: hard - "console-grid@npm:^2.2.4": version: 2.2.4 resolution: "console-grid@npm:2.2.4" @@ -1909,7 +1840,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.0, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:^7.0.0": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -1929,7 +1860,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:4, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4": +"debug@npm:4, debug@npm:^4.3.4": version: 4.4.0 resolution: "debug@npm:4.4.0" dependencies: @@ -1941,7 +1872,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.4.0, debug@npm:^4.4.3": +"debug@npm:^4.4.0": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -2016,13 +1947,6 @@ __metadata: languageName: node linkType: hard -"deep-is@npm:^0.1.3": - version: 0.1.4 - resolution: "deep-is@npm:0.1.4" - checksum: 10c0/7f0ee496e0dff14a573dc6127f14c95061b448b87b995fc96c017ce0a1e66af1675e73f1d6064407975bc4ea6ab679497a29fff7b5b9c4e99cb10797c1ad0b4c - languageName: node - linkType: hard - "default-browser-id@npm:^5.0.0": version: 5.0.0 resolution: "default-browser-id@npm:5.0.0" @@ -2108,17 +2032,15 @@ __metadata: "@axe-core/playwright": "npm:4.11.2" "@azure/arm-network": "npm:34.2.0" "@azure/identity": "npm:4.13.1" - "@eslint/js": "npm:9.39.4" "@keycloak/keycloak-admin-client": "npm:25.0.6" "@kubernetes/client-node": "npm:0.22.3" "@microsoft/microsoft-graph-client": "npm:3.0.7" "@microsoft/microsoft-graph-types": "npm:2.43.1" "@playwright/test": "npm:1.59.1" + "@types/js-yaml": "npm:^4.0.9" "@types/node": "npm:24.12.2" + "@types/node-fetch": "npm:^2.6.13" "@types/pg": "npm:8.20.0" - "@typescript-eslint/eslint-plugin": "npm:8.59.4" - "@typescript-eslint/parser": "npm:8.59.4" - eslint: "npm:9.39.4" eslint-plugin-check-file: "npm:3.3.1" eslint-plugin-playwright: "npm:2.10.4" ioredis: "npm:5.10.1" @@ -2128,12 +2050,12 @@ __metadata: node-fetch: "npm:2.7.0" octokit: "npm:4.1.4" otplib: "npm:12.0.1" + oxfmt: "npm:0.56.0" + oxlint: "npm:1.71.0" + oxlint-tsgolint: "npm:0.23.0" pg: "npm:8.22.0" - prettier: "npm:3.8.3" - prettier-plugin-sh: "npm:0.18.1" shellcheck: "npm:4.1.0" - typescript: "npm:5.9.3" - typescript-eslint: "npm:8.59.4" + typescript: "npm:6.0.3" uuid: "npm:14.0.0" winston: "npm:3.14.2" yaml: "npm:2.9.0" @@ -2258,163 +2180,52 @@ __metadata: languageName: node linkType: hard -"es6-error@npm:^4.1.1": - version: 4.1.1 - resolution: "es6-error@npm:4.1.1" - checksum: 10c0/357663fb1e845c047d548c3d30f86e005db71e122678f4184ced0693f634688c3f3ef2d7de7d4af732f734de01f528b05954e270f06aa7d133679fb9fe6600ef - languageName: node - linkType: hard - -"escape-string-regexp@npm:^4.0.0": - version: 4.0.0 - resolution: "escape-string-regexp@npm:4.0.0" - checksum: 10c0/9497d4dd307d845bd7f75180d8188bb17ea8c151c1edbf6b6717c100e104d629dc2dfb687686181b0f4b7d732c7dfdc4d5e7a8ff72de1b0ca283a75bbb3a9cd9 - languageName: node - linkType: hard - -"eslint-plugin-check-file@npm:3.3.1": - version: 3.3.1 - resolution: "eslint-plugin-check-file@npm:3.3.1" - dependencies: - is-glob: "npm:^4.0.3" - micromatch: "npm:^4.0.8" - peerDependencies: - eslint: ">=9.0.0" - checksum: 10c0/1a6712a493f48b5c48a96f15b0a75f5d8f842f3cac122a57febd9349b6251d2ebb3e908cebc419be598e97df4d4f109d0c6d2bc0aafde16673328fc42d0ba308 - languageName: node - linkType: hard - -"eslint-plugin-playwright@npm:2.10.4": - version: 2.10.4 - resolution: "eslint-plugin-playwright@npm:2.10.4" - dependencies: - globals: "npm:^17.3.0" - peerDependencies: - eslint: ">=8.40.0" - checksum: 10c0/515aac2a870b217f23637bfdab26b9125e47cff91625dbafe20617b3522430fdee8961780f6b7dc9fcfcdbec8235ccd30eafd0ac871088cf3af4d70b8f9b7e29 - languageName: node - linkType: hard - -"eslint-scope@npm:^8.4.0": - version: 8.4.0 - resolution: "eslint-scope@npm:8.4.0" - dependencies: - esrecurse: "npm:^4.3.0" - estraverse: "npm:^5.2.0" - checksum: 10c0/407f6c600204d0f3705bd557f81bd0189e69cd7996f408f8971ab5779c0af733d1af2f1412066b40ee1588b085874fc37a2333986c6521669cdbdd36ca5058e0 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^3.4.3": - version: 3.4.3 - resolution: "eslint-visitor-keys@npm:3.4.3" - checksum: 10c0/92708e882c0a5ffd88c23c0b404ac1628cf20104a108c745f240a13c332a11aac54f49a22d5762efbffc18ecbc9a580d1b7ad034bf5f3cc3307e5cbff2ec9820 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^4.2.1": - version: 4.2.1 - resolution: "eslint-visitor-keys@npm:4.2.1" - checksum: 10c0/fcd43999199d6740db26c58dbe0c2594623e31ca307e616ac05153c9272f12f1364f5a0b1917a8e962268fdecc6f3622c1c2908b4fcc2e047a106fe6de69dc43 - languageName: node - linkType: hard - -"eslint-visitor-keys@npm:^5.0.0": - version: 5.0.1 - resolution: "eslint-visitor-keys@npm:5.0.1" - checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678 - languageName: node - linkType: hard - -"eslint@npm:9.39.4": - version: 9.39.4 - resolution: "eslint@npm:9.39.4" - dependencies: - "@eslint-community/eslint-utils": "npm:^4.8.0" - "@eslint-community/regexpp": "npm:^4.12.1" - "@eslint/config-array": "npm:^0.21.2" - "@eslint/config-helpers": "npm:^0.4.2" - "@eslint/core": "npm:^0.17.0" - "@eslint/eslintrc": "npm:^3.3.5" - "@eslint/js": "npm:9.39.4" - "@eslint/plugin-kit": "npm:^0.4.1" - "@humanfs/node": "npm:^0.16.6" - "@humanwhocodes/module-importer": "npm:^1.0.1" - "@humanwhocodes/retry": "npm:^0.4.2" - "@types/estree": "npm:^1.0.6" - ajv: "npm:^6.14.0" - chalk: "npm:^4.0.0" - cross-spawn: "npm:^7.0.6" - debug: "npm:^4.3.2" - escape-string-regexp: "npm:^4.0.0" - eslint-scope: "npm:^8.4.0" - eslint-visitor-keys: "npm:^4.2.1" - espree: "npm:^10.4.0" - esquery: "npm:^1.5.0" - esutils: "npm:^2.0.2" - fast-deep-equal: "npm:^3.1.3" - file-entry-cache: "npm:^8.0.0" - find-up: "npm:^5.0.0" - glob-parent: "npm:^6.0.2" - ignore: "npm:^5.2.0" - imurmurhash: "npm:^0.1.4" - is-glob: "npm:^4.0.0" - json-stable-stringify-without-jsonify: "npm:^1.0.1" - lodash.merge: "npm:^4.6.2" - minimatch: "npm:^3.1.5" - natural-compare: "npm:^1.4.0" - optionator: "npm:^0.9.3" - peerDependencies: - jiti: "*" - peerDependenciesMeta: - jiti: - optional: true - bin: - eslint: bin/eslint.js - checksum: 10c0/1955067c2d991f0c84f4c4abfafe31bb47fa3b717a7fd3e43fe1e511c6f859d7700cbca969f85661dc4c130f7aeced5e5444884314198a54428f5e5141db9337 - languageName: node - linkType: hard - -"espree@npm:^10.0.1, espree@npm:^10.4.0": - version: 10.4.0 - resolution: "espree@npm:10.4.0" +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" dependencies: - acorn: "npm:^8.15.0" - acorn-jsx: "npm:^5.3.2" - eslint-visitor-keys: "npm:^4.2.1" - checksum: 10c0/c63fe06131c26c8157b4083313cb02a9a54720a08e21543300e55288c40e06c3fc284bdecf108d3a1372c5934a0a88644c98714f38b6ae8ed272b40d9ea08d6b + 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 -"esquery@npm:^1.5.0": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" - dependencies: - estraverse: "npm:^5.1.0" - checksum: 10c0/cb9065ec605f9da7a76ca6dadb0619dfb611e37a81e318732977d90fab50a256b95fee2d925fba7c2f3f0523aa16f91587246693bc09bc34d5a59575fe6e93d2 +"es6-error@npm:^4.1.1": + version: 4.1.1 + resolution: "es6-error@npm:4.1.1" + checksum: 10c0/357663fb1e845c047d548c3d30f86e005db71e122678f4184ced0693f634688c3f3ef2d7de7d4af732f734de01f528b05954e270f06aa7d133679fb9fe6600ef languageName: node linkType: hard -"esrecurse@npm:^4.3.0": - version: 4.3.0 - resolution: "esrecurse@npm:4.3.0" - dependencies: - estraverse: "npm:^5.2.0" - checksum: 10c0/81a37116d1408ded88ada45b9fb16dbd26fba3aadc369ce50fcaf82a0bac12772ebd7b24cd7b91fc66786bf2c1ac7b5f196bc990a473efff972f5cb338877cf5 +"escape-string-regexp@npm:^4.0.0": + version: 4.0.0 + resolution: "escape-string-regexp@npm:4.0.0" + checksum: 10c0/9497d4dd307d845bd7f75180d8188bb17ea8c151c1edbf6b6717c100e104d629dc2dfb687686181b0f4b7d732c7dfdc4d5e7a8ff72de1b0ca283a75bbb3a9cd9 languageName: node linkType: hard -"estraverse@npm:^5.1.0, estraverse@npm:^5.2.0": - version: 5.3.0 - resolution: "estraverse@npm:5.3.0" - checksum: 10c0/1ff9447b96263dec95d6d67431c5e0771eb9776427421260a3e2f0fdd5d6bd4f8e37a7338f5ad2880c9f143450c9b1e4fc2069060724570a49cf9cf0312bd107 +"eslint-plugin-check-file@npm:3.3.1": + version: 3.3.1 + resolution: "eslint-plugin-check-file@npm:3.3.1" + dependencies: + is-glob: "npm:^4.0.3" + micromatch: "npm:^4.0.8" + peerDependencies: + eslint: ">=9.0.0" + checksum: 10c0/1a6712a493f48b5c48a96f15b0a75f5d8f842f3cac122a57febd9349b6251d2ebb3e908cebc419be598e97df4d4f109d0c6d2bc0aafde16673328fc42d0ba308 languageName: node linkType: hard -"esutils@npm:^2.0.2": - version: 2.0.3 - resolution: "esutils@npm:2.0.3" - checksum: 10c0/9a2fe69a41bfdade834ba7c42de4723c97ec776e40656919c62cbd13607c45e127a003f05f724a1ea55e5029a4cf2de444b13009f2af71271e42d93a637137c7 +"eslint-plugin-playwright@npm:2.10.4": + version: 2.10.4 + resolution: "eslint-plugin-playwright@npm:2.10.4" + dependencies: + globals: "npm:^17.3.0" + peerDependencies: + eslint: ">=8.40.0" + checksum: 10c0/515aac2a870b217f23637bfdab26b9125e47cff91625dbafe20617b3522430fdee8961780f6b7dc9fcfcdbec8235ccd30eafd0ac871088cf3af4d70b8f9b7e29 languageName: node linkType: hard @@ -2462,7 +2273,7 @@ __metadata: languageName: node linkType: hard -"fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": +"fast-deep-equal@npm:^3.1.1": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" checksum: 10c0/40dedc862eb8992c54579c66d914635afbec43350afbbe991235fdcb4e3a8d5af1b23ae7e79bef7d4882d0ecee06c3197488026998fb19f72dc95acff1d1b1d0 @@ -2483,13 +2294,6 @@ __metadata: languageName: node linkType: hard -"fast-levenshtein@npm:^2.0.6": - version: 2.0.6 - resolution: "fast-levenshtein@npm:2.0.6" - checksum: 10c0/111972b37338bcb88f7d9e2c5907862c280ebf4234433b95bc611e518d192ccb2d38119c4ac86e26b668d75f7f3894f4ff5c4982899afced7ca78633b08287c4 - languageName: node - linkType: hard - "fd-slicer@npm:~1.1.0": version: 1.1.0 resolution: "fd-slicer@npm:1.1.0" @@ -2499,18 +2303,6 @@ __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" @@ -2525,15 +2317,6 @@ __metadata: languageName: node linkType: hard -"file-entry-cache@npm:^8.0.0": - version: 8.0.0 - resolution: "file-entry-cache@npm:8.0.0" - dependencies: - flat-cache: "npm:^4.0.0" - checksum: 10c0/9e2b5938b1cd9b6d7e3612bdc533afd4ac17b2fc646569e9a8abbf2eb48e5eb8e316bc38815a3ef6a1b456f4107f0d0f055a614ca613e75db6bf9ff4d72c1638 - languageName: node - linkType: hard - "file-type@npm:^20.5.0": version: 20.5.0 resolution: "file-type@npm:20.5.0" @@ -2576,33 +2359,6 @@ __metadata: languageName: node linkType: hard -"find-up@npm:^5.0.0": - version: 5.0.0 - resolution: "find-up@npm:5.0.0" - dependencies: - locate-path: "npm:^6.0.0" - path-exists: "npm:^4.0.0" - checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a - languageName: node - linkType: hard - -"flat-cache@npm:^4.0.0": - version: 4.0.1 - resolution: "flat-cache@npm:4.0.1" - dependencies: - flatted: "npm:^3.2.9" - keyv: "npm:^4.5.4" - checksum: 10c0/2c59d93e9faa2523e4fda6b4ada749bed432cfa28c8e251f33b25795e426a1c6dbada777afb1f74fcfff33934fdbdea921ee738fcc33e71adc9d6eca984a1cfc - languageName: node - linkType: hard - -"flatted@npm:^3.2.9": - version: 3.3.2 - resolution: "flatted@npm:3.3.2" - checksum: 10c0/24cc735e74d593b6c767fe04f2ef369abe15b62f6906158079b9874bdb3ee5ae7110bb75042e70cd3f99d409d766f357caf78d5ecee9780206f5fdc5edbad334 - languageName: node - linkType: hard - "fn.name@npm:1.x.x": version: 1.1.0 resolution: "fn.name@npm:1.1.0" @@ -2645,6 +2401,19 @@ __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" @@ -2714,7 +2483,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.1 resolution: "get-intrinsic@npm:1.3.1" dependencies: @@ -2771,15 +2540,6 @@ __metadata: languageName: node linkType: hard -"glob-parent@npm:^6.0.2": - version: 6.0.2 - resolution: "glob-parent@npm:6.0.2" - dependencies: - is-glob: "npm:^4.0.3" - checksum: 10c0/317034d88654730230b3f43bb7ad4f7c90257a426e872ea0bf157473ac61c99bf5d205fad8f0185f989be8d2fa6d3c7dce1645d99d545b6ea9089c39f838e7f8 - languageName: node - linkType: hard - "glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": version: 10.4.5 resolution: "glob@npm:10.4.5" @@ -2810,13 +2570,6 @@ __metadata: languageName: node linkType: hard -"globals@npm:^14.0.0": - version: 14.0.0 - resolution: "globals@npm:14.0.0" - checksum: 10c0/b96ff42620c9231ad468d4c58ff42afee7777ee1c963013ff8aabe095a451d0ceeb8dcd8ef4cbd64d2538cef45f787a78ba3a9574f4a634438963e334471302d - languageName: node - linkType: hard - "globals@npm:^17.3.0": version: 17.3.0 resolution: "globals@npm:17.3.0" @@ -2906,6 +2659,15 @@ __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" @@ -2967,30 +2729,6 @@ __metadata: languageName: node linkType: hard -"ignore@npm:^5.2.0": - version: 5.3.2 - resolution: "ignore@npm:5.3.2" - checksum: 10c0/f9f652c957983634ded1e7f02da3b559a0d4cc210fca3792cb67f1b153623c9c42efdc1c4121af171e295444459fc4a9201101fb041b1104a3c000bccb188337 - languageName: node - linkType: hard - -"ignore@npm:^7.0.5": - version: 7.0.5 - resolution: "ignore@npm:7.0.5" - checksum: 10c0/ae00db89fe873064a093b8999fe4cc284b13ef2a178636211842cceb650b9c3e390d3339191acb145d81ed5379d2074840cf0c33a20bdbd6f32821f79eb4ad5d - languageName: node - linkType: hard - -"import-fresh@npm:^3.2.1": - version: 3.3.0 - resolution: "import-fresh@npm:3.3.0" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10c0/7f882953aa6b740d1f0e384d0547158bc86efbf2eea0f1483b8900a6f65c5a5123c2cf09b0d542cc419d0b98a759ecaeb394237e97ea427f2da221dc3cd80cc3 - languageName: node - linkType: hard - "imurmurhash@npm:^0.1.4": version: 0.1.4 resolution: "imurmurhash@npm:0.1.4" @@ -3076,7 +2814,7 @@ __metadata: languageName: node linkType: hard -"is-glob@npm:^4.0.0, is-glob@npm:^4.0.3": +"is-glob@npm:^4.0.3": version: 4.0.3 resolution: "is-glob@npm:4.0.3" dependencies: @@ -3280,17 +3018,6 @@ __metadata: languageName: node linkType: hard -"js-yaml@npm:^4.1.1": - version: 4.1.1 - resolution: "js-yaml@npm:4.1.1" - dependencies: - argparse: "npm:^2.0.1" - bin: - js-yaml: bin/js-yaml.js - checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 - languageName: node - linkType: hard - "jsbn@npm:1.1.0": version: 1.1.0 resolution: "jsbn@npm:1.1.0" @@ -3312,13 +3039,6 @@ __metadata: languageName: node linkType: hard -"json-buffer@npm:3.0.1": - version: 3.0.1 - resolution: "json-buffer@npm:3.0.1" - checksum: 10c0/0d1c91569d9588e7eef2b49b59851f297f3ab93c7b35c7c221e288099322be6b562767d11e4821da500f3219542b9afd2e54c5dc573107c1126ed1080f8e96d7 - languageName: node - linkType: hard - "json-schema-traverse@npm:^0.4.1": version: 0.4.1 resolution: "json-schema-traverse@npm:0.4.1" @@ -3333,13 +3053,6 @@ __metadata: languageName: node linkType: hard -"json-stable-stringify-without-jsonify@npm:^1.0.1": - version: 1.0.1 - resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" - checksum: 10c0/cb168b61fd4de83e58d09aaa6425ef71001bae30d260e2c57e7d09a5fd82223e2f22a042dedaab8db23b7d9ae46854b08bb1f91675a8be11c5cffebef5fb66a5 - languageName: node - linkType: hard - "json-stringify-safe@npm:^5.0.1, json-stringify-safe@npm:~5.0.1": version: 5.0.1 resolution: "json-stringify-safe@npm:5.0.1" @@ -3412,15 +3125,6 @@ __metadata: languageName: node linkType: hard -"keyv@npm:^4.5.4": - version: 4.5.4 - resolution: "keyv@npm:4.5.4" - dependencies: - json-buffer: "npm:3.0.1" - checksum: 10c0/aa52f3c5e18e16bb6324876bb8b59dd02acf782a4b789c7b2ae21107fab95fab3890ed448d4f8dba80ce05391eeac4bfabb4f02a20221342982f806fa2cf271e - languageName: node - linkType: hard - "kuler@npm:^2.0.0": version: 2.0.0 resolution: "kuler@npm:2.0.0" @@ -3428,25 +3132,6 @@ __metadata: languageName: node linkType: hard -"levn@npm:^0.4.1": - version: 0.4.1 - resolution: "levn@npm:0.4.1" - dependencies: - prelude-ls: "npm:^1.2.1" - type-check: "npm:~0.4.0" - checksum: 10c0/effb03cad7c89dfa5bd4f6989364bfc79994c2042ec5966cb9b95990e2edee5cd8969ddf42616a0373ac49fac1403437deaf6e9050fbbaa3546093a59b9ac94e - languageName: node - linkType: hard - -"locate-path@npm:^6.0.0": - version: 6.0.0 - resolution: "locate-path@npm:6.0.0" - dependencies: - p-locate: "npm:^5.0.0" - checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3 - languageName: node - linkType: hard - "lodash.defaults@npm:^4.2.0": version: 4.2.0 resolution: "lodash.defaults@npm:4.2.0" @@ -3503,13 +3188,6 @@ __metadata: languageName: node linkType: hard -"lodash.merge@npm:^4.6.2": - version: 4.6.2 - resolution: "lodash.merge@npm:4.6.2" - checksum: 10c0/402fa16a1edd7538de5b5903a90228aa48eb5533986ba7fa26606a49db2572bf414ff73a2c9f5d5fd36b31c46a5d5c7e1527749c07cbcf965ccff5fbdf32c506 - languageName: node - linkType: hard - "lodash.once@npm:^4.0.0": version: 4.1.1 resolution: "lodash.once@npm:4.1.1" @@ -3616,7 +3294,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:^2.1.12, mime-types@npm:~2.1.19": +"mime-types@npm:^2.1.12, mime-types@npm:^2.1.35, mime-types@npm:~2.1.19": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -3625,24 +3303,6 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.2.2": - version: 10.2.4 - resolution: "minimatch@npm:10.2.4" - dependencies: - brace-expansion: "npm:^5.0.2" - checksum: 10c0/35f3dfb7b99b51efd46afd378486889f590e7efb10e0f6a10ba6800428cf65c9a8dedb74427d0570b318d749b543dc4e85f06d46d2858bc8cac7e1eb49a95945 - languageName: node - linkType: hard - -"minimatch@npm:^3.1.5": - version: 3.1.5 - resolution: "minimatch@npm:3.1.5" - dependencies: - brace-expansion: "npm:^1.1.7" - checksum: 10c0/2ecbdc0d33f07bddb0315a8b5afbcb761307a8778b48f0b312418ccbced99f104a2d17d8aca7573433c70e8ccd1c56823a441897a45e384ea76ef401a26ace70 - languageName: node - linkType: hard - "minimatch@npm:^9.0.4": version: 9.0.5 resolution: "minimatch@npm:9.0.5" @@ -3800,13 +3460,6 @@ __metadata: languageName: node linkType: hard -"natural-compare@npm:^1.4.0": - version: 1.4.0 - resolution: "natural-compare@npm:1.4.0" - checksum: 10c0/f5f9a7974bfb28a91afafa254b197f0f22c684d4a1731763dda960d2c8e375b36c7d690e0d9dc8fba774c537af14a7e979129bca23d88d052fbeb9466955e447 - languageName: node - linkType: hard - "negotiator@npm:^0.6.3": version: 0.6.3 resolution: "negotiator@npm:0.6.3" @@ -3946,20 +3599,6 @@ __metadata: languageName: node linkType: hard -"optionator@npm:^0.9.3": - version: 0.9.3 - resolution: "optionator@npm:0.9.3" - dependencies: - "@aashutoshrathi/word-wrap": "npm:^1.2.3" - deep-is: "npm:^0.1.3" - fast-levenshtein: "npm:^2.0.6" - levn: "npm:^0.4.1" - prelude-ls: "npm:^1.2.1" - type-check: "npm:^0.4.0" - checksum: 10c0/66fba794d425b5be51353035cf3167ce6cfa049059cbb93229b819167687e0f48d2bc4603fcb21b091c99acb516aae1083624675b15c4765b2e4693a085e959c - languageName: node - linkType: hard - "otplib@npm:12.0.1": version: 12.0.1 resolution: "otplib@npm:12.0.1" @@ -3971,21 +3610,185 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.0.2": - version: 3.1.0 - resolution: "p-limit@npm:3.1.0" - dependencies: - yocto-queue: "npm:^0.1.0" - checksum: 10c0/9db675949dbdc9c3763c89e748d0ef8bdad0afbb24d49ceaf4c46c02c77d30db4e0652ed36d0a0a7a95154335fab810d95c86153105bb73b3a90448e2bb14e1a +"oxfmt@npm:0.56.0": + version: 0.56.0 + resolution: "oxfmt@npm:0.56.0" + dependencies: + "@oxfmt/binding-android-arm-eabi": "npm:0.56.0" + "@oxfmt/binding-android-arm64": "npm:0.56.0" + "@oxfmt/binding-darwin-arm64": "npm:0.56.0" + "@oxfmt/binding-darwin-x64": "npm:0.56.0" + "@oxfmt/binding-freebsd-x64": "npm:0.56.0" + "@oxfmt/binding-linux-arm-gnueabihf": "npm:0.56.0" + "@oxfmt/binding-linux-arm-musleabihf": "npm:0.56.0" + "@oxfmt/binding-linux-arm64-gnu": "npm:0.56.0" + "@oxfmt/binding-linux-arm64-musl": "npm:0.56.0" + "@oxfmt/binding-linux-ppc64-gnu": "npm:0.56.0" + "@oxfmt/binding-linux-riscv64-gnu": "npm:0.56.0" + "@oxfmt/binding-linux-riscv64-musl": "npm:0.56.0" + "@oxfmt/binding-linux-s390x-gnu": "npm:0.56.0" + "@oxfmt/binding-linux-x64-gnu": "npm:0.56.0" + "@oxfmt/binding-linux-x64-musl": "npm:0.56.0" + "@oxfmt/binding-openharmony-arm64": "npm:0.56.0" + "@oxfmt/binding-win32-arm64-msvc": "npm:0.56.0" + "@oxfmt/binding-win32-ia32-msvc": "npm:0.56.0" + "@oxfmt/binding-win32-x64-msvc": "npm:0.56.0" + tinypool: "npm:2.1.0" + peerDependencies: + svelte: ^5.0.0 + vite-plus: "*" + dependenciesMeta: + "@oxfmt/binding-android-arm-eabi": + optional: true + "@oxfmt/binding-android-arm64": + optional: true + "@oxfmt/binding-darwin-arm64": + optional: true + "@oxfmt/binding-darwin-x64": + optional: true + "@oxfmt/binding-freebsd-x64": + optional: true + "@oxfmt/binding-linux-arm-gnueabihf": + optional: true + "@oxfmt/binding-linux-arm-musleabihf": + optional: true + "@oxfmt/binding-linux-arm64-gnu": + optional: true + "@oxfmt/binding-linux-arm64-musl": + optional: true + "@oxfmt/binding-linux-ppc64-gnu": + optional: true + "@oxfmt/binding-linux-riscv64-gnu": + optional: true + "@oxfmt/binding-linux-riscv64-musl": + optional: true + "@oxfmt/binding-linux-s390x-gnu": + optional: true + "@oxfmt/binding-linux-x64-gnu": + optional: true + "@oxfmt/binding-linux-x64-musl": + optional: true + "@oxfmt/binding-openharmony-arm64": + optional: true + "@oxfmt/binding-win32-arm64-msvc": + optional: true + "@oxfmt/binding-win32-ia32-msvc": + optional: true + "@oxfmt/binding-win32-x64-msvc": + optional: true + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true + bin: + oxfmt: bin/oxfmt + checksum: 10c0/f17ef097ebdc58a7ec4afa987b45ded016de5302753652c8f8c22d657f233fa35b36ffd8a4dd6490a6be4673051a5fc5b204890e69b606fffbe0ae35804bb0db languageName: node linkType: hard -"p-locate@npm:^5.0.0": - version: 5.0.0 - resolution: "p-locate@npm:5.0.0" +"oxlint-tsgolint@npm:0.23.0": + version: 0.23.0 + resolution: "oxlint-tsgolint@npm:0.23.0" dependencies: - p-limit: "npm:^3.0.2" - checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a + "@oxlint-tsgolint/darwin-arm64": "npm:0.23.0" + "@oxlint-tsgolint/darwin-x64": "npm:0.23.0" + "@oxlint-tsgolint/linux-arm64": "npm:0.23.0" + "@oxlint-tsgolint/linux-x64": "npm:0.23.0" + "@oxlint-tsgolint/win32-arm64": "npm:0.23.0" + "@oxlint-tsgolint/win32-x64": "npm:0.23.0" + dependenciesMeta: + "@oxlint-tsgolint/darwin-arm64": + optional: true + "@oxlint-tsgolint/darwin-x64": + optional: true + "@oxlint-tsgolint/linux-arm64": + optional: true + "@oxlint-tsgolint/linux-x64": + optional: true + "@oxlint-tsgolint/win32-arm64": + optional: true + "@oxlint-tsgolint/win32-x64": + optional: true + bin: + tsgolint: bin/tsgolint.js + checksum: 10c0/052035ea9fe2fe654aa1187ba30f842e1c44883bec34fffbb959dd86b5cfd0067af1bf0c27897b720f28346b3641422672e35913db8940f3e02728e459a1d8f1 + languageName: node + linkType: hard + +"oxlint@npm:1.71.0": + version: 1.71.0 + resolution: "oxlint@npm:1.71.0" + dependencies: + "@oxlint/binding-android-arm-eabi": "npm:1.71.0" + "@oxlint/binding-android-arm64": "npm:1.71.0" + "@oxlint/binding-darwin-arm64": "npm:1.71.0" + "@oxlint/binding-darwin-x64": "npm:1.71.0" + "@oxlint/binding-freebsd-x64": "npm:1.71.0" + "@oxlint/binding-linux-arm-gnueabihf": "npm:1.71.0" + "@oxlint/binding-linux-arm-musleabihf": "npm:1.71.0" + "@oxlint/binding-linux-arm64-gnu": "npm:1.71.0" + "@oxlint/binding-linux-arm64-musl": "npm:1.71.0" + "@oxlint/binding-linux-ppc64-gnu": "npm:1.71.0" + "@oxlint/binding-linux-riscv64-gnu": "npm:1.71.0" + "@oxlint/binding-linux-riscv64-musl": "npm:1.71.0" + "@oxlint/binding-linux-s390x-gnu": "npm:1.71.0" + "@oxlint/binding-linux-x64-gnu": "npm:1.71.0" + "@oxlint/binding-linux-x64-musl": "npm:1.71.0" + "@oxlint/binding-openharmony-arm64": "npm:1.71.0" + "@oxlint/binding-win32-arm64-msvc": "npm:1.71.0" + "@oxlint/binding-win32-ia32-msvc": "npm:1.71.0" + "@oxlint/binding-win32-x64-msvc": "npm:1.71.0" + peerDependencies: + oxlint-tsgolint: ">=0.22.1" + vite-plus: "*" + dependenciesMeta: + "@oxlint/binding-android-arm-eabi": + optional: true + "@oxlint/binding-android-arm64": + optional: true + "@oxlint/binding-darwin-arm64": + optional: true + "@oxlint/binding-darwin-x64": + optional: true + "@oxlint/binding-freebsd-x64": + optional: true + "@oxlint/binding-linux-arm-gnueabihf": + optional: true + "@oxlint/binding-linux-arm-musleabihf": + optional: true + "@oxlint/binding-linux-arm64-gnu": + optional: true + "@oxlint/binding-linux-arm64-musl": + optional: true + "@oxlint/binding-linux-ppc64-gnu": + optional: true + "@oxlint/binding-linux-riscv64-gnu": + optional: true + "@oxlint/binding-linux-riscv64-musl": + optional: true + "@oxlint/binding-linux-s390x-gnu": + optional: true + "@oxlint/binding-linux-x64-gnu": + optional: true + "@oxlint/binding-linux-x64-musl": + optional: true + "@oxlint/binding-openharmony-arm64": + optional: true + "@oxlint/binding-win32-arm64-msvc": + optional: true + "@oxlint/binding-win32-ia32-msvc": + optional: true + "@oxlint/binding-win32-x64-msvc": + optional: true + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + bin: + oxlint: bin/oxlint + checksum: 10c0/c5374ea8b3ac130cc60f4217cc72bf4d047535f6d1b8e414fa587ffcf1c7ca283010b82282385898f1039efe85e4931d73480970b295dcd4bb8bab7a91317b22 languageName: node linkType: hard @@ -4005,22 +3808,6 @@ __metadata: languageName: node linkType: hard -"parent-module@npm:^1.0.0": - version: 1.0.1 - resolution: "parent-module@npm:1.0.1" - dependencies: - callsites: "npm:^3.0.0" - checksum: 10c0/c63d6e80000d4babd11978e0d3fee386ca7752a02b035fd2435960ffaa7219dc42146f07069fb65e6e8bf1caef89daf9af7535a39bddf354d78bf50d8294f556 - languageName: node - linkType: hard - -"path-exists@npm:^4.0.0": - version: 4.0.0 - resolution: "path-exists@npm:4.0.0" - checksum: 10c0/8c0bd3f5238188197dc78dced15207a4716c51cc4e3624c44fc97acf69558f5ebb9a2afff486fe1b4ee148e0c133e96c5e11a9aa5c48a3006e3467da070e5e1b - languageName: node - linkType: hard - "path-key@npm:^3.1.0": version: 3.1.1 resolution: "path-key@npm:3.1.1" @@ -4147,13 +3934,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3": - version: 4.0.3 - resolution: "picomatch@npm:4.0.3" - checksum: 10c0/9582c951e95eebee5434f59e426cddd228a7b97a0161a375aed4be244bd3fe8e3a31b846808ea14ef2c8a2527a6eeab7b3946a67d5979e81694654f939473ae2 - languageName: node - linkType: hard - "pify@npm:^2.3.0": version: 2.3.0 resolution: "pify@npm:2.3.0" @@ -4245,34 +4025,6 @@ __metadata: languageName: node linkType: hard -"prelude-ls@npm:^1.2.1": - version: 1.2.1 - resolution: "prelude-ls@npm:1.2.1" - checksum: 10c0/b00d617431e7886c520a6f498a2e14c75ec58f6d93ba48c3b639cf241b54232d90daa05d83a9e9b9fef6baa63cb7e1e4602c2372fea5bc169668401eb127d0cd - languageName: node - linkType: hard - -"prettier-plugin-sh@npm:0.18.1": - version: 0.18.1 - resolution: "prettier-plugin-sh@npm:0.18.1" - dependencies: - "@reteps/dockerfmt": "npm:^0.5.1" - sh-syntax: "npm:^0.5.8" - peerDependencies: - prettier: ^3.6.0 - checksum: 10c0/e79a8dab1b9cd966bf078b1ca4ec412d190fb905ca6bfb89f841d309ed39ecb7136901b64919e3d8e52e9233d658b64bc69203ff81384914ec90dedbcbe804da - languageName: node - linkType: hard - -"prettier@npm:3.8.3": - version: 3.8.3 - resolution: "prettier@npm:3.8.3" - bin: - prettier: bin/prettier.cjs - checksum: 10c0/754816fd7593eb80f6376d7476d463e832c38a12f32775a82683adb6e35b772b1f484d65f19401507b983a8c8a7cd5a4a9f12006bd56491e8f35503473f77473 - languageName: node - linkType: hard - "proc-log@npm:^4.1.0, proc-log@npm:^4.2.0": version: 4.2.0 resolution: "proc-log@npm:4.2.0" @@ -4395,13 +4147,6 @@ __metadata: languageName: node linkType: hard -"resolve-from@npm:^4.0.0": - version: 4.0.0 - resolution: "resolve-from@npm:4.0.0" - checksum: 10c0/8408eec31a3112ef96e3746c37be7d64020cda07c03a920f5024e77290a218ea758b26ca9529fd7b1ad283947f34b2291c1c0f6aa0ed34acfdda9c6014c8d190 - languageName: node - linkType: hard - "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -4513,15 +4258,6 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.7.3": - version: 7.7.3 - resolution: "semver@npm:7.7.3" - bin: - semver: bin/semver.js - checksum: 10c0/4afe5c986567db82f44c8c6faef8fe9df2a9b1d98098fc1721f57c696c4c21cebd572f297fc21002f81889492345b8470473bc6f4aff5fb032a6ea59ea2bc45e - languageName: node - linkType: hard - "serialize-error@npm:^7.0.1": version: 7.0.1 resolution: "serialize-error@npm:7.0.1" @@ -4545,15 +4281,6 @@ __metadata: languageName: node linkType: hard -"sh-syntax@npm:^0.5.8": - version: 0.5.8 - resolution: "sh-syntax@npm:0.5.8" - dependencies: - tslib: "npm:^2.8.1" - checksum: 10c0/2d2609fc8760ef97175c852be26ee3eeb196078c5aec282c8b96a59ee362be4f470d3e0df4e372da6c7a7b44ccc42910cbdcb0915271b3cb6a6212d00dde116f - languageName: node - linkType: hard - "shebang-command@npm:^2.0.0": version: 2.0.0 resolution: "shebang-command@npm:2.0.0" @@ -4772,13 +4499,6 @@ __metadata: languageName: node linkType: hard -"strip-json-comments@npm:^3.1.1": - version: 3.1.1 - resolution: "strip-json-comments@npm:3.1.1" - checksum: 10c0/9681a6257b925a7fa0f285851c0e613cc934a50661fa7bb41ca9cbbff89686bb4a0ee366e6ecedc4daafd01e83eee0720111ab294366fe7c185e935475ebcecd - languageName: node - linkType: hard - "strtok3@npm:^10.2.0": version: 10.3.5 resolution: "strtok3@npm:10.3.5" @@ -4891,13 +4611,10 @@ __metadata: languageName: node linkType: hard -"tinyglobby@npm:^0.2.15": - version: 0.2.15 - resolution: "tinyglobby@npm:0.2.15" - dependencies: - fdir: "npm:^6.5.0" - picomatch: "npm:^4.0.3" - checksum: 10c0/869c31490d0d88eedb8305d178d4c75e7463e820df5a9b9d388291daf93e8b1eb5de1dad1c1e139767e4269fe75f3b10d5009b2cc14db96ff98986920a186844 +"tinypool@npm:2.1.0": + version: 2.1.0 + resolution: "tinypool@npm:2.1.0" + checksum: 10c0/9fb1c760558c6264e0f4cfde96a63b12450b43f1730fbe6274aa24ddbdf488745c08924d0dea7a1303b47d555416a6415f2113898c69b6ecf731e75ac95238a5 languageName: node linkType: hard @@ -4963,15 +4680,6 @@ __metadata: languageName: node linkType: hard -"ts-api-utils@npm:^2.5.0": - version: 2.5.0 - resolution: "ts-api-utils@npm:2.5.0" - peerDependencies: - typescript: ">=4.8.4" - checksum: 10c0/767849383c114e7f1971fa976b20e73ac28fd0c70d8d65c0004790bf4d8f89888c7e4cf6d5949f9c1beae9bc3c64835bef77bbe27fddf45a3c7b60cebcf85c8c - 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": version: 2.8.1 resolution: "tslib@npm:2.8.1" @@ -4995,15 +4703,6 @@ __metadata: languageName: node linkType: hard -"type-check@npm:^0.4.0, type-check@npm:~0.4.0": - version: 0.4.0 - resolution: "type-check@npm:0.4.0" - dependencies: - prelude-ls: "npm:^1.2.1" - checksum: 10c0/7b3fd0ed43891e2080bf0c5c504b418fbb3e5c7b9708d3d015037ba2e6323a28152ec163bcb65212741fa5d2022e3075ac3c76440dbd344c9035f818e8ecee58 - languageName: node - linkType: hard - "type-fest@npm:^0.13.1": version: 0.13.1 resolution: "type-fest@npm:0.13.1" @@ -5022,38 +4721,23 @@ __metadata: languageName: node linkType: hard -"typescript-eslint@npm:8.59.4": - version: 8.59.4 - resolution: "typescript-eslint@npm:8.59.4" - dependencies: - "@typescript-eslint/eslint-plugin": "npm:8.59.4" - "@typescript-eslint/parser": "npm:8.59.4" - "@typescript-eslint/typescript-estree": "npm:8.59.4" - "@typescript-eslint/utils": "npm:8.59.4" - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: ">=4.8.4 <6.1.0" - checksum: 10c0/96241e50eac4e646e56b7950405aa861ff2f744e4268c98e240ee702db0b45463a1e9146f09fbc71bfd8dc53b2b3c43c2f1fab6a92154c7e1c2b7373bcd5c90e - languageName: node - linkType: hard - -"typescript@npm:5.9.3": - version: 5.9.3 - resolution: "typescript@npm:5.9.3" +"typescript@npm:6.0.3": + version: 6.0.3 + resolution: "typescript@npm:6.0.3" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/6bd7552ce39f97e711db5aa048f6f9995b53f1c52f7d8667c1abdc1700c68a76a308f579cd309ce6b53646deb4e9a1be7c813a93baaf0a28ccd536a30270e1c5 + checksum: 10c0/4a25ff5045b984370f48f196b3a0120779b1b343d40b9a68d114ea5e5fff099809b2bb777576991a63a5cd59cf7bffd96ff6fe10afcefbcb8bd6fb96ad4b6606 languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.9.3#optional!builtin": - version: 5.9.3 - resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" +"typescript@patch:typescript@npm%3A6.0.3#optional!builtin": + version: 6.0.3 + resolution: "typescript@patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5" bin: tsc: bin/tsc tsserver: bin/tsserver - checksum: 10c0/ad09fdf7a756814dce65bc60c1657b40d44451346858eea230e10f2e95a289d9183b6e32e5c11e95acc0ccc214b4f36289dcad4bf1886b0adb84d711d336a430 + checksum: 10c0/2f25c74e65663c248fa1ade2b8459d9ce5372ff9dad07067310f132966ebec1d93f6c42f0baf77a6b6a7a91460463f708e6887013aaade22111037457c6b25df languageName: node linkType: hard @@ -5372,10 +5056,3 @@ __metadata: checksum: 10c0/935e32054171104bdf8a4091180f61b5698d8b90ee64552bb643c2176f815d4215d0764e3f41e0d9a1e4525b37602bf145ec5fd39dd014f0be7290851ce3acce languageName: node linkType: hard - -"yocto-queue@npm:^0.1.0": - version: 0.1.0 - resolution: "yocto-queue@npm:0.1.0" - checksum: 10c0/dceb44c28578b31641e13695d200d34ec4ab3966a5729814d5445b194933c096b7ced71494ce53a0e8820685d1d010df8b2422e5bf2cdea7e469d97ffbea306f - languageName: node - linkType: hard