diff --git a/e2e-tests/livestream-client/.gitignore b/e2e-tests/livestream-client/.gitignore new file mode 100644 index 00000000..6c3ed0df --- /dev/null +++ b/e2e-tests/livestream-client/.gitignore @@ -0,0 +1,29 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/e2e-tests/livestream-client/README.md b/e2e-tests/livestream-client/README.md new file mode 100644 index 00000000..d42ef223 --- /dev/null +++ b/e2e-tests/livestream-client/README.md @@ -0,0 +1,42 @@ +# Livestream E2E Tests + +This is a lightweight React app for testing WHIP/WHEP livestreaming functionality using the `@fishjam-cloud/ts-client` package. + +## Features + +The React app provides two main sections. There are no manual URL or token inputs — tokens are fetched automatically via `useSandbox()` and the room name is taken from the `?room=` query parameter (defaulting to `"livestream-e2e"`). + +### Receive (WHEP) + +- Start/Stop Receiving button +- Video player to display the received stream +- Connection status and error display + +### Publish (WHIP) + +- Start/Stop Publishing button +- Video preview showing animated emoji (no camera required!) +- Connection status and error display + +## Usage + +### Install dependencies + +```bash +yarn install +``` + +### Run dev server + +```bash +yarn dev +``` + +The app will be available at `http://localhost:5174` + +### Run tests + +```bash +yarn e2e # Run tests +yarn e2e:ui # Run tests with Playwright UI +``` diff --git a/e2e-tests/livestream-client/config.ts b/e2e-tests/livestream-client/config.ts new file mode 100644 index 00000000..7c66a50e --- /dev/null +++ b/e2e-tests/livestream-client/config.ts @@ -0,0 +1,2 @@ +export const FISHJAM_URL = + import.meta.env.VITE_FISHJAM_URL || "http://localhost:4000"; diff --git a/e2e-tests/livestream-client/index.html b/e2e-tests/livestream-client/index.html new file mode 100644 index 00000000..99463d63 --- /dev/null +++ b/e2e-tests/livestream-client/index.html @@ -0,0 +1,23 @@ + + + + + + Livestream E2E Tests + + + +
+ + + diff --git a/e2e-tests/livestream-client/package.json b/e2e-tests/livestream-client/package.json new file mode 100644 index 00000000..36f50ae5 --- /dev/null +++ b/e2e-tests/livestream-client/package.json @@ -0,0 +1,39 @@ +{ + "name": "@fishjam-e2e/livestream-client", + "private": true, + "version": "0.1.0", + "type": "module", + "license": "Apache-2.0", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint . --ext .ts,.tsx --fix", + "lint:check": "eslint . --ext .ts,.tsx", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui", + "preview": "vite preview" + }, + "dependencies": { + "@fishjam-cloud/react-client": "workspace:*", + "@fishjam-cloud/ts-client": "workspace:*", + "react": "19.1.0", + "react-dom": "19.1.0" + }, + "devDependencies": { + "@playwright/test": "^1.51.1", + "@types/node": "^22.14.0", + "@types/react": "19.1.0", + "@types/react-dom": "19.1.0", + "@typescript-eslint/eslint-plugin": "^8.29.1", + "@typescript-eslint/parser": "^8.29.1", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^8.57.1", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.19", + "prettier": "^3.8.1", + "typescript": "^5.8.3", + "vite": "^6.2.6" + } +} diff --git a/e2e-tests/livestream-client/playwright.config.ts b/e2e-tests/livestream-client/playwright.config.ts new file mode 100644 index 00000000..35d4bc6a --- /dev/null +++ b/e2e-tests/livestream-client/playwright.config.ts @@ -0,0 +1,58 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: "./scenarios", + /* 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: [ + ["list"], + [ + "html", + { + outputFolder: "../../playwright-report/livestream-e2e", + open: "never", + }, + ], + ], + /* 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: "http://localhost:5174", + + /* 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"], + launchOptions: { + args: [ + "--use-fake-ui-for-media-stream", + "--use-fake-device-for-media-stream", + ], + }, + }, + }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: `VITE_FISHJAM_URL=${process.env.VITE_FISHJAM_URL ?? ""} yarn dev`, + url: "http://localhost:5174", + reuseExistingServer: !process.env.CI, + }, +}); diff --git a/e2e-tests/livestream-client/scenarios/basic.spec.ts b/e2e-tests/livestream-client/scenarios/basic.spec.ts new file mode 100644 index 00000000..9a3e40a6 --- /dev/null +++ b/e2e-tests/livestream-client/scenarios/basic.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; + +import { + assertThatVideoIsPlaying, + assertThatVideoStopped, + startPublishing, + startReceiving, + stopPublishing, + stopReceiving, +} from "./utils"; + +test("Displays basic UI", async ({ page }) => { + await page.goto("/"); + + await expect( + page.getByRole("button", { name: "Start Publishing", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Start Receiving", exact: true }), + ).toBeVisible(); + await expect(page.locator("#receive-video")).toBeVisible(); + await expect(page.locator("#publish-video")).toBeVisible(); +}); + +test("Viewer receives a published stream", async ({ page }) => { + await page.goto(`/?room=${encodeURIComponent(crypto.randomUUID())}`); + + await startPublishing(page); + await startReceiving(page); + await assertThatVideoIsPlaying(page); +}); + +test("Viewer stops receiving when disconnected", async ({ page }) => { + await page.goto(`/?room=${encodeURIComponent(crypto.randomUUID())}`); + await startPublishing(page); + await startReceiving(page); + await assertThatVideoIsPlaying(page); + await stopReceiving(page); + await assertThatVideoStopped(page); +}); + +test("Viewer can reconnect after disconnecting", async ({ page }) => { + await page.goto(`/?room=${encodeURIComponent(crypto.randomUUID())}`); + + await startPublishing(page); + await startReceiving(page); + await assertThatVideoIsPlaying(page); + await stopReceiving(page); + await startReceiving(page); + await assertThatVideoIsPlaying(page); +}); + +test("Stream ends when streamer stops publishing", async ({ page }) => { + await page.goto(`/?room=${encodeURIComponent(crypto.randomUUID())}`); + + await startPublishing(page); + await startReceiving(page); + await assertThatVideoIsPlaying(page); + await stopPublishing(page); + await assertThatVideoStopped(page); +}); diff --git a/e2e-tests/livestream-client/scenarios/utils.ts b/e2e-tests/livestream-client/scenarios/utils.ts new file mode 100644 index 00000000..675382a3 --- /dev/null +++ b/e2e-tests/livestream-client/scenarios/utils.ts @@ -0,0 +1,84 @@ +import type { Page } from "@playwright/test"; +import { expect, test } from "@playwright/test"; + +const TO_PASS_TIMEOUT_MILLIS = 15 * 1000; + +const expectWithLongerTimeout = expect.configure({ + timeout: TO_PASS_TIMEOUT_MILLIS, +}); + +type ViewerWindow = typeof window & { + viewer?: { + getStatistics: () => Promise; + }; +}; + +const getDecodedFrames = (page: Page): Promise => + page.evaluate(async () => { + const viewer = (window as ViewerWindow)?.viewer; + if (!viewer) return -1; + const stats = await viewer.getStatistics(); + if (!stats) return -1; + for (const stat of stats.values()) { + if (stat.type === "inbound-rtp") { + return stat.framesDecoded ?? 0; + } + } + return 0; + }); + +export const startPublishing = async (page: Page) => + await test.step("Start publishing", async () => { + await page + .getByRole("button", { name: "Start Publishing", exact: true }) + .click(); + await expectWithLongerTimeout( + page.locator("#publish-status"), + ).toContainText("Publishing"); + }); + +export const stopPublishing = async (page: Page) => + await test.step("Stop publishing", async () => { + await page + .getByRole("button", { name: "Stop Publishing", exact: true }) + .click(); + await expectWithLongerTimeout( + page.locator("#publish-status"), + ).toContainText("Not publishing"); + }); + +export const startReceiving = async (page: Page) => + await test.step("Start receiving", async () => { + await page + .getByRole("button", { name: "Start Receiving", exact: true }) + .click(); + await expectWithLongerTimeout( + page.locator("#receive-status"), + ).toContainText("Receiving"); + }); + +export const stopReceiving = async (page: Page) => + await test.step("Stop receiving", async () => { + await page + .getByRole("button", { name: "Stop Receiving", exact: true }) + .click(); + await expectWithLongerTimeout( + page.locator("#receive-status"), + ).toContainText("Not receiving"); + }); + +export const assertThatVideoIsPlaying = async (page: Page) => + await test.step("Assert that video is playing", async () => { + const firstMeasure = await getDecodedFrames(page); + await expectWithLongerTimeout(async () => + expect(await getDecodedFrames(page)).toBeGreaterThan(firstMeasure), + ).toPass(); + }); + +export const assertThatVideoStopped = async (page: Page) => + await test.step("Assert that video stopped", async () => { + const firstMeasure = await getDecodedFrames(page); + await page.waitForTimeout(500); + const secondMeasure = await getDecodedFrames(page); + expect(secondMeasure - firstMeasure).toBeLessThanOrEqual(0); + }); diff --git a/e2e-tests/livestream-client/src/App.tsx b/e2e-tests/livestream-client/src/App.tsx new file mode 100644 index 00000000..b3541759 --- /dev/null +++ b/e2e-tests/livestream-client/src/App.tsx @@ -0,0 +1,209 @@ +import { + useLivestreamStreamer, + useLivestreamViewer, + useSandbox, +} from "@fishjam-cloud/react-client"; +import { LivestreamError } from "@fishjam-cloud/ts-client"; +import { useEffect, useRef, useState } from "react"; + +import { createCanvasStream, stopCanvasStream } from "./canvasStream"; + +const roomName = + new URLSearchParams(window.location.search).get("room") ?? "livestream-e2e"; + +export function App() { + const { getSandboxLivestream, getSandboxViewerToken } = useSandbox(); + const viewer = useLivestreamViewer(); + const streamer = useLivestreamStreamer(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).viewer = viewer; + + // Receive (WHEP) state + const [receiveError, setReceiveError] = useState(null); + const receiveVideoRef = useRef(null); + + // Publish (WHIP) state + const [publishError, setPublishError] = useState(null); + const publishVideoRef = useRef(null); + const localStreamRef = useRef(null); + + useEffect(() => { + if (!receiveVideoRef.current) return; + receiveVideoRef.current.srcObject = viewer.stream; + }, [viewer.stream]); + + const handleStartReceiving = async () => { + setReceiveError(null); + try { + const viewerToken = await getSandboxViewerToken(roomName); + await viewer.connect({ token: viewerToken }); + } catch (error) { + const errorMessage = + error === LivestreamError.UNAUTHORIZED + ? "Unauthorized: Invalid or missing token" + : error === LivestreamError.STREAM_NOT_FOUND + ? "Stream not found" + : error === LivestreamError.UNKNOWN_ERROR + ? "Unknown error occurred" + : String(error); + + setReceiveError(errorMessage); + console.error("Failed to start receiving:", error); + } + }; + + const handleStopReceiving = async () => { + viewer.disconnect(); + if (receiveVideoRef.current) { + receiveVideoRef.current.srcObject = null; + } + }; + + const handleStartPublishing = async () => { + setPublishError(null); + try { + const { streamerToken } = await getSandboxLivestream(roomName); + + // Create canvas stream with animated emoji + const stream = createCanvasStream(); + + localStreamRef.current = stream; + + if (publishVideoRef.current) { + publishVideoRef.current.srcObject = stream; + } + + await streamer.connect({ + inputs: { video: stream }, + token: streamerToken, + }); + } catch (error) { + const errorMessage = + error === LivestreamError.UNAUTHORIZED + ? "Unauthorized: Invalid or missing token" + : error === LivestreamError.STREAM_NOT_FOUND + ? "Stream not found" + : error === LivestreamError.STREAMER_ALREADY_CONNECTED + ? "Streamer already connected" + : error === LivestreamError.UNKNOWN_ERROR + ? "Unknown error occurred" + : String(error); + + setPublishError(errorMessage); + console.error("Failed to start publishing:", error); + + // Clean up local stream if it was created + if (localStreamRef.current) { + stopCanvasStream(localStreamRef.current); + localStreamRef.current = null; + } + } + }; + + const handleStopPublishing = async () => { + streamer.disconnect(); + + if (localStreamRef.current) { + stopCanvasStream(localStreamRef.current); + localStreamRef.current = null; + } + + if (publishVideoRef.current) { + publishVideoRef.current.srcObject = null; + } + }; + + return ( +
+

Livestream E2E Tests

+ +
+ {/* Receive Section */} +
+

Receive (WHEP)

+
+ + {receiveError && ( +
+ Error: {receiveError} +
+ )} +
+ Status: {viewer.isConnected ? "Receiving" : "Not receiving"} +
+
+ +
+ + {/* Publish Section */} +
+

Publish (WHIP)

+
+ + {publishError && ( +
+ Error: {publishError} +
+ )} +
+ Status: {streamer.isConnected ? "Publishing" : "Not publishing"} +
+
+ +
+
+
+ ); +} diff --git a/e2e-tests/livestream-client/src/canvasStream.ts b/e2e-tests/livestream-client/src/canvasStream.ts new file mode 100644 index 00000000..4b91e1eb --- /dev/null +++ b/e2e-tests/livestream-client/src/canvasStream.ts @@ -0,0 +1,92 @@ +/** + * Creates a MediaStream from a canvas with an animated emoji. + * This works reliably in CI environments like GitHub runners. + */ +export function createCanvasStream(): MediaStream { + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext("2d")!; + + const emojis = ["🎥", "📹", "🎬", "🎞️", "📽️"]; + let frame = 0; + let emojiIndex = 0; + + // Animate the canvas + const animate = () => { + // Background with gradient + const gradient = ctx.createLinearGradient( + 0, + 0, + canvas.width, + canvas.height, + ); + gradient.addColorStop(0, `hsl(${frame % 360}, 70%, 50%)`); + gradient.addColorStop(1, `hsl(${(frame + 120) % 360}, 70%, 50%)`); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw emoji + ctx.font = "120px Arial"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + // Bounce effect + const bounce = Math.sin(frame * 0.1) * 30; + const rotation = Math.sin(frame * 0.05) * 0.2; + + ctx.save(); + ctx.translate(canvas.width / 2, canvas.height / 2 + bounce); + ctx.rotate(rotation); + ctx.fillText(emojis[emojiIndex], 0, 0); + ctx.restore(); + + // Draw frame counter + ctx.fillStyle = "white"; + ctx.font = "20px monospace"; + ctx.textAlign = "left"; + ctx.fillText(`Frame: ${frame}`, 10, 30); + ctx.fillText(`Time: ${(frame / 30).toFixed(1)}s`, 10, 60); + + frame++; + + // Change emoji every 60 frames (~2 seconds at 30fps) + if (frame % 60 === 0) { + emojiIndex = (emojiIndex + 1) % emojis.length; + } + }; + + // Start animation loop + const intervalId = setInterval(animate, 1000 / 30); // 30 FPS + + // Initial draw + animate(); + + // Create stream from canvas + const stream = canvas.captureStream(30); // 30 FPS + + // Store interval ID so we can clean it up + ( + stream as MediaStream & { _intervalId?: ReturnType } + )._intervalId = intervalId; + + return stream; +} + +/** + * Stops the canvas stream and cleans up the animation interval. + */ +export function stopCanvasStream(stream: MediaStream | null) { + if (!stream) return; + + // Clean up animation interval if it exists + const intervalId = ( + stream as MediaStream & { _intervalId?: ReturnType } + )._intervalId; + if (intervalId) { + clearInterval(intervalId); + } + + // Stop all tracks + stream.getTracks().forEach((track) => track.stop()); +} diff --git a/e2e-tests/livestream-client/src/main.tsx b/e2e-tests/livestream-client/src/main.tsx new file mode 100644 index 00000000..686cd572 --- /dev/null +++ b/e2e-tests/livestream-client/src/main.tsx @@ -0,0 +1,14 @@ +import { FishjamProvider } from "@fishjam-cloud/react-client"; +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { FISHJAM_URL } from "../config"; +import { App } from "./App"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/e2e-tests/livestream-client/src/vite-env.d.ts b/e2e-tests/livestream-client/src/vite-env.d.ts new file mode 100644 index 00000000..4b778ed8 --- /dev/null +++ b/e2e-tests/livestream-client/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_WHEP_URL?: string; + readonly VITE_WHIP_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/e2e-tests/livestream-client/tsconfig.json b/e2e-tests/livestream-client/tsconfig.json new file mode 100644 index 00000000..df9af620 --- /dev/null +++ b/e2e-tests/livestream-client/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "es2022"], + "module": "esnext", + "moduleResolution": "bundler", + "target": "es2022", + "jsx": "react-jsx", + "strict": true, + "incremental": true, + "skipLibCheck": true, + "esModuleInterop": true, + "declaration": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/e2e-tests/livestream-client/tsconfig.node.json b/e2e-tests/livestream-client/tsconfig.node.json new file mode 100644 index 00000000..91931218 --- /dev/null +++ b/e2e-tests/livestream-client/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts", "playwright.config.ts", "config.ts"] +} diff --git a/e2e-tests/livestream-client/vite.config.ts b/e2e-tests/livestream-client/vite.config.ts new file mode 100644 index 00000000..5a9f993a --- /dev/null +++ b/e2e-tests/livestream-client/vite.config.ts @@ -0,0 +1,8 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { port: 5174 }, +}); diff --git a/package.json b/package.json index 02d8f799..cbf67d37 100644 --- a/package.json +++ b/package.json @@ -14,13 +14,14 @@ "examples/mobile-client/*", "examples/mobile-client/common/plugins", "e2e-tests/webrtc-client", - "e2e-tests/react-client" + "e2e-tests/react-client", + "e2e-tests/livestream-client" ], "packageManager": "yarn@4.12.0", "scripts": { "build": "yarn workspaces foreach -Ap --topological-dev run build", "test:unit": "yarn workspaces foreach -Ap run test", - "test:e2e": "yarn workspace @fishjam-e2e/webrtc-client e2e && yarn workspace @fishjam-e2e/react-client e2e", + "test:e2e": "yarn workspaces foreach -A --include '@fishjam-e2e/*' run e2e", "gen:proto": "yarn workspace @fishjam-cloud/protobufs gen:proto", "tsc": "yarn workspaces foreach -Ap run tsc || echo '❌ Type errors! ❌' ", "format:root": "prettier . --write", diff --git a/packages/react-client/src/hooks/useLivestreamViewer.ts b/packages/react-client/src/hooks/useLivestreamViewer.ts index c6086ea5..238c79b5 100644 --- a/packages/react-client/src/hooks/useLivestreamViewer.ts +++ b/packages/react-client/src/hooks/useLivestreamViewer.ts @@ -24,6 +24,7 @@ export interface UseLivestreamViewerResult { error: LivestreamError | null; /** Utility flag which indicates the current connection status */ isConnected: boolean; + getStatistics: () => Promise; } const isLivestreamError = (err: unknown): err is LivestreamError => @@ -79,5 +80,7 @@ export const useLivestreamViewer = (): UseLivestreamViewerResult => { [disconnect, onConnectionStateChange, fishjamId], ); - return { stream, connect, disconnect, error, isConnected }; + const getStatistics = useCallback(async () => resultRef.current?.getStatistics(), []); + + return { stream, connect, disconnect, error, isConnected, getStatistics }; }; diff --git a/packages/ts-client/src/livestream.ts b/packages/ts-client/src/livestream.ts index a00ab1dc..b43db8eb 100644 --- a/packages/ts-client/src/livestream.ts +++ b/packages/ts-client/src/livestream.ts @@ -1,10 +1,12 @@ export type ReceiveLivestreamResult = { stream: MediaStream; stop: () => Promise; + getStatistics: () => Promise; }; export type PublishLivestreamResult = { stopPublishing: () => Promise; + getStatistics: () => Promise; }; export enum LivestreamError { @@ -33,6 +35,7 @@ export async function receiveLivestream(url: string, token?: string, callbacks?: const stream = event.streams[0]; if (stream) { resolve({ + getStatistics: () => pc.getStats(), stream, stop: async () => { await whep.stop(); @@ -89,6 +92,7 @@ export async function publishLivestream( } return { + getStatistics: () => pc.getStats(), stopPublishing: async () => { await whip.stop(); callbacks?.onConnectionStateChange?.(pc); diff --git a/yarn.lock b/yarn.lock index ae12863a..31cc74dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3949,6 +3949,30 @@ __metadata: languageName: unknown linkType: soft +"@fishjam-e2e/livestream-client@workspace:e2e-tests/livestream-client": + version: 0.0.0-use.local + resolution: "@fishjam-e2e/livestream-client@workspace:e2e-tests/livestream-client" + dependencies: + "@fishjam-cloud/react-client": "workspace:*" + "@fishjam-cloud/ts-client": "workspace:*" + "@playwright/test": "npm:^1.51.1" + "@types/node": "npm:^22.14.0" + "@types/react": "npm:19.1.0" + "@types/react-dom": "npm:19.1.0" + "@typescript-eslint/eslint-plugin": "npm:^8.29.1" + "@typescript-eslint/parser": "npm:^8.29.1" + "@vitejs/plugin-react": "npm:^4.3.4" + eslint: "npm:^8.57.1" + eslint-plugin-react-hooks: "npm:^5.2.0" + eslint-plugin-react-refresh: "npm:^0.4.19" + prettier: "npm:^3.8.1" + react: "npm:19.1.0" + react-dom: "npm:19.1.0" + typescript: "npm:^5.8.3" + vite: "npm:^6.2.6" + languageName: unknown + linkType: soft + "@fishjam-e2e/react-client@workspace:e2e-tests/react-client": version: 0.0.0-use.local resolution: "@fishjam-e2e/react-client@workspace:e2e-tests/react-client" @@ -16373,6 +16397,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.8.1": + version: 3.8.1 + resolution: "prettier@npm:3.8.1" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42 + languageName: node + linkType: hard + "pretty-bytes@npm:^5.6.0": version: 5.6.0 resolution: "pretty-bytes@npm:5.6.0"