diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8719fdde3..c979d0df0 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -434,3 +434,71 @@ jobs: ./ios-diag/ retention-days: 7 if-no-files-found: warn + + web-e2e: + name: web-e2e + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [linux] + steps: + - uses: actions/checkout@v4.1.7 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: setup pnpm + run: npm install -g corepack@latest && corepack enable + + - name: install dependencies + run: pnpm install + + - name: download streamplace binary + uses: actions/download-artifact@v4 + with: + name: streamplace-linux-amd64-${{ github.run_id }} + path: . + + - name: make streamplace executable + run: chmod +x ./streamplace + + - name: install playwright browser + run: + pnpm --filter @streamplace/e2e-web exec playwright install --with-deps + chromium + + - name: start e2e environment + run: | + ./streamplace e2e > /tmp/e2e-env.sh 2> /tmp/e2e-server.log & + for i in $(seq 1 120); do + if [ -s /tmp/e2e-env.sh ]; then break; fi + sleep 1 + done + if [ ! -s /tmp/e2e-env.sh ]; then + echo "e2e environment failed to start; server log:" + cat /tmp/e2e-server.log + exit 1 + fi + cat /tmp/e2e-env.sh + + - name: run web e2e flows + run: | + set -a + source /tmp/e2e-env.sh + set +a + pnpm --filter @streamplace/e2e-web exec playwright test + + - name: upload playwright artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: web-e2e-${{ github.run_id }} + path: | + js/e2e-web/playwright-report/ + js/e2e-web/test-results/ + /tmp/e2e-server.log + retention-days: 7 + if-no-files-found: warn diff --git a/hack/e2e-web-local.sh b/hack/e2e-web-local.sh new file mode 100755 index 000000000..11b29ea9a --- /dev/null +++ b/hack/e2e-web-local.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# +# Run the headless-web e2e suite (Playwright) against a self-contained +# `streamplace e2e` harness — the web counterpart to hack/e2e-local.sh. +# +# hack/e2e-web-local.sh [-- ] +# +# Prerequisites: +# * A streamplace binary whose embedded web bundle was built with +# EXPO_PUBLIC_WEB_TRY_LOCAL=true, so the app served by the harness node +# targets its own origin (no server-setup step needed). Build it with: +# EXPO_PUBLIC_WEB_TRY_LOCAL=true make app && make dev +# * Playwright + its chromium browser installed in js/e2e-web +# (pnpm --filter @streamplace/e2e-web install-browser). +# +# On a dev host without the cgo runtime, run this inside the build container. +set -euo pipefail + +cd "$(dirname "$0")/.." +REPO="$PWD" + +# --- locate the harness binary (build--/streamplace) ------------- +case "$(uname)-$(uname -m)" in + Linux-x86_64) BUILDDIR=build-linux-amd64 ;; + Linux-aarch64) BUILDDIR=build-linux-arm64 ;; + Darwin-arm64) BUILDDIR=build-darwin-arm64 ;; + Darwin-x86_64) BUILDDIR=build-darwin-amd64 ;; + *) echo "unsupported host $(uname)-$(uname -m)"; exit 1 ;; +esac +if [ ! -x "$BUILDDIR/streamplace" ]; then + echo "harness binary $BUILDDIR/streamplace missing — run 'make dev' first" + exit 1 +fi + +# --- start the harness, capture its env, and always clean it up ------------ +ENVFILE="$(mktemp)" +LOGFILE="$(mktemp)" +echo "starting e2e harness…" +"$BUILDDIR/streamplace" e2e > "$ENVFILE" 2> "$LOGFILE" & +HARNESS_PID=$! +cleanup() { + kill "$HARNESS_PID" 2>/dev/null || true + pkill -P "$HARNESS_PID" 2>/dev/null || true + rm -f "$ENVFILE" "$LOGFILE" +} +trap cleanup EXIT + +for _ in $(seq 1 90); do grep -q SERVER_URL "$ENVFILE" && break; sleep 2; done +if ! grep -q SERVER_URL "$ENVFILE"; then + echo "harness failed to start; log:"; tail -20 "$LOGFILE"; exit 1 +fi +# shellcheck disable=SC1090 +. "$ENVFILE" +export SERVER_URL ACCOUNT_HANDLE ACCOUNT_DID +echo "harness up: SERVER_URL=$SERVER_URL ACCOUNT_HANDLE=$ACCOUNT_HANDLE" + +# --- run the flows --------------------------------------------------------- +cd "$REPO/js/e2e-web" +exec pnpm exec playwright test "$@" diff --git a/js/e2e-web/.gitignore b/js/e2e-web/.gitignore new file mode 100644 index 000000000..a4fcaaf6b --- /dev/null +++ b/js/e2e-web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +test-results/ +playwright-report/ +blob-report/ +.playwright/ diff --git a/js/e2e-web/README.md b/js/e2e-web/README.md new file mode 100644 index 000000000..6810f2dc7 --- /dev/null +++ b/js/e2e-web/README.md @@ -0,0 +1,58 @@ +# e2e-web (Playwright) + +Headless-web e2e flows for the Streamplace app — the web counterpart to the +mobile `.maestro/` suite. Same harness (`streamplace e2e`), same testIDs, same +flows; a different driver (Playwright headless Chromium) because Maestro is +mobile-only. + +## How it fits together + +- **Harness:** `streamplace e2e` (see `pkg/cmd/e2e.go`) boots a node + local + PDS/PLC + a looping WHIP test stream and prints `SERVER_URL` / + `ACCOUNT_HANDLE` / `ACCOUNT_DID`. The node also serves the web app (embedded + via `//go:embed all:dist/**`), so Playwright just points a browser at + `SERVER_URL`. +- **Shared testIDs:** react-native-web maps `testID` -> `data-testid`, so the + very same IDs the Maestro flows use (`home-stream-card`, + `settings-use-custom-node`, `settings-custom-node-url`, `settings-save-node`) + are Playwright selectors here — no separate web instrumentation. +- **Server setup:** the app ships pointed at production, so `global-setup.ts` + opens `/settings/advanced`, enters the test node's URL, and saves the + resulting browser state (`storageState`). Every flow reuses it and starts + already pointed at the harness — the web analogue of + `.maestro/00-server-setup.yaml`. + +## Flow ↔ Maestro parity + +| Playwright (`flows/`) | Maestro (`.maestro/`) | +| --------------------- | ---------------------- | +| `global-setup.ts` | `00-server-setup.yaml` | +| `01-smoke.spec.ts` | `01-smoke.yaml` | +| `02-tabs.spec.ts` | `02-tabs.yaml` | +| `03-go-live.spec.ts` | `03-go-live.yaml` | +| `04-stream.spec.ts` | `04-stream.yaml` | + +The web app renders the **desktop layout** (a sidebar of nav links), not the +mobile tab bar, so a couple of flows adapt to that surface while keeping the +same intent: `02-tabs` drives the sidebar (Home ↔ Settings) instead of the tab +bar, and `03-go-live` reaches the streaming entry point via the Live Dashboard +(`/live`), which — logged out — surfaces the same Log In prompt as the mobile +"Go Live → Start streaming" path. + +## Run it locally + +```bash +# once: install the browser +pnpm --filter @streamplace/e2e-web install-browser + +# build a streamplace binary that embeds the web app, then run the suite +make dev +hack/e2e-web-local.sh +``` + +`hack/e2e-web-local.sh` starts the harness, waits for `SERVER_URL`, runs the +flows, and tears the harness down. On a host without the cgo runtime, run it +inside the build container. + +Artifacts on failure (traces, screenshots, video) land in `test-results/` and a +report in `playwright-report/` (`pnpm --filter @streamplace/e2e-web report`). diff --git a/js/e2e-web/flows/01-smoke.spec.ts b/js/e2e-web/flows/01-smoke.spec.ts new file mode 100644 index 000000000..27a52cd99 --- /dev/null +++ b/js/e2e-web/flows/01-smoke.spec.ts @@ -0,0 +1,7 @@ +import { expect, test } from "@playwright/test"; + +// Mirror of .maestro/01-smoke.yaml: the app loads and renders the home feed. +test("01-smoke: app loads", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Streamplace").first()).toBeVisible(); +}); diff --git a/js/e2e-web/flows/02-tabs.spec.ts b/js/e2e-web/flows/02-tabs.spec.ts new file mode 100644 index 000000000..260a46512 --- /dev/null +++ b/js/e2e-web/flows/02-tabs.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "@playwright/test"; + +// Web counterpart of .maestro/02-tabs.yaml. The web app renders the desktop +// layout (a sidebar of nav links) rather than the mobile tab bar, so we +// exercise the sidebar: Home -> Settings -> Home. Nav links carry accessible +// names of the form "Link to /". +test("02-tabs: primary navigation", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Streamplace").first()).toBeVisible(); + + await page.getByRole("link", { name: "Link to /settings" }).first().click(); + await expect(page.getByText("Advanced").first()).toBeVisible(); + + await page + .getByRole("link", { name: "Link to /", exact: true }) + .first() + .click(); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible({ + timeout: 30_000, + }); +}); diff --git a/js/e2e-web/flows/03-go-live.spec.ts b/js/e2e-web/flows/03-go-live.spec.ts new file mode 100644 index 000000000..b30139346 --- /dev/null +++ b/js/e2e-web/flows/03-go-live.spec.ts @@ -0,0 +1,13 @@ +import { expect, test } from "@playwright/test"; + +// Web counterpart of .maestro/03-go-live.yaml. On mobile the "Go Live" tab -> +// "Start streaming" prompts an unauthenticated user to log in. The web app's +// streaming entry point is the Live Dashboard (/live); visiting it while logged +// out surfaces the same Log In / Sign Up prompt. +test("03-go-live: streaming requires login", async ({ page }) => { + await page.goto("/"); + + await page.getByRole("link", { name: "Link to /live" }).first().click(); + await expect(page.getByText("Live Dashboard").first()).toBeVisible(); + await expect(page.getByRole("button", { name: "Log In" })).toBeVisible(); +}); diff --git a/js/e2e-web/flows/04-stream.spec.ts b/js/e2e-web/flows/04-stream.spec.ts new file mode 100644 index 000000000..7ab3d6df3 --- /dev/null +++ b/js/e2e-web/flows/04-stream.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; + +// Mirror of .maestro/04-stream.yaml: open the looping test stream from the home +// feed and confirm playback context loads. The home feed card carries +// testID="home-stream-card" (data-testid on web); the "Now streaming - e2e test +// stream" chat system message arrives over the chat socket shortly after open. +test("04-stream: open test stream from feed", async ({ page }) => { + await page.goto("/"); + + const card = page.getByTestId("home-stream-card").first(); + await expect(card).toBeVisible({ timeout: 30_000 }); + await card.click(); + + // the stream page mounts a video element and posts a "Now streaming - ..." + // system message into chat once playback context is established + await expect( + page.getByText("Now streaming - e2e test stream").first(), + ).toBeVisible({ timeout: 30_000 }); + await expect(page.locator("video").first()).toBeVisible(); +}); diff --git a/js/e2e-web/global-setup.ts b/js/e2e-web/global-setup.ts new file mode 100644 index 000000000..b8d2c06c6 --- /dev/null +++ b/js/e2e-web/global-setup.ts @@ -0,0 +1,85 @@ +import { chromium, expect, type FullConfig } from "@playwright/test"; +import { STORAGE_STATE } from "./storage"; + +// Web counterpart to .maestro/00-server-setup.yaml. +// +// The mobile app ships pointed at production, so the Maestro suite opens +// Settings -> Advanced and enters the test node's URL. The web app served by +// the harness is the same react-native codebase, so we do the same thing — +// once, here — and persist the result (redux-persist writes it to +// localStorage) via storageState so every flow starts already pointed at the +// harness. react-navigation's linking config exposes the advanced-settings +// screen at /settings/advanced, so we can deep-link straight to it instead of +// clicking through the nav. +export default async function globalSetup(_config: FullConfig) { + const SERVER_URL = process.env.SERVER_URL; + if (!SERVER_URL) { + throw new Error("SERVER_URL is not set — start the harness first"); + } + + const browser = await chromium.launch(); + const page = await browser.newPage(); + try { + await page.goto(`${SERVER_URL}/settings/advanced`); + + // toggle "use custom node" on, which reveals the URL field + save button + const toggle = page.getByTestId("settings-use-custom-node"); + await expect(toggle).toBeVisible({ timeout: 60_000 }); + await toggle.click(); + + // the react-native-web TextInput renders as an ; fill it + const urlField = page + .locator( + '[data-testid="settings-custom-node-url"], [data-testid="settings-custom-node-url"] input', + ) + .first(); + await expect(urlField).toBeVisible(); + await urlField.fill(SERVER_URL); + + await page.getByTestId("settings-save-node").click(); + + // The harness WHIP stream takes a few seconds after boot to ingest its + // first segments and register as live. The home feed fetches getLiveUsers + // on mount and doesn't aggressively re-poll, so wait for the stream to be + // live *before* loading the feed — otherwise the flows race the stream and + // see an empty "no one is streaming" feed. + await waitForLiveStream(SERVER_URL); + + // prove the app is now talking to the test node: its isolated firehose only + // has our looping test stream, so a card must appear on the home feed + await page.goto(`${SERVER_URL}/`); + await expect(page.getByTestId("home-stream-card").first()).toBeVisible({ + timeout: 30_000, + }); + + await page.context().storageState({ path: STORAGE_STATE }); + } finally { + await browser.close(); + } +} + +// Poll getLiveUsers until the harness's looping test stream shows up (it needs +// a few seconds after boot to ingest its first segments). +async function waitForLiveStream(serverUrl: string, timeoutMs = 90_000) { + const deadline = Date.now() + timeoutMs; + let lastErr = ""; + while (Date.now() < deadline) { + try { + const res = await fetch( + `${serverUrl}/xrpc/place.stream.live.getLiveUsers`, + ); + if (res.ok) { + const body = (await res.json()) as { streams?: unknown[] }; + if (Array.isArray(body.streams) && body.streams.length > 0) return; + } else { + lastErr = `status ${res.status}`; + } + } catch (e) { + lastErr = (e as Error).message; + } + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error( + `timed out waiting for a live stream from ${serverUrl} (last: ${lastErr || "empty feed"})`, + ); +} diff --git a/js/e2e-web/package.json b/js/e2e-web/package.json new file mode 100644 index 000000000..70bea5b88 --- /dev/null +++ b/js/e2e-web/package.json @@ -0,0 +1,17 @@ +{ + "name": "@streamplace/e2e-web", + "version": "0.11.0", + "private": true, + "description": "Headless-web e2e flows (Playwright) — the web counterpart to the .maestro/ mobile flows", + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed", + "report": "playwright show-report", + "install-browser": "playwright install --with-deps chromium" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^22.10.0", + "typescript": "~5.6.3" + } +} diff --git a/js/e2e-web/playwright.config.ts b/js/e2e-web/playwright.config.ts new file mode 100644 index 000000000..1e596cb3f --- /dev/null +++ b/js/e2e-web/playwright.config.ts @@ -0,0 +1,53 @@ +import { defineConfig, devices } from "@playwright/test"; +import { STORAGE_STATE } from "./storage"; + +// The web app under test is served by a `streamplace e2e` harness (see +// pkg/cmd/e2e.go), which prints SERVER_URL / ACCOUNT_HANDLE / ACCOUNT_DID. +// hack/e2e-web-local.sh starts that harness and exports SERVER_URL before +// invoking playwright. global-setup then points the app at that node (see +// global-setup.ts), which is where a missing SERVER_URL is reported — we don't +// throw here so the config stays importable by static tooling (knip, etc.). +const SERVER_URL = process.env.SERVER_URL; + +export default defineConfig({ + testDir: "./flows", + // Point the app at the test node once (Settings -> Advanced), then reuse that + // browser state so every flow starts already configured — the web analogue of + // .maestro/00-server-setup.yaml. + globalSetup: require.resolve("./global-setup"), + // Flows share one harness (one account, one looping stream) and are written + // to mirror the ordered .maestro/ suite, so run them serially. + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI + ? [["list"], ["html", { open: "never" }]] + : [["list"]], + timeout: 90_000, + expect: { timeout: 30_000 }, + use: { + baseURL: SERVER_URL, + storageState: STORAGE_STATE, + headless: true, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + // The app is served over http on loopback with a self-test stream; don't + // let cert/mixed-content strictness get in the way. + ignoreHTTPSErrors: true, + // WebRTC playback needs a permissive autoplay/media posture. + launchOptions: { + args: [ + "--autoplay-policy=no-user-gesture-required", + "--use-fake-ui-for-media-stream", + ], + }, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/js/e2e-web/storage.ts b/js/e2e-web/storage.ts new file mode 100644 index 000000000..fb46daf51 --- /dev/null +++ b/js/e2e-web/storage.ts @@ -0,0 +1,5 @@ +import path from "path"; + +// Where global-setup saves the post-server-setup browser state (localStorage +// holds the custom-node URL, persisted by redux-persist) for the flows to reuse. +export const STORAGE_STATE = path.join(__dirname, ".playwright", "state.json"); diff --git a/js/e2e-web/tsconfig.json b/js/e2e-web/tsconfig.json new file mode 100644 index 000000000..d2deb82dc --- /dev/null +++ b/js/e2e-web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "node", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["flows/**/*.ts", "playwright.config.ts"] +} diff --git a/knip.json b/knip.json index 57847c615..d070b4bda 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,12 @@ "$schema": "https://unpkg.com/knip@5/schema.json", "project": ["js/streamplace/src/**/*.ts"], "entry": ["js/streamplace/src/index.ts"], - "ignoreWorkspaces": ["js/app", "js/desktop", "js/config-react-native-webrtc"], + "ignoreWorkspaces": [ + "js/app", + "js/desktop", + "js/config-react-native-webrtc", + "js/e2e-web" + ], "include": ["unlisted"], "ignore": [ ".*tamagui.config.ts.*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa4ac084b..f5388389b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -719,6 +719,18 @@ importers: specifier: ^0.6.2 version: 0.6.2(@astrojs/starlight@0.34.8(astro@5.18.1(@types/node@25.5.0)(idb-keyval@6.2.1)(ioredis@5.10.0)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.59.0)(terser@5.46.0)(typescript@5.9.3)(yaml@2.8.2))) + js/e2e-web: + devDependencies: + '@playwright/test': + specifier: ^1.49.1 + version: 1.61.1 + '@types/node': + specifier: ^22.10.0 + version: 22.19.15 + typescript: + specifier: ~5.6.3 + version: 5.6.3 + js/playback-router: devDependencies: '@cloudflare/workers-types': @@ -4281,6 +4293,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.61.1': + resolution: {integrity: sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==} + engines: {node: '>=18'} + hasBin: true + '@poppinss/colors@4.1.6': resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} @@ -7561,6 +7578,7 @@ packages: conventional-changelog-core@5.0.1: resolution: {integrity: sha512-Rvi5pH+LvgsqGwZPZ3Cq/tz4ty7mjijhr3qR4m9IBXNbxGGYgTVVO+duXzz9aArmHxFtwZ+LRkrNIMDQzgoY4A==} engines: {node: '>=14'} + deprecated: Deprecated and no longer maintained. Please use conventional-changelog instead. conventional-changelog-preset-loader@3.0.0: resolution: {integrity: sha512-qy9XbdSLmVnwnvzEisjxdDiLA4OmV3o8db+Zdg4WiFw14fP3B6XNz98X0swPPpkTd/pc1K7+adKgEDM1JCUMiA==} @@ -9063,6 +9081,11 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -9207,7 +9230,7 @@ packages: git-raw-commits@3.0.0: resolution: {integrity: sha512-b5OHmZ3vAgGrDn/X0kS+9qCfNKWe4K/jFnhwzVWWg0/k5eLa3060tZShrRg8Dja5kPc+YjS0Gc6y7cRr44Lpjw==} engines: {node: '>=14'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-remote-origin-url@2.0.0: @@ -9217,7 +9240,7 @@ packages: git-semver-tags@5.0.1: resolution: {integrity: sha512-hIvOeZwRbQ+7YEUmCkHqo8FOLQZCEn18yevLHADlFPZY02KJGsu5FZt9YW/lybfK2uhWFI7Qg/07LekJiTv7iA==} engines: {node: '>=14'} - deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true git-up@7.0.0: @@ -12223,6 +12246,16 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.61.1: + resolution: {integrity: sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==} + engines: {node: '>=18'} + hasBin: true + plist@3.1.0: resolution: {integrity: sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ==} engines: {node: '>=10.4.0'} @@ -14152,6 +14185,7 @@ packages: tsconfck@3.1.6: resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} engines: {node: ^18 || >=20} + deprecated: unmaintained hasBin: true peerDependencies: typescript: ^5.0.0 @@ -19432,7 +19466,7 @@ snapshots: '@grpc/grpc-js@1.9.15': dependencies: '@grpc/proto-loader': 0.7.15 - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@grpc/proto-loader@0.7.13': dependencies: @@ -19844,14 +19878,14 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.5.0 + '@types/node': 22.19.15 jest-mock: 29.7.0 '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 25.5.0 + '@types/node': 22.19.15 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -19885,7 +19919,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/yargs': 17.0.32 chalk: 4.1.2 @@ -20454,6 +20488,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.61.1': + dependencies: + playwright: 1.61.1 + '@poppinss/colors@4.1.6': dependencies: kleur: 4.1.5 @@ -22372,7 +22410,7 @@ snapshots: '@types/appdmg@0.5.5': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 optional: true '@types/babel__core@7.20.5': @@ -22400,7 +22438,7 @@ snapshots: '@types/bn.js@5.2.0': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/body-parser@1.19.5': dependencies: @@ -22409,13 +22447,13 @@ snapshots: '@types/bonjour@3.5.13': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.0.4 '@types/keyv': 3.1.4 - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/responselike': 1.0.3 '@types/caseless@0.12.5': @@ -22424,7 +22462,7 @@ snapshots: '@types/connect-history-api-fallback@1.5.4': dependencies: '@types/express-serve-static-core': 4.19.5 - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/connect@3.4.38': dependencies: @@ -22472,18 +22510,18 @@ snapshots: '@types/fs-extra@9.0.13': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 optional: true '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 25.5.0 + '@types/node': 22.19.15 optional: true '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/hammerjs@2.0.46': {} @@ -22499,7 +22537,7 @@ snapshots: '@types/http-proxy@1.17.15': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/istanbul-lib-coverage@2.0.6': {} @@ -22523,7 +22561,7 @@ snapshots: '@types/keyv@3.1.4': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/long@4.0.2': optional: true @@ -22555,7 +22593,7 @@ snapshots: '@types/node-forge@1.3.11': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/node@16.18.126': {} @@ -22603,20 +22641,20 @@ snapshots: '@types/responselike@1.0.3': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/retry@0.12.0': {} '@types/sax@1.2.7': dependencies: - '@types/node': 17.0.45 + '@types/node': 22.19.15 '@types/sdp-transform@2.15.0': {} '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/serve-index@1.9.4': dependencies: @@ -22625,12 +22663,12 @@ snapshots: '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/send': 0.17.4 '@types/sockjs@0.3.36': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/source-map-support@0.5.10': dependencies: @@ -22653,7 +22691,7 @@ snapshots: '@types/ws@8.5.12': dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 '@types/yargs-parser@21.0.3': {} @@ -22663,7 +22701,7 @@ snapshots: '@types/yauzl@2.10.3': dependencies: - '@types/node': 20.19.37 + '@types/node': 22.19.15 optional: true '@typescript-eslint/eslint-plugin@8.57.0(@typescript-eslint/parser@8.57.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.6.3)': @@ -24063,7 +24101,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -24074,7 +24112,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -26400,6 +26438,9 @@ snapshots: fs.realpath@1.0.0: {} + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -27881,7 +27922,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.5.0 + '@types/node': 22.19.15 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -27891,7 +27932,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 25.5.0 + '@types/node': 22.19.15 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -27918,7 +27959,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.5.0 + '@types/node': 22.19.15 jest-util: 29.7.0 jest-regex-util@29.6.3: {} @@ -27926,7 +27967,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.5.0 + '@types/node': 22.19.15 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -27943,13 +27984,13 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@29.7.0: dependencies: - '@types/node': 25.5.0 + '@types/node': 22.19.15 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -30681,6 +30722,14 @@ snapshots: mlly: 1.7.4 pathe: 2.0.3 + playwright-core@1.61.1: {} + + playwright@1.61.1: + dependencies: + playwright-core: 1.61.1 + optionalDependencies: + fsevents: 2.3.2 + plist@3.1.0: dependencies: '@xmldom/xmldom': 0.8.11 @@ -30904,7 +30953,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.5.0 + '@types/node': 22.19.15 long: 5.3.2 protocols@2.0.1: {}