diff --git a/workspaces/backstage/e2e-tests/package.json b/workspaces/backstage/e2e-tests/package.json index 88b0b43a07..991c4250e9 100644 --- a/workspaces/backstage/e2e-tests/package.json +++ b/workspaces/backstage/e2e-tests/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@eslint/js": "10.0.1", "@playwright/test": "1.59.1", - "@red-hat-developer-hub/e2e-test-utils": "2.1.6", + "@red-hat-developer-hub/e2e-test-utils": "2.1.7", "@types/node": "25.5.2", "eslint": "10.2.0", "eslint-plugin-check-file": "3.3.1", diff --git a/workspaces/backstage/e2e-tests/playwright.config.ts b/workspaces/backstage/e2e-tests/playwright.config.ts index d258aa29c1..5052324754 100644 --- a/workspaces/backstage/e2e-tests/playwright.config.ts +++ b/workspaces/backstage/e2e-tests/playwright.config.ts @@ -63,5 +63,9 @@ export default playwrightDefineConfig({ name: "backstage-auth", testMatch: /tests\/specs\/auth\.spec\.ts/, }, + { + name: "backstage-gitlab-auth", + testMatch: /tests\/specs\/gitlab-auth\.spec\.ts/, + }, ], }); diff --git a/workspaces/backstage/e2e-tests/support/api/gitlab-oauth-helper.ts b/workspaces/backstage/e2e-tests/support/api/gitlab-oauth-helper.ts new file mode 100644 index 0000000000..644ccb3854 --- /dev/null +++ b/workspaces/backstage/e2e-tests/support/api/gitlab-oauth-helper.ts @@ -0,0 +1,109 @@ +export interface GitLabOAuthApp { + id: number; + applicationId: string; + applicationName: string; + secret: string; + callbackUrl: string; + scopes: string[]; +} + +/** + * GitLab OAuth application helper for auth-provider e2e. + * Ported from RHDH core (gitlab-helper) — create/delete OAuth apps only. + */ +export class GitLabOAuthHelper { + private readonly personalAccessToken: string; + private readonly apiBaseUrl: string; + + constructor(host: string, personalAccessToken: string) { + const cleanHost = host.replace(/^https?:\/\//, ""); + this.apiBaseUrl = `https://${cleanHost}/api/v4`; + this.personalAccessToken = personalAccessToken; + } + + async createOAuthApplication( + name: string, + redirectUri: string, + scopes = "api read_user write_repository sudo", + trusted = true, + ): Promise { + const response = await fetch(`${this.apiBaseUrl}/applications`, { + method: "POST", + headers: { + // GitLab API header name + // eslint-disable-next-line @typescript-eslint/naming-convention + "PRIVATE-TOKEN": this.personalAccessToken, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + // eslint-disable-next-line @typescript-eslint/naming-convention + redirect_uri: redirectUri, + scopes, + trusted, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to create OAuth application: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + + const app = (await response.json()) as Record; + const id = app.id; + const applicationId = app.application_id; + const secret = app.secret; + if ( + typeof id !== "number" || + typeof applicationId !== "string" || + typeof secret !== "string" + ) { + throw new TypeError( + "GitLab API response missing required fields (id, application_id, or secret)", + ); + } + + const applicationName = + (typeof app.application_name === "string" && app.application_name) || + (typeof app.name === "string" && app.name) || + name; + const callbackUrl = + (typeof app.callback_url === "string" && app.callback_url) || + (typeof app.redirect_uri === "string" && app.redirect_uri) || + redirectUri; + const responseScopes = Array.isArray(app.scopes) + ? app.scopes.filter((s): s is string => typeof s === "string") + : scopes.split(" "); + + return { + id, + applicationId, + applicationName, + secret, + callbackUrl, + scopes: responseScopes, + }; + } + + async deleteOAuthApplication(applicationId: number): Promise { + const response = await fetch( + `${this.apiBaseUrl}/applications/${applicationId}`, + { + method: "DELETE", + headers: { + // eslint-disable-next-line @typescript-eslint/naming-convention + "PRIVATE-TOKEN": this.personalAccessToken, + }, + }, + ); + + if (!response.ok && response.status !== 404) { + const errorText = await response.text(); + throw new Error( + `Failed to delete OAuth application: ${response.status} ${response.statusText} - ${errorText}`, + ); + } + } +} diff --git a/workspaces/backstage/e2e-tests/support/constants/gitlab-auth.ts b/workspaces/backstage/e2e-tests/support/constants/gitlab-auth.ts new file mode 100644 index 0000000000..c6c3eaf78b --- /dev/null +++ b/workspaces/backstage/e2e-tests/support/constants/gitlab-auth.ts @@ -0,0 +1,20 @@ +/** Static catalog token from tests/config/gitlab-auth/value-file.yaml */ +export const GITLAB_AUTH_CATALOG_TOKEN = "gitlab-auth-e2e-token"; + +/** Display names expected after GitLab org ingestion (core auth-providers suite). */ +export const GITLAB_INGESTED_USERS = [ + "user1", + "user2", + "user3", + "Administrator", +] as const; + +export const GITLAB_INGESTED_GROUPS = [ + "my-org", + "group1", + "all", + "nested", + "nested_2", +] as const; + +export const GITLAB_LOGIN_USER = "user1"; diff --git a/workspaces/backstage/e2e-tests/support/gitlab/gitlab-login.ts b/workspaces/backstage/e2e-tests/support/gitlab/gitlab-login.ts new file mode 100644 index 0000000000..e3935c6bbd --- /dev/null +++ b/workspaces/backstage/e2e-tests/support/gitlab/gitlab-login.ts @@ -0,0 +1,107 @@ +import { expect, type Locator, type Page } from "@playwright/test"; +import type { UIhelper } from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +/** + * GitLab OAuth popup login (ported from RHDH core Common.gitlabLogin). + * Not yet on LoginHelper in e2e-test-utils. + */ +export async function gitlabLogin( + page: Page, + uiHelper: UIhelper, + username: string, + password: string, +): Promise { + await page.goto("/"); + await page.waitForSelector('p:has-text("Sign in using GitLab")'); + + const [popup] = await Promise.all([ + page.waitForEvent("popup"), + uiHelper.clickButton("Sign In"), + ]); + + await expect(async () => { + await popup.waitForLoadState("domcontentloaded"); + expect(popup).toBeTruthy(); + }).toPass({ + intervals: [5_000, 10_000], + timeout: 20_000, + }); + + try { + await popup.waitForEvent("close", { timeout: 5000 }); + return "Already logged in"; + } catch { + // Popup stayed open — continue with credentials. + } + + await popup.locator("#user_login").click({ timeout: 5000 }); + await popup.locator("#user_login").fill(username, { timeout: 5000 }); + await popup.locator("#user_password").click({ timeout: 5000 }); + await popup.locator("#user_password").fill(password, { timeout: 5000 }); + await popup.getByTestId("sign-in-button").click({ timeout: 5000 }); + + await popup + .waitForLoadState("domcontentloaded", { timeout: 10_000 }) + .catch(() => undefined); + + const twoFactorInput = popup.locator("#user_otp_attempt"); + if (await twoFactorInput.isVisible({ timeout: 5000 }).catch(() => false)) { + await popup.waitForEvent("close", { timeout: 20_000 }); + return "Login successful"; + } + + // GitLab EE button text is "Authorize "; prefer role + testid. + const authorizeCandidates: Locator[] = [ + popup.getByRole("button", { name: /Authorize/ }), + popup.getByTestId("authorize-button"), + popup.locator('button:has-text("Authorize")'), + ]; + + await expect(async () => { + for (const candidate of authorizeCandidates) { + if (await candidate.isVisible({ timeout: 2000 }).catch(() => false)) { + return; + } + } + throw new Error("Authorization button not found"); + }).toPass({ + intervals: [1000, 2000], + timeout: 15_000, + }); + + let buttonToClick: Locator | undefined; + for (const candidate of authorizeCandidates) { + if (await candidate.isVisible().catch(() => false)) { + buttonToClick = candidate; + break; + } + } + if (!buttonToClick) { + throw new Error("Failed to find authorization button"); + } + + await popup + .getByRole("document") + .click({ timeout: 1000 }) + .catch(() => undefined); + + await buttonToClick.waitFor({ state: "visible", timeout: 5000 }); + await expect(buttonToClick).toBeEnabled({ timeout: 10_000 }); + await buttonToClick.scrollIntoViewIfNeeded({ timeout: 5000 }); + await popup.waitForTimeout(1000); + + try { + await buttonToClick.click({ timeout: 5000 }); + } catch { + await buttonToClick.click({ force: true, timeout: 5000 }); + } + + try { + await popup.waitForEvent("close", { timeout: 20_000 }); + } catch { + if (!popup.isClosed()) { + throw new Error("GitLab login popup did not close after sign-in"); + } + } + return "Login successful"; +} diff --git a/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/app-config-rhdh.yaml b/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/app-config-rhdh.yaml new file mode 100644 index 0000000000..c317c37319 --- /dev/null +++ b/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/app-config-rhdh.yaml @@ -0,0 +1,56 @@ +app: + baseUrl: ${RHDH_BASE_URL} + title: RHDH GitLab Auth E2E + +backend: + baseUrl: ${RHDH_BASE_URL} + cors: + origin: ${RHDH_BASE_URL} + +permission: + enabled: false + +signInPage: gitlab + +auth: + environment: production + providers: + guest: + dangerouslyAllowOutsideDevelopment: true + gitlab: + production: + audience: https://${AUTH_PROVIDERS_GITLAB_HOST} + clientId: ${AUTH_PROVIDERS_GITLAB_CLIENT_ID} + clientSecret: ${AUTH_PROVIDERS_GITLAB_CLIENT_SECRET} + callbackUrl: ${RHDH_BASE_URL}/api/auth/gitlab/handler/frame + # Dynamic gitlab auth provider has no implicit default (unlike core's + # static authProvidersModule). Match upstream default: + # usernameMatchingUserEntityName. + signIn: + resolvers: + - resolver: usernameMatchingUserEntityName + +catalog: + rules: + - allow: [API, Component, Group, Location, Resource, System, Template, User] + providers: + gitlab: + orgProvider: + host: ${AUTH_PROVIDERS_GITLAB_HOST} + orgEnabled: true + group: ${AUTH_PROVIDERS_GITLAB_PARENT_ORG} + restrictUsersToGroup: true + includeUsersWithoutSeat: true + schedule: + initialDelay: + seconds: 0 + frequency: + minutes: 1 + timeout: + minutes: 1 + +integrations: + gitlab: + - host: ${AUTH_PROVIDERS_GITLAB_HOST} + token: ${AUTH_PROVIDERS_GITLAB_TOKEN} + apiBaseUrl: https://${AUTH_PROVIDERS_GITLAB_HOST}/api/v4 diff --git a/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/dynamic-plugins.yaml b/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/dynamic-plugins.yaml new file mode 100644 index 0000000000..36cbd1b104 --- /dev/null +++ b/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/dynamic-plugins.yaml @@ -0,0 +1,24 @@ +plugins: + - package: oci://quay.io/rhdh/red-hat-developer-hub-backstage-plugin-homepage:2.0.0--1.17.1!red-hat-developer-hub-backstage-plugin-homepage + disabled: true + - package: ./dynamic-plugins/dist/backstage-plugin-auth-backend-module-gitlab-provider-dynamic + disabled: false + - package: ./dynamic-plugins/dist/backstage-plugin-catalog-backend-module-gitlab-org-dynamic + disabled: false + pluginConfig: + catalog: + providers: + gitlab: + orgProvider: + host: ${AUTH_PROVIDERS_GITLAB_HOST} + orgEnabled: true + group: ${AUTH_PROVIDERS_GITLAB_PARENT_ORG} + restrictUsersToGroup: true + includeUsersWithoutSeat: true + schedule: + initialDelay: + seconds: 0 + frequency: + minutes: 1 + timeout: + minutes: 1 diff --git a/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/rhdh-secrets.yaml b/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/rhdh-secrets.yaml new file mode 100644 index 0000000000..906e78fb03 --- /dev/null +++ b/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/rhdh-secrets.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: rhdh-secrets +type: Opaque +stringData: + # Vault-synced (must be VAULT_* in CI / vault UI) + AUTH_PROVIDERS_GITLAB_HOST: $VAULT_AUTH_PROVIDERS_GITLAB_HOST + AUTH_PROVIDERS_GITLAB_TOKEN: $VAULT_AUTH_PROVIDERS_GITLAB_TOKEN + AUTH_PROVIDERS_GITLAB_PARENT_ORG: $VAULT_AUTH_PROVIDERS_GITLAB_PARENT_ORG + DEFAULT_USER_PASSWORD: $VAULT_DEFAULT_USER_PASSWORD + # Set dynamically in beforeAll after creating the ephemeral OAuth app + AUTH_PROVIDERS_GITLAB_CLIENT_ID: $AUTH_PROVIDERS_GITLAB_CLIENT_ID + AUTH_PROVIDERS_GITLAB_CLIENT_SECRET: $AUTH_PROVIDERS_GITLAB_CLIENT_SECRET + RHDH_BASE_URL: $RHDH_BASE_URL diff --git a/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/value-file.yaml b/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/value-file.yaml new file mode 100644 index 0000000000..130725edb1 --- /dev/null +++ b/workspaces/backstage/e2e-tests/tests/config/gitlab-auth/value-file.yaml @@ -0,0 +1,15 @@ +# Static external-access token for catalog API calls (Helm-injected appConfig). +global: + lightspeed: + enabled: false + +upstream: + backstage: + appConfig: + backend: + auth: + externalAccess: + - type: static + options: + token: gitlab-auth-e2e-token + subject: gitlab-auth-e2e diff --git a/workspaces/backstage/e2e-tests/tests/specs/auth.spec.ts b/workspaces/backstage/e2e-tests/tests/specs/auth.spec.ts index 4dcc1c8b7b..2cc7c64c87 100644 --- a/workspaces/backstage/e2e-tests/tests/specs/auth.spec.ts +++ b/workspaces/backstage/e2e-tests/tests/specs/auth.spec.ts @@ -4,7 +4,7 @@ const isNightlyMode = !!process.env.E2E_NIGHTLY_MODE || (process.env.JOB_NAME?.includes("periodic-") ?? false); -test.describe("Auth plugin", () => { +test.describe("Auth plugin", { tag: "@auth-tests" }, () => { test.beforeAll(async ({ rhdh }) => { await rhdh.configure({ auth: "guest", diff --git a/workspaces/backstage/e2e-tests/tests/specs/gitlab-auth.spec.ts b/workspaces/backstage/e2e-tests/tests/specs/gitlab-auth.spec.ts new file mode 100644 index 0000000000..32b612729d --- /dev/null +++ b/workspaces/backstage/e2e-tests/tests/specs/gitlab-auth.spec.ts @@ -0,0 +1,261 @@ +import { + expect, + test, + request, +} from "@red-hat-developer-hub/e2e-test-utils/test"; +import { requireEnv } from "@red-hat-developer-hub/e2e-test-utils/utils"; +import { CatalogApiHelper } from "@red-hat-developer-hub/e2e-test-utils/helpers"; + +import { GitLabOAuthHelper } from "../../support/api/gitlab-oauth-helper.js"; +import { + GITLAB_AUTH_CATALOG_TOKEN, + GITLAB_INGESTED_GROUPS, + GITLAB_INGESTED_USERS, + GITLAB_LOGIN_USER, +} from "../../support/constants/gitlab-auth.js"; +import { gitlabLogin } from "../../support/gitlab/gitlab-login.js"; + +const APP_CONFIG_PATH = "tests/config/gitlab-auth/app-config-rhdh.yaml"; + +test.describe.configure({ mode: "serial" }); + +test.describe("GitLab auth and org ingestion", { tag: "@auth-tests" }, () => { + let baseUrl: string; + let oauthHelper: GitLabOAuthHelper; + let oauthAppId: number | null = null; + + test.beforeAll(async ({ rhdh }) => { + test.setTimeout(600_000); + + requireEnv( + "VAULT_AUTH_PROVIDERS_GITLAB_HOST", + "VAULT_AUTH_PROVIDERS_GITLAB_TOKEN", + "VAULT_AUTH_PROVIDERS_GITLAB_PARENT_ORG", + "VAULT_DEFAULT_USER_PASSWORD", + ); + + const host = process.env.VAULT_AUTH_PROVIDERS_GITLAB_HOST!; + const token = process.env.VAULT_AUTH_PROVIDERS_GITLAB_TOKEN!; + + oauthHelper = new GitLabOAuthHelper(host, token); + + await test.runOnce("gitlab-auth-setup", async () => { + await rhdh.configure({ + auth: "guest", + appConfig: APP_CONFIG_PATH, + secrets: "tests/config/gitlab-auth/rhdh-secrets.yaml", + dynamicPlugins: "tests/config/gitlab-auth/dynamic-plugins.yaml", + valueFile: "tests/config/gitlab-auth/value-file.yaml", + }); + + const callbackUrl = `${rhdh.rhdhUrl}/api/auth/gitlab/handler/frame`; + const oauthApp = await oauthHelper.createOAuthApplication( + `rhdh-overlays-gitlab-auth-${Date.now()}`, + callbackUrl, + ); + oauthAppId = oauthApp.id; + + // Injected into rhdh-secrets via envsubst at deploy time + process.env.AUTH_PROVIDERS_GITLAB_CLIENT_ID = oauthApp.applicationId; + process.env.AUTH_PROVIDERS_GITLAB_CLIENT_SECRET = oauthApp.secret; + + await rhdh.deploy(); + }); + + baseUrl = rhdh.rhdhUrl; + }); + + test.beforeEach(async ({ page }) => { + await page.context().clearCookies(); + }); + + test.afterAll(async () => { + if (oauthAppId !== null) { + try { + await oauthHelper.deleteOAuthApplication(oauthAppId); + } catch (error) { + console.error( + "[TEST] Failed to delete GitLab OAuth application:", + error, + ); + } + } + await CatalogApiHelper.dispose(); + }); + + // Core static gitlab auth defaults to usernameMatchingUserEntityName + // (authProvidersModule). Dynamic provider requires that resolver explicitly. + test("Login with GitLab usernameMatchingUserEntityName resolver", async ({ + loginHelper, + uiHelper, + page, + }) => { + test.setTimeout(600_000); + + const login = await gitlabLogin( + page, + uiHelper, + GITLAB_LOGIN_USER, + process.env.VAULT_DEFAULT_USER_PASSWORD!, + ); + expect(login).toBe("Login successful"); + + await page.goto("/settings"); + await uiHelper.verifyHeading(GITLAB_LOGIN_USER); + await loginHelper.signOut(); + }); + + test("Ingestion of GitLab users and groups", async () => { + test.setTimeout(300_000); + + await expect + .poll( + () => + checkUserDisplayNamesInCatalog(baseUrl, [...GITLAB_INGESTED_USERS]), + { timeout: 120_000 }, + ) + .toBe(true); + + expect( + await checkGroupDisplayNamesInCatalog(baseUrl, [ + ...GITLAB_INGESTED_GROUPS, + ]), + ).toBe(true); + + expect( + await CatalogApiHelper.getGroupMembers( + baseUrl, + GITLAB_AUTH_CATALOG_TOKEN, + "all", + ), + ).toEqual(expect.arrayContaining(["user1", "user2", "user3", "root"])); + expect( + await CatalogApiHelper.getGroupMembers( + baseUrl, + GITLAB_AUTH_CATALOG_TOKEN, + "group1", + ), + ).toEqual(expect.arrayContaining(["root"])); + expect( + await CatalogApiHelper.getGroupMembers( + baseUrl, + GITLAB_AUTH_CATALOG_TOKEN, + "group1-nested", + ), + ).toEqual(expect.arrayContaining(["user1", "user2", "root"])); + expect( + await CatalogApiHelper.getGroupMembers( + baseUrl, + GITLAB_AUTH_CATALOG_TOKEN, + "group1-nested-nested_2", + ), + ).toEqual(expect.arrayContaining(["user3", "root"])); + + expect(await groupHasRelation(baseUrl, "group1", "childOf", "my-org")).toBe( + true, + ); + expect( + await groupHasRelation(baseUrl, "my-org", "parentOf", "group1"), + ).toBe(true); + expect(await groupHasRelation(baseUrl, "all", "childOf", "my-org")).toBe( + true, + ); + expect(await groupHasRelation(baseUrl, "my-org", "parentOf", "all")).toBe( + true, + ); + expect( + await groupHasRelation(baseUrl, "group1-nested", "childOf", "group1"), + ).toBe(true); + expect( + await groupHasRelation(baseUrl, "group1", "parentOf", "group1-nested"), + ).toBe(true); + expect( + await groupHasRelation( + baseUrl, + "group1-nested-nested_2", + "childOf", + "group1-nested", + ), + ).toBe(true); + expect( + await groupHasRelation( + baseUrl, + "group1-nested", + "parentOf", + "group1-nested-nested_2", + ), + ).toBe(true); + }); +}); + +async function catalogQuery( + baseUrl: string, + filter: string, +): Promise { + const context = await request.newContext({ ignoreHTTPSErrors: true }); + try { + const url = `${baseUrl}/api/catalog/entities/by-query?orderField=metadata.name%2Casc&filter=${encodeURIComponent(filter)}`; + const response = await context.get(url, { + headers: { Authorization: `Bearer ${GITLAB_AUTH_CATALOG_TOKEN}` }, + }); + if (!response.ok()) { + throw new Error( + `Catalog query failed (${filter}): ${response.status()} ${response.statusText()}`, + ); + } + const body = (await response.json()) as { items?: unknown[] }; + return body.items ?? []; + } finally { + await context.dispose(); + } +} + +function profileDisplayName(entity: unknown): string | undefined { + if (typeof entity !== "object" || entity === null) { + return undefined; + } + const spec = (entity as { spec?: { profile?: { displayName?: unknown } } }) + .spec; + const name = spec?.profile?.displayName; + return typeof name === "string" ? name : undefined; +} + +async function checkUserDisplayNamesInCatalog( + baseUrl: string, + displayNames: string[], +): Promise { + const users = await catalogQuery(baseUrl, "kind=user"); + const found = users + .map(profileDisplayName) + .filter((name): name is string => typeof name === "string"); + return displayNames.every((name) => found.includes(name)); +} + +async function checkGroupDisplayNamesInCatalog( + baseUrl: string, + displayNames: string[], +): Promise { + const groups = await catalogQuery(baseUrl, "kind=group"); + const found = groups + .map(profileDisplayName) + .filter((name): name is string => typeof name === "string"); + return displayNames.every((name) => found.includes(name)); +} + +async function groupHasRelation( + baseUrl: string, + groupName: string, + relationType: string, + relatedName: string, +): Promise { + const entity = await CatalogApiHelper.getGroupEntity( + baseUrl, + GITLAB_AUTH_CATALOG_TOKEN, + groupName, + ); + const names = + entity.relations + ?.filter((r: { type: string }) => r.type === relationType) + .map((r: { targetRef: string }) => r.targetRef.split("/")[1]) ?? []; + return names.includes(relatedName); +} diff --git a/workspaces/backstage/e2e-tests/yarn.lock b/workspaces/backstage/e2e-tests/yarn.lock index 0364d2c08e..4fda717d1b 100644 --- a/workspaces/backstage/e2e-tests/yarn.lock +++ b/workspaces/backstage/e2e-tests/yarn.lock @@ -291,9 +291,9 @@ __metadata: languageName: node linkType: hard -"@red-hat-developer-hub/e2e-test-utils@npm:2.1.6": - version: 2.1.6 - resolution: "@red-hat-developer-hub/e2e-test-utils@npm:2.1.6" +"@red-hat-developer-hub/e2e-test-utils@npm:2.1.7": + version: 2.1.7 + resolution: "@red-hat-developer-hub/e2e-test-utils@npm:2.1.7" dependencies: "@axe-core/playwright": "npm:4.11.1" "@backstage-community/plugin-rbac-common": "npm:1.26.0" @@ -316,7 +316,7 @@ __metadata: zx: "npm:8.8.5" peerDependencies: "@playwright/test": ^1.57.0 - checksum: 10/a5000ff2539618600baa5d9c3153edec0bf4dc4d14388a0589ce987f891ca840f3cc72f29497a67ea5ad96e81d177784ff2e48cdccbebaf5244f3f46d3c90300 + checksum: 10/cd7ad30421536a06bba473817fdd2e40a88ebaab534ea66f0c04752dd9009cabc428b003834da32f36fef7747b3ec5d3757d9bdf25a6dfa25dc3422836332d72 languageName: node linkType: hard @@ -626,7 +626,7 @@ __metadata: dependencies: "@eslint/js": "npm:10.0.1" "@playwright/test": "npm:1.59.1" - "@red-hat-developer-hub/e2e-test-utils": "npm:2.1.6" + "@red-hat-developer-hub/e2e-test-utils": "npm:2.1.7" "@types/node": "npm:25.5.2" eslint: "npm:10.2.0" eslint-plugin-check-file: "npm:3.3.1"