diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..ce993670 --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,33 @@ +name: Playwright Tests +on: + pull_request: {} + push: + branches: [livekit, full-mesh] +jobs: + test: + timeout-minutes: 10 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + cache: "yarn" + node-version-file: ".node-version" + - name: Install dependencies + run: yarn install --frozen-lockfile + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + - name: Run backend components + run: | + docker compose -f playwright-backend-docker-compose.yml up -d + docker ps + - name: Copy config file + run: cp config/config.devenv.json public/config.json + - name: Run Playwright tests + run: yarn playwright test + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report + path: playwright-report/ + retention-days: 3 diff --git a/.gitignore b/.gitignore index 7b2cd2c2..938fe508 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ public/config.json backend/synapse_tmp/* /coverage yarn-error.log + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/README.md b/README.md index acadb9c0..736617a6 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ utilizes **[MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)** with **[LiveKit](https://livekit.io/)** as its backend. -![A demo of Element Call with six people](demo.jpg) +![A demo of Element Call with six people](demo.gif) You can find the latest development version continuously deployed to [call.element.dev](https://call.element.dev/). @@ -189,6 +189,64 @@ yarn backend # podman-compose -f dev-backend-docker-compose.yml up ``` +### Playwright tests + +Our Playwright tests run automatically as part of our CI along with our other tests, +on every pull request. + +You may need to follow instructions to set up your development environment for running +Playwright by following and +. + +However the Playwright tests are run, an element-call instance must be running on +https://localhost:3000 (this is configured in `playwright.config.ts`) - this is what will +be tested. + +The local backend environment should be running for the test to work: +`yarn backend` + +There are a few different ways to run the tests yourself. The simplest is to run: + +```shell +yarn run test:playwright +``` + +This will run the Playwright tests once, non-interactively. + +There is a more user-friendly way to run the tests in interactive mode: + +```shell +yarn run test:playwright:open +``` + +The easiest way to develop new test is to use the codegen feature of Playwright: + +```shell +npx playwright codegen +``` + +This will record your action and write the test code for you. Use the tool bar to test visibility, text content, +clicking. + +##### Investigate a failed test from the CI + +In the failed action page, click on the failed job, then scroll down to the `upload-artifact` step. +You will find a link to download the zip report, as per: + +``` +Artifact playwright-report has been successfully uploaded! Final size is 1360358 bytes. Artifact ID is 2746265841 +Artifact download URL: https://github.com/element-hq/element-call/actions/runs/13837660687/artifacts/2746265841 +``` + +Unzip the report then use this command to open the report in your browser: + +```shell +npx playwright show-report ~/Downloads/playwright-report/ +``` + +Under the failed test there is a small icon looking like "3 columns" (next to test name file name), +click on it to see the live screenshots/console output. + ### Test Coverage diff --git a/backend/playwright_homeserver.yaml b/backend/playwright_homeserver.yaml new file mode 100644 index 00000000..d4d0a041 --- /dev/null +++ b/backend/playwright_homeserver.yaml @@ -0,0 +1,67 @@ +server_name: "synapse.localhost" +public_baseurl: http://synapse.localhost:8008/ + +pid_file: /data/homeserver.pid + +listeners: + - port: 8008 + tls: false + type: http + x_forwarded: true + resources: + - names: [client, federation, openid] + compress: false + +database: + name: sqlite3 + args: + database: /data/homeserver.db + +media_store_path: /data/media_store +signing_key_path: "/data/SERVERNAME.signing.key" +trusted_key_servers: + - server_name: "matrix.org" + +experimental_features: + # MSC3266: Room summary API. Used for knocking over federation + msc3266_enabled: true + # MSC4222 needed for syncv2 state_after. This allow clients to + # correctly track the state of the room. + msc4222_enabled: true + +# The maximum allowed duration by which sent events can be delayed, as +# per MSC4140. Must be a positive value if set. Defaults to no +# duration (null), which disallows sending delayed events. +max_event_delay_duration: 24h + +# Ratelimiting settings for client actions (registration, login, messaging). +# +# Each ratelimiting configuration is made of two parameters: +# - per_second: number of requests a client can send per second. +# - burst_count: number of requests a client can send before being throttled. + +rc_message: + per_second: 10000 + burst_count: 10000 + +rc_login: + address: + per_second: 10000 + burst_count: 10000 + account: + per_second: 10000 + burst_count: 10000 + failed_attempts: + per_second: 10000 + burst_count: 10000 + +rc_registration: + per_second: 10000 + burst_count: 10000 + +# Required for Element Call in Single Page Mode due to on-the-fly user registration +enable_registration: true +enable_registration_without_verification: true + +report_stats: false +serve_server_wellknown: true diff --git a/demo.gif b/demo.gif new file mode 100644 index 00000000..e785383e Binary files /dev/null and b/demo.gif differ diff --git a/docs/self-hosting.md b/docs/self-hosting.md index 26644ed6..d76413d4 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -46,10 +46,15 @@ experimental_features: max_event_delay_duration: 24h rc_message: - # This needs to match at least the heart-beat frequency plus a bit of headroom - # Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s + # This needs to match at least e2ee key sharing frequency plus a bit of headroom + # Note key sharing events are bursty per_second: 0.5 burst_count: 30 + # This needs to match at least the heart-beat frequency plus a bit of headroom + # Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s +rc_delayed_event_mgmt: + per_second: 1 + burst_count: 20 ``` ### MatrixRTC Backend @@ -84,7 +89,7 @@ to implement { "type": "another_foci", "props_for_another_foci": "val" - }, + } ] ``` diff --git a/package.json b/package.json index cacd969b..2f087f60 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "i18n:check": "i18next --fail-on-warnings --fail-on-update", "test": "vitest", "test:coverage": "vitest --coverage", - "backend": "docker-compose -f dev-backend-docker-compose.yml up" + "backend": "docker-compose -f dev-backend-docker-compose.yml up", + "test:playwright": "playwright test", + "test:playwright:open": "yarn test:playwright --ui" }, "devDependencies": { "@babel/core": "^7.16.5", @@ -43,6 +45,7 @@ "@opentelemetry/sdk-trace-base": "^1.25.1", "@opentelemetry/sdk-trace-web": "^1.9.1", "@opentelemetry/semantic-conventions": "^1.25.1", + "@playwright/test": "^1.51.0", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-visually-hidden": "^1.0.3", diff --git a/playwright-backend-docker-compose.yml b/playwright-backend-docker-compose.yml new file mode 100644 index 00000000..fed10fe8 --- /dev/null +++ b/playwright-backend-docker-compose.yml @@ -0,0 +1,86 @@ +networks: + ecbackend: + +services: + auth-service: + image: ghcr.io/element-hq/lk-jwt-service:latest-ci + hostname: auth-server + environment: + - LK_JWT_PORT=8080 + - LIVEKIT_URL=ws://localhost:7880 + - LIVEKIT_KEY=devkey + - LIVEKIT_SECRET=secret + # If the configured homeserver runs on localhost, it'll probably be using + # a self-signed certificate + - LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING + deploy: + restart_policy: + condition: on-failure + ports: + # HOST_PORT:CONTAINER_PORT + - 8009:8080 + networks: + - ecbackend + + livekit: + image: livekit/livekit-server:latest + command: --dev --config /etc/livekit.yaml + restart: unless-stopped + # The SFU seems to work far more reliably when we let it share the host + # network rather than opening specific ports (but why?? we're not missing + # any…) + ports: + # HOST_PORT:CONTAINER_PORT + - 7880:7880/tcp + - 7881:7881/tcp + - 7882:7882/tcp + - 50100-50200:50100-50200/udp + volumes: + - ./backend/dev_livekit.yaml:/etc/livekit.yaml:Z + networks: + - ecbackend + + redis: + image: redis:6-alpine + command: redis-server /etc/redis.conf + ports: + # HOST_PORT:CONTAINER_PORT + - 6379:6379 + volumes: + - ./backend/redis.conf:/etc/redis.conf:Z + networks: + - ecbackend + + synapse: + hostname: homeserver + image: docker.io/matrixdotorg/synapse:latest + environment: + - SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml + # Needed for rootless podman-compose such that the uid/gid mapping does + # fit local user uid. If the container runs as root (uid 0) it is fine as + # it actually maps to your non-root user on the host (e.g. 1000). + # Otherwise uid mapping will not match your non-root user. + - UID=0 + - GID=0 + volumes: + - ./backend/synapse_tmp:/data:Z + - ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z + networks: + - ecbackend + + nginx: + # openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout tls_localhost_key.pem -out tls_localhost_cert.pem -subj "/C=GB/ST=London/L=London/O=Alros/OU=IT Department/CN=localhost" + hostname: synapse.localhost + image: nginx:latest + volumes: + - ./backend/tls_localhost_nginx.conf:/etc/nginx/conf.d/default.conf:Z + - ./backend/tls_localhost_key.pem:/root/ssl/key.pem:Z + - ./backend/tls_localhost_cert.pem:/root/ssl/cert.pem:Z + ports: + # HOST_PORT:CONTAINER_PORT + - "8008:80" + - "4443:443" + depends_on: + - synapse + networks: + - ecbackend diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..cdb8ec23 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,81 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { defineConfig, devices } from "@playwright/test"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./playwright", + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + baseURL: "https://localhost:3000", + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + permissions: [ + "clipboard-write", + "clipboard-read", + "microphone", + "camera", + ], + ignoreHTTPSErrors: true, + launchOptions: { + args: [ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + "--mute-audio", + ], + }, + }, + }, + + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + ignoreHTTPSErrors: true, + launchOptions: { + firefoxUserPrefs: { + "permissions.default.microphone": 1, + "permissions.default.camera": 1, + }, + }, + }, + }, + + // No safari for now, until I find a solution to fix `Not allowed to request resource` due to calling + // clear http to the homeserver + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "yarn dev", + url: "https://localhost:3000", + reuseExistingServer: !process.env.CI, + ignoreHTTPSErrors: true, + }, +}); diff --git a/playwright/access.spec.ts b/playwright/access.spec.ts new file mode 100644 index 00000000..52eef171 --- /dev/null +++ b/playwright/access.spec.ts @@ -0,0 +1,131 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; + +test("Sign up a new account, then login, then logout", async ({ browser }) => { + const userId = `test_user-id_${Date.now()}`; + + const newUserContext = await browser.newContext(); + const newUserPage = await newUserContext.newPage(); + await newUserPage.goto("/"); + + await expect(newUserPage.getByTestId("home_register")).toBeVisible(); + await newUserPage.getByTestId("home_register").click(); + + await newUserPage.getByTestId("register_username").click(); + await newUserPage.getByTestId("register_username").fill(userId); + + await newUserPage.getByTestId("register_password").click(); + await newUserPage.getByTestId("register_password").fill("password1!"); + await newUserPage.getByTestId("register_confirm_password").click(); + await newUserPage.getByTestId("register_confirm_password").fill("password1!"); + await newUserPage.getByTestId("register_register").click(); + + await expect( + newUserPage.getByRole("heading", { name: "Start new call" }), + ).toBeVisible(); + + // Now use a new page to login this account + const returningUserContext = await browser.newContext(); + const returningUserPage = await returningUserContext.newPage(); + await returningUserPage.goto("/"); + + await expect(returningUserPage.getByTestId("home_login")).toBeVisible(); + await returningUserPage.getByTestId("home_login").click(); + await returningUserPage.getByTestId("login_username").click(); + await returningUserPage.getByTestId("login_username").fill(userId); + await returningUserPage.getByTestId("login_password").click(); + await returningUserPage.getByTestId("login_password").fill("password1!"); + await returningUserPage.getByTestId("login_login").click(); + + await expect( + returningUserPage.getByRole("heading", { name: "Start new call" }), + ).toBeVisible(); + + // logout + await returningUserPage.getByTestId("usermenu_open").click(); + await returningUserPage.locator('[data-test-id="usermenu_logout"]').click(); + + await expect( + returningUserPage.getByRole("link", { name: "Log In" }), + ).toBeVisible(); + await expect(returningUserPage.getByTestId("home_login")).toBeVisible(); +}); + +test("As a guest, create a call, share link and other join", async ({ + browser, +}) => { + // Use reduce motion to disable animations that are making the tests a bit flaky + const creatorContext = await browser.newContext({ reducedMotion: "reduce" }); + const creatorPage = await creatorContext.newPage(); + + await creatorPage.goto("/"); + + // ======== + // ARRANGE: The first user creates a call as guest, join it, then click the invite button to copy the invite link + // ======== + await creatorPage.getByTestId("home_callName").click(); + await creatorPage.getByTestId("home_callName").fill("Welcome"); + await creatorPage.getByTestId("home_displayName").click(); + await creatorPage.getByTestId("home_displayName").fill("Inviter"); + await creatorPage.getByTestId("home_go").click(); + await expect(creatorPage.locator("video")).toBeVisible(); + + // join + await creatorPage.getByTestId("lobby_joinCall").click(); + // Spotlight mode to make checking the test visually clearer + await creatorPage.getByRole("radio", { name: "Spotlight" }).check(); + + // Get the invite link + await creatorPage.getByRole("button", { name: "Invite" }).click(); + await expect( + creatorPage.getByRole("heading", { name: "Invite to this call" }), + ).toBeVisible(); + await expect(creatorPage.getByRole("img", { name: "QR Code" })).toBeVisible(); + await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible(); + await expect(creatorPage.getByTestId("modal_inviteLink")).toBeVisible(); + await creatorPage.getByTestId("modal_inviteLink").click(); + + let inviteLink = (await creatorPage.evaluate( + "navigator.clipboard.readText()", + )) as string; + expect(inviteLink).toContain("room/#/"); + + // ======== + // ACT: The other user use the invite link to join the call as a guest + // ======== + const guestInviteeContext = await browser.newContext({ + reducedMotion: "reduce", + }); + const guestPage = await guestInviteeContext.newPage(); + + await guestPage.goto(inviteLink); + await guestPage.getByTestId("joincall_displayName").fill("Invitee"); + await expect(guestPage.getByTestId("joincall_joincall")).toBeVisible(); + await guestPage.getByTestId("joincall_joincall").click(); + await guestPage.getByTestId("lobby_joinCall").click(); + await guestPage.getByRole("radio", { name: "Spotlight" }).check(); + + // ======== + // ASSERT: check that there are two members in the call + // ======== + + // There should be two participants now + await expect( + guestPage.getByTestId("roomHeader_participants_count"), + ).toContainText("2"); + expect(await guestPage.getByTestId("videoTile").count()).toBe(2); + + // Same in creator page + await expect( + creatorPage.getByTestId("roomHeader_participants_count"), + ).toContainText("2"); + expect(await creatorPage.getByTestId("videoTile").count()).toBe(2); + + // XXX check the display names on the video tiles +}); diff --git a/playwright/create-call.spec.ts b/playwright/create-call.spec.ts new file mode 100644 index 00000000..759cd2db --- /dev/null +++ b/playwright/create-call.spec.ts @@ -0,0 +1,55 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { expect, test } from "@playwright/test"; + +test("Start a new call then leave and show the feedback screen", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("home_callName").click(); + await page.getByTestId("home_callName").fill("HelloCall"); + await page.getByTestId("home_displayName").click(); + await page.getByTestId("home_displayName").fill("John Doe"); + await page.getByTestId("home_go").click(); + + await expect(page.locator("video")).toBeVisible(); + await expect(page.getByTestId("lobby_joinCall")).toBeVisible(); + + // Check the button toolbar + // await expect(page.getByRole('button', { name: 'Mute microphone' })).toBeVisible(); + // await expect(page.getByRole('button', { name: 'Stop video' })).toBeVisible(); + await expect(page.getByRole("button", { name: "Settings" })).toBeVisible(); + await expect(page.getByRole("button", { name: "End call" })).toBeVisible(); + + // Join the call + await page.getByTestId("lobby_joinCall").click(); + + // Ensure that the call is connected + await page + .locator("div") + .filter({ hasText: /^HelloCall$/ }) + .click(); + // Check the number of participants + await expect(page.locator("div").filter({ hasText: /^1$/ })).toBeVisible(); + // The tooltip with the name should be visible + await expect(page.getByTestId("name_tag")).toContainText("John Doe"); + + // leave the call + await page.getByTestId("incall_leave").click(); + await expect(page.getByRole("heading")).toContainText( + "John Doe, your call has ended. How did it go?", + ); + await expect(page.getByRole("main")).toContainText( + "Why not finish by setting up a password to keep your account?", + ); + + await expect( + page.getByRole("link", { name: "Not now, return to home screen" }), + ).toBeVisible(); +}); diff --git a/playwright/landing.spec.ts b/playwright/landing.spec.ts new file mode 100644 index 00000000..b22a037e --- /dev/null +++ b/playwright/landing.spec.ts @@ -0,0 +1,30 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { test, expect } from "@playwright/test"; + +test("has title", async ({ page }) => { + await page.goto("/"); + + await expect(page).toHaveTitle(/Element Call/); +}); + +test("Landing page", async ({ page }) => { + await page.goto("/"); + + // There should be a login button in the header + await expect(page.getByRole("link", { name: "Log In" })).toBeVisible(); + + await expect( + page.getByRole("heading", { name: "Start new call" }), + ).toBeVisible(); + + await expect(page.getByTestId("home_callName")).toBeVisible(); + await expect(page.getByTestId("home_displayName")).toBeVisible(); + + await expect(page.getByTestId("home_go")).toBeVisible(); +}); diff --git a/src/Header.tsx b/src/Header.tsx index 8a312983..89455411 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -161,7 +161,12 @@ export const RoomHeaderInfo: FC = ({ height={20} aria-label={t("header_participants_label")} /> - + {t("participant_count", { count: participantCount ?? 0 })} diff --git a/vitest.config.js b/vitest.config.js index 68fef5be..a6c3107f 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -13,6 +13,7 @@ export default defineConfig((configEnv) => }, }, setupFiles: ["src/vitest.setup.ts"], + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], coverage: { reporter: ["html", "json"], include: ["src/"], @@ -21,6 +22,7 @@ export default defineConfig((configEnv) => "src/utils/test.ts", "src/utils/test-viewmodel.ts", "src/utils/test-fixtures.ts", + "playwright/**", ], }, }, diff --git a/yarn.lock b/yarn.lock index c6b9c56b..61d70d96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2137,6 +2137,13 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@playwright/test@^1.51.0": + version "1.51.0" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.51.0.tgz#8d5c8400b465a0bfdbcf993e390ceecb903ea6d2" + integrity sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA== + dependencies: + playwright "1.51.0" + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -6995,6 +7002,20 @@ picomatch@^4.0.1, picomatch@^4.0.2: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== +playwright-core@1.51.0: + version "1.51.0" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.51.0.tgz#bb23ea6bb6298242d088ae5e966ffcf8dc9827e8" + integrity sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg== + +playwright@1.51.0: + version "1.51.0" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.51.0.tgz#9ba154497ba62bc6dc199c58ee19295eb35a4707" + integrity sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA== + dependencies: + playwright-core "1.51.0" + optionalDependencies: + fsevents "2.3.2" + pluralize@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-8.0.0.tgz#1a6fa16a38d12a1901e0320fa017051c539ce3b1"