From 8b458a3ddf54604f484fe4517fdcc14c77dc2252 Mon Sep 17 00:00:00 2001 From: Cesar Mendivil Date: Thu, 20 Nov 2025 12:50:38 -0700 Subject: [PATCH] feat: add initial LiveKit Meet integration with utility scripts, configs, and core components - Add Next.js app structure with base configs, linting, and formatting - Implement LiveKit Meet page, types, and utility functions - Add Docker, Compose, and deployment scripts for backend and token server - Provide E2E and smoke test scaffolding with Puppeteer and Playwright helpers - Include CSS modules and global styles for UI - Add postMessage and studio integration utilities - Update package.json with dependencies and scripts for development and testing --- .../validate-studio-flow-browserless.yml | 68 + .github/workflows/validate-studio-flow.yml | 80 + ARCHIVE_STUDIO.md | 6 + README-E2E.md | 59 + deploy/README-token-server.md | 51 + deploy/docker-compose.token.yml | 38 + deploy/token-server.Dockerfile | 41 + docker-compose.prod.yml | 17 - docs/E2E-TOKEN-FLOW.md | 233 + e2e/LOG.md | 143 + e2e/README-validate-flow.md | 56 + e2e/README.md | 187 + e2e/analyze_external_page_browserless.js | 149 + e2e/check-browserless-conn.js | 18 + e2e/decode_token.js | 29 + e2e/logging.js | 63 + e2e/mock_server.js | 109 + e2e/playwright/token-flow.js | 76 + e2e/print-log-summary.sh | 15 + e2e/publish_artifact.js | 70 + e2e/puppeteer-runner/.env.example | 0 e2e/puppeteer-runner/README.md | 0 e2e/puppeteer-runner/debug-chrome.js | 22 + .../debug-elements-screenshot.js | 22 + .../debug-list-elements-local.js | 28 + e2e/puppeteer-runner/debug-list-elements.js | 27 + e2e/puppeteer-runner/debug-post-token.js | 172 + e2e/puppeteer-runner/debug-run.js | 29 + e2e/puppeteer-runner/index.ts | 0 e2e/puppeteer-runner/package.json | 23 + e2e/puppeteer-runner/save-candidates.js | 20 + e2e/puppeteer-runner/send-token-direct.js | 0 e2e/puppeteer-runner/send-token-to-studio.js | 234 + e2e/puppeteer-runner/ws-test.js | 9 + e2e/run_e2e_with_mock.js | 121 + e2e/simulate_token_query_browserless.js | 72 + e2e/streamyard-flow-browserless.js | 194 + e2e/streamyard-flow-remote.js | 99 + e2e/streamyard-flow.js | 98 + e2e/test-pages/broadcast.html | 12 + e2e/validate-flow-browserless.js | 159 + e2e/validate-flow-domains-local.js | 162 + e2e/validate-flow-remote-chrome.js | 198 + e2e/validate-session-id-flow.js | 69 + e2e/ws-test.js | 61 + package-lock.json | 3994 ++++---------- package.json | 15 +- packages/avanza-ui/package.json | 6 +- packages/backend-api/.env.production | 3 + packages/backend-api/Dockerfile | 26 +- packages/backend-api/README.md | 123 + packages/backend-api/docker-compose.yml | 35 + packages/backend-api/docker-entrypoint.sh | 18 + packages/backend-api/package.json | 5 +- packages/backend-api/prisma/schema.prisma | 80 + .../backend-api/scripts/get_session_token.js | 92 + .../scripts/request_internal_session.js | 51 + .../backend-api/scripts/test-token-flow.js | 74 + .../scripts/test_generate_token.cjs | 64 + .../scripts/test_generate_token.js | 64 + .../scripts/test_prisma_session.js | 72 + packages/backend-api/src/index.ts | 529 +- packages/broadcast-panel/README-NGINX.md | 82 + packages/broadcast-panel/e2e/README.md | 38 + .../e2e/browserless_connect.mjs | 84 + .../e2e/dify-plugin-playwright.mjs | 122 + .../e2e/gemini_agent_server.py | 113 + .../broadcast-panel/e2e/gemini_log_agent.py | 272 + .../e2e/playwright-token-e2e.spec.ts | 0 .../e2e/playwright_connect.mjs | 109 + .../e2e/playwright_py_runner.py | 216 + .../e2e/puppeteer_browserless_e2e.cjs | 208 + .../e2e/puppeteer_browserless_e2e.js | 69 + .../e2e/puppeteer_connect_debug.mjs | 116 + .../e2e/puppeteer_local_debug.cjs | 49 + .../e2e/run_browserless_e2e.js | 211 + packages/broadcast-panel/e2e/run_local_e2e.js | 363 ++ .../e2e/run_studio_integration.sh | 52 + packages/broadcast-panel/e2e/smoke_test.py | 1 + .../e2e/start-chrome-remote.sh | 28 + packages/broadcast-panel/e2e/ws_connect.mjs | 57 + packages/broadcast-panel/nginx.conf | 14 + packages/broadcast-panel/package.json | 19 +- .../broadcast-panel/package-lock.json | 24 +- .../packages/broadcast-panel/package.json | 3 +- .../scripts/browser_e2e_local.cjs | 88 + .../scripts/browserless_e2e.cjs | 126 + .../scripts/browserless_test.cjs | 18 + .../broadcast-panel/scripts/e2e_mock_ui.cjs | 120 + .../scripts/fetch_public_page_browserless.cjs | 36 + .../src/components/NewTransmissionModal.tsx | 43 +- .../src/components/PageContainer.tsx | 4 +- .../broadcast-panel/src/components/Studio.tsx | 59 +- .../src/components/StudioConnector.tsx | 162 + .../src/components/TransmissionsTable.tsx | 130 +- .../src/features/studio/BottomControls.tsx | 171 + .../src/features/studio/StudioPortal.css | 17 + .../src/features/studio/StudioPortal.tsx | 219 + .../src/features/studio/StudioRoom.css | 7 + .../src/features/studio/StudioRoom.tsx | 373 ++ .../studio/__tests__/StudioPortal.test.tsx | 89 + .../__tests__/e2e_simulated_flow.test.tsx | 126 + .../studio/__tests__/postMessage.test.ts | 11 + .../features/studio/__tests__/smoke.test.tsx | 32 + .../features/studio/icons/IconCameraOn.tsx | 11 + .../src/features/studio/icons/IconMicOff.tsx | 12 + .../src/features/studio/index.ts | 3 + .../broadcast-panel/src/hooks/useLayouts.ts | 0 .../src/hooks/useStudioLauncher.ts | 211 +- .../src/hooks/useStudioMessageListener.ts | 33 + .../src/hooks/useStudioSession.ts | 63 + packages/broadcast-panel/src/main.tsx | 195 +- .../broadcast-panel/src/utils/postMessage.ts | 39 + packages/broadcast-panel/tsconfig.json | 6 +- packages/broadcast-panel/vite.config.ts | 83 +- packages/broadcast-panel/vitest.setup.ts | 87 + packages/meet/.env.example | 30 + packages/meet/.eslintrc.json | 3 + packages/meet/.gitattributes | 1 + packages/meet/.github/assets/livekit-mark.png | Bin 0 -> 832 bytes packages/meet/.github/assets/livekit-meet.jpg | Bin 0 -> 102195 bytes .../meet/.github/assets/template-dark.webp | Bin 0 -> 758 bytes .../meet/.github/assets/template-graphic.svg | 31 + .../meet/.github/assets/template-light.webp | Bin 0 -> 794 bytes .../.github/workflows/sync-to-production.yaml | 16 + packages/meet/.github/workflows/test.yaml | 32 + packages/meet/.gitignore | 38 + packages/meet/.prettierignore | 3 + packages/meet/.prettierrc | 7 + packages/meet/LICENSE | 202 + packages/meet/README.md | 42 + .../meet/app/api/connection-details/route.ts | 89 + packages/meet/app/api/record/start/route.ts | 70 + packages/meet/app/api/record/stop/route.ts | 39 + .../app/custom/VideoConferenceClientImpl.tsx | 97 + packages/meet/app/custom/page.tsx | 28 + packages/meet/app/layout.tsx | 60 + packages/meet/app/page.tsx | 201 + .../app/rooms/[roomName]/PageClientImpl.tsx | 233 + packages/meet/app/rooms/[roomName]/page.tsx | 33 + packages/meet/lib/CameraSettings.tsx | 175 + packages/meet/lib/Debug.tsx | 251 + packages/meet/lib/KeyboardShortcuts.tsx | 31 + packages/meet/lib/MicrophoneSettings.tsx | 55 + packages/meet/lib/RecordingIndicator.tsx | 40 + packages/meet/lib/SettingsMenu.tsx | 154 + packages/meet/lib/client-utils.ts | 29 + packages/meet/lib/getLiveKitURL.test.ts | 35 + packages/meet/lib/getLiveKitURL.ts | 12 + packages/meet/lib/types.ts | 28 + packages/meet/lib/usePerfomanceOptimiser.ts | 71 + packages/meet/lib/useSetupE2EE.ts | 15 + packages/meet/next-env.d.ts | 5 + packages/meet/next.config.js | 37 + packages/meet/package.json | 44 + packages/meet/pnpm-lock.yaml | 4795 +++++++++++++++++ .../ali-kazal-tbw_KQE3Cbg-unsplash.jpg | 3 + .../samantha-gades-BlIhVfXbi9s-unsplash.jpg | 3 + packages/meet/public/favicon.ico | Bin 0 -> 15406 bytes .../public/images/livekit-apple-touch.png | Bin 0 -> 323 bytes .../meet/public/images/livekit-meet-home.svg | 1 + .../public/images/livekit-meet-open-graph.png | Bin 0 -> 22701 bytes .../images/livekit-safari-pinned-tab.svg | 1 + packages/meet/renovate.json | 17 + .../meet/scripts/start_meeting_capture.js | 347 ++ packages/meet/styles/Debug.module.css | 16 + packages/meet/styles/Home.module.css | 40 + packages/meet/styles/SettingsMenu.module.css | 17 + packages/meet/styles/globals.css | 67 + packages/meet/tsconfig.json | 29 + packages/studio-panel-deprecated/README.md | 10 + packages/studio-panel/package.json | 4 +- .../src/components/Portal/ControlsBar.tsx | 0 .../components/Portal/DevTokenLauncher.tsx | 0 .../src/components/Portal/PresentersPanel.tsx | 0 .../src/components/Portal/RightTabs.tsx | 0 .../src/components/Portal/ScenesList.tsx | 0 .../src/components/Portal/StudioPortal.tsx | 292 +- .../src/components/StudioRoom/StudioRoom.tsx | 10 +- packages/token-server/Dockerfile | 20 + packages/token-server/README.md | 39 + .../token-server/docker-compose.token.yml | 18 + packages/token-server/docker-compose.yml | 14 + packages/token-server/package.json | 15 + packages/token-server/src/index.js | 143 + scripts/check_token_server.sh | 55 + scripts/check_token_server_node.mjs | 63 + scripts/collect_token_server_diagnostics.sh | 70 + scripts/e2e-orchestrator.sh | 155 + scripts/e2e-run.sh | 199 + scripts/e2e/attach_and_run.js | 141 + scripts/e2e/auto_enter_and_wait_token.js | 204 + scripts/e2e/run_broadcast_studio_flow.js | 127 + scripts/e2e/watch_browser.js | 154 + scripts/install-playwright-deps.sh | 42 + scripts/remote_puppeteer.cjs | 178 + scripts/run-local-e2e.sh | 67 + scripts/start-backend.sh | 36 + scripts/start-chrome-debug.sh | 91 + scripts/start-chrome-new-profile.sh | 101 + scripts/start-chrome-remote.sh | 14 + scripts/update_backend_env.sh | 94 + scripts/update_token_server_env.sh | 77 + scripts/verify_backend_after_update.sh | 86 + scripts/verify_token_server_after_update.sh | 66 + shared/components/AuthButton.tsx | 3 +- shared/utils/api.ts | 4 +- 207 files changed, 20455 insertions(+), 3124 deletions(-) create mode 100644 .github/workflows/validate-studio-flow-browserless.yml create mode 100644 .github/workflows/validate-studio-flow.yml create mode 100644 ARCHIVE_STUDIO.md create mode 100644 README-E2E.md create mode 100644 deploy/README-token-server.md create mode 100644 deploy/docker-compose.token.yml create mode 100644 deploy/token-server.Dockerfile create mode 100644 docs/E2E-TOKEN-FLOW.md create mode 100644 e2e/LOG.md create mode 100644 e2e/README-validate-flow.md create mode 100644 e2e/README.md create mode 100644 e2e/analyze_external_page_browserless.js create mode 100644 e2e/check-browserless-conn.js create mode 100644 e2e/decode_token.js create mode 100644 e2e/logging.js create mode 100644 e2e/mock_server.js create mode 100644 e2e/playwright/token-flow.js create mode 100755 e2e/print-log-summary.sh create mode 100644 e2e/publish_artifact.js create mode 100644 e2e/puppeteer-runner/.env.example create mode 100644 e2e/puppeteer-runner/README.md create mode 100644 e2e/puppeteer-runner/debug-chrome.js create mode 100644 e2e/puppeteer-runner/debug-elements-screenshot.js create mode 100644 e2e/puppeteer-runner/debug-list-elements-local.js create mode 100644 e2e/puppeteer-runner/debug-list-elements.js create mode 100644 e2e/puppeteer-runner/debug-post-token.js create mode 100644 e2e/puppeteer-runner/debug-run.js create mode 100644 e2e/puppeteer-runner/index.ts create mode 100644 e2e/puppeteer-runner/package.json create mode 100644 e2e/puppeteer-runner/save-candidates.js create mode 100644 e2e/puppeteer-runner/send-token-direct.js create mode 100644 e2e/puppeteer-runner/send-token-to-studio.js create mode 100644 e2e/puppeteer-runner/ws-test.js create mode 100644 e2e/run_e2e_with_mock.js create mode 100644 e2e/simulate_token_query_browserless.js create mode 100644 e2e/streamyard-flow-browserless.js create mode 100644 e2e/streamyard-flow-remote.js create mode 100644 e2e/streamyard-flow.js create mode 100644 e2e/test-pages/broadcast.html create mode 100644 e2e/validate-flow-browserless.js create mode 100644 e2e/validate-flow-domains-local.js create mode 100644 e2e/validate-flow-remote-chrome.js create mode 100644 e2e/validate-session-id-flow.js create mode 100644 e2e/ws-test.js create mode 100644 packages/backend-api/README.md create mode 100644 packages/backend-api/docker-compose.yml create mode 100755 packages/backend-api/docker-entrypoint.sh create mode 100644 packages/backend-api/prisma/schema.prisma create mode 100644 packages/backend-api/scripts/get_session_token.js create mode 100644 packages/backend-api/scripts/request_internal_session.js create mode 100644 packages/backend-api/scripts/test-token-flow.js create mode 100644 packages/backend-api/scripts/test_generate_token.cjs create mode 100644 packages/backend-api/scripts/test_generate_token.js create mode 100644 packages/backend-api/scripts/test_prisma_session.js create mode 100644 packages/broadcast-panel/README-NGINX.md create mode 100644 packages/broadcast-panel/e2e/README.md create mode 100644 packages/broadcast-panel/e2e/browserless_connect.mjs create mode 100644 packages/broadcast-panel/e2e/dify-plugin-playwright.mjs create mode 100644 packages/broadcast-panel/e2e/gemini_agent_server.py create mode 100644 packages/broadcast-panel/e2e/gemini_log_agent.py create mode 100644 packages/broadcast-panel/e2e/playwright-token-e2e.spec.ts create mode 100644 packages/broadcast-panel/e2e/playwright_connect.mjs create mode 100644 packages/broadcast-panel/e2e/playwright_py_runner.py create mode 100644 packages/broadcast-panel/e2e/puppeteer_browserless_e2e.cjs create mode 100644 packages/broadcast-panel/e2e/puppeteer_browserless_e2e.js create mode 100644 packages/broadcast-panel/e2e/puppeteer_connect_debug.mjs create mode 100644 packages/broadcast-panel/e2e/puppeteer_local_debug.cjs create mode 100644 packages/broadcast-panel/e2e/run_browserless_e2e.js create mode 100644 packages/broadcast-panel/e2e/run_local_e2e.js create mode 100644 packages/broadcast-panel/e2e/run_studio_integration.sh create mode 100644 packages/broadcast-panel/e2e/smoke_test.py create mode 100644 packages/broadcast-panel/e2e/start-chrome-remote.sh create mode 100644 packages/broadcast-panel/e2e/ws_connect.mjs create mode 100644 packages/broadcast-panel/scripts/browser_e2e_local.cjs create mode 100644 packages/broadcast-panel/scripts/browserless_e2e.cjs create mode 100644 packages/broadcast-panel/scripts/browserless_test.cjs create mode 100644 packages/broadcast-panel/scripts/e2e_mock_ui.cjs create mode 100644 packages/broadcast-panel/scripts/fetch_public_page_browserless.cjs create mode 100644 packages/broadcast-panel/src/components/StudioConnector.tsx create mode 100644 packages/broadcast-panel/src/features/studio/BottomControls.tsx create mode 100644 packages/broadcast-panel/src/features/studio/StudioPortal.css create mode 100644 packages/broadcast-panel/src/features/studio/StudioPortal.tsx create mode 100644 packages/broadcast-panel/src/features/studio/StudioRoom.css create mode 100644 packages/broadcast-panel/src/features/studio/StudioRoom.tsx create mode 100644 packages/broadcast-panel/src/features/studio/__tests__/StudioPortal.test.tsx create mode 100644 packages/broadcast-panel/src/features/studio/__tests__/e2e_simulated_flow.test.tsx create mode 100644 packages/broadcast-panel/src/features/studio/__tests__/postMessage.test.ts create mode 100644 packages/broadcast-panel/src/features/studio/__tests__/smoke.test.tsx create mode 100644 packages/broadcast-panel/src/features/studio/icons/IconCameraOn.tsx create mode 100644 packages/broadcast-panel/src/features/studio/icons/IconMicOff.tsx create mode 100644 packages/broadcast-panel/src/features/studio/index.ts create mode 100644 packages/broadcast-panel/src/hooks/useLayouts.ts create mode 100644 packages/broadcast-panel/src/hooks/useStudioMessageListener.ts create mode 100644 packages/broadcast-panel/src/hooks/useStudioSession.ts create mode 100644 packages/broadcast-panel/src/utils/postMessage.ts create mode 100644 packages/broadcast-panel/vitest.setup.ts create mode 100644 packages/meet/.env.example create mode 100644 packages/meet/.eslintrc.json create mode 100644 packages/meet/.gitattributes create mode 100644 packages/meet/.github/assets/livekit-mark.png create mode 100644 packages/meet/.github/assets/livekit-meet.jpg create mode 100644 packages/meet/.github/assets/template-dark.webp create mode 100644 packages/meet/.github/assets/template-graphic.svg create mode 100644 packages/meet/.github/assets/template-light.webp create mode 100644 packages/meet/.github/workflows/sync-to-production.yaml create mode 100644 packages/meet/.github/workflows/test.yaml create mode 100644 packages/meet/.gitignore create mode 100644 packages/meet/.prettierignore create mode 100644 packages/meet/.prettierrc create mode 100644 packages/meet/LICENSE create mode 100644 packages/meet/README.md create mode 100644 packages/meet/app/api/connection-details/route.ts create mode 100644 packages/meet/app/api/record/start/route.ts create mode 100644 packages/meet/app/api/record/stop/route.ts create mode 100644 packages/meet/app/custom/VideoConferenceClientImpl.tsx create mode 100644 packages/meet/app/custom/page.tsx create mode 100644 packages/meet/app/layout.tsx create mode 100644 packages/meet/app/page.tsx create mode 100644 packages/meet/app/rooms/[roomName]/PageClientImpl.tsx create mode 100644 packages/meet/app/rooms/[roomName]/page.tsx create mode 100644 packages/meet/lib/CameraSettings.tsx create mode 100644 packages/meet/lib/Debug.tsx create mode 100644 packages/meet/lib/KeyboardShortcuts.tsx create mode 100644 packages/meet/lib/MicrophoneSettings.tsx create mode 100644 packages/meet/lib/RecordingIndicator.tsx create mode 100644 packages/meet/lib/SettingsMenu.tsx create mode 100644 packages/meet/lib/client-utils.ts create mode 100644 packages/meet/lib/getLiveKitURL.test.ts create mode 100644 packages/meet/lib/getLiveKitURL.ts create mode 100644 packages/meet/lib/types.ts create mode 100644 packages/meet/lib/usePerfomanceOptimiser.ts create mode 100644 packages/meet/lib/useSetupE2EE.ts create mode 100644 packages/meet/next-env.d.ts create mode 100644 packages/meet/next.config.js create mode 100644 packages/meet/package.json create mode 100644 packages/meet/pnpm-lock.yaml create mode 100644 packages/meet/public/background-images/ali-kazal-tbw_KQE3Cbg-unsplash.jpg create mode 100644 packages/meet/public/background-images/samantha-gades-BlIhVfXbi9s-unsplash.jpg create mode 100644 packages/meet/public/favicon.ico create mode 100644 packages/meet/public/images/livekit-apple-touch.png create mode 100644 packages/meet/public/images/livekit-meet-home.svg create mode 100644 packages/meet/public/images/livekit-meet-open-graph.png create mode 100644 packages/meet/public/images/livekit-safari-pinned-tab.svg create mode 100644 packages/meet/renovate.json create mode 100644 packages/meet/scripts/start_meeting_capture.js create mode 100644 packages/meet/styles/Debug.module.css create mode 100644 packages/meet/styles/Home.module.css create mode 100644 packages/meet/styles/SettingsMenu.module.css create mode 100644 packages/meet/styles/globals.css create mode 100644 packages/meet/tsconfig.json create mode 100644 packages/studio-panel-deprecated/README.md create mode 100644 packages/studio-panel/src/components/Portal/ControlsBar.tsx create mode 100644 packages/studio-panel/src/components/Portal/DevTokenLauncher.tsx create mode 100644 packages/studio-panel/src/components/Portal/PresentersPanel.tsx create mode 100644 packages/studio-panel/src/components/Portal/RightTabs.tsx create mode 100644 packages/studio-panel/src/components/Portal/ScenesList.tsx create mode 100644 packages/token-server/Dockerfile create mode 100644 packages/token-server/README.md create mode 100644 packages/token-server/docker-compose.token.yml create mode 100644 packages/token-server/docker-compose.yml create mode 100644 packages/token-server/package.json create mode 100644 packages/token-server/src/index.js create mode 100755 scripts/check_token_server.sh create mode 100755 scripts/check_token_server_node.mjs create mode 100755 scripts/collect_token_server_diagnostics.sh create mode 100755 scripts/e2e-orchestrator.sh create mode 100755 scripts/e2e-run.sh create mode 100644 scripts/e2e/attach_and_run.js create mode 100644 scripts/e2e/auto_enter_and_wait_token.js create mode 100644 scripts/e2e/run_broadcast_studio_flow.js create mode 100644 scripts/e2e/watch_browser.js create mode 100755 scripts/install-playwright-deps.sh create mode 100644 scripts/remote_puppeteer.cjs create mode 100755 scripts/run-local-e2e.sh create mode 100755 scripts/start-backend.sh create mode 100755 scripts/start-chrome-debug.sh create mode 100755 scripts/start-chrome-new-profile.sh create mode 100755 scripts/start-chrome-remote.sh create mode 100644 scripts/update_backend_env.sh create mode 100644 scripts/update_token_server_env.sh create mode 100644 scripts/verify_backend_after_update.sh create mode 100644 scripts/verify_token_server_after_update.sh diff --git a/.github/workflows/validate-studio-flow-browserless.yml b/.github/workflows/validate-studio-flow-browserless.yml new file mode 100644 index 0000000..88c620e --- /dev/null +++ b/.github/workflows/validate-studio-flow-browserless.yml @@ -0,0 +1,68 @@ +name: Validate Studio Flow (Browserless) + +on: + workflow_dispatch: + inputs: + browserless_ws: + description: 'WebSocket endpoint for browserless (wss://...)' + required: true + default: '' + browserless_token: + description: 'Optional token for browserless (appended as ?token=...)' + required: false + default: '' + streamyard_email: + description: 'Optional StreamYard email (for login)' + required: false + default: '' + streamyard_password: + description: 'Optional StreamYard password (for login)' + required: false + default: '' + +jobs: + browserless-validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install deps + run: | + npm ci || npm install --no-audit --no-fund + + - name: Ensure puppeteer-core installed + run: | + if [ ! -d node_modules/puppeteer-core ]; then npm install --no-audit --no-fund puppeteer-core@24.30.0; fi + + - name: Run browserless flow + env: + BROWSERLESS_WS: ${{ github.event.inputs.browserless_ws }} + BROWSERLESS_TOKEN: ${{ github.event.inputs.browserless_token }} + STREAMYARD_EMAIL: ${{ github.event.inputs.streamyard_email }} + STREAMYARD_PASSWORD: ${{ github.event.inputs.streamyard_password }} + run: | + echo "Connecting to browserless at $BROWSERLESS_WS" + node e2e/streamyard-flow-browserless.js + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: browserless-studio-flow-results + path: | + e2e/streamyard-flow-browserless-result.json + e2e/streamyard_flow_browserless.png + + - name: Show result + if: always() + run: | + echo '---- result (if present) ----' + if [ -f e2e/streamyard-flow-browserless-result.json ]; then cat e2e/streamyard-flow-browserless-result.json; else echo 'No JSON result found'; fi + echo '------------------------------' + diff --git a/.github/workflows/validate-studio-flow.yml b/.github/workflows/validate-studio-flow.yml new file mode 100644 index 0000000..5f98820 --- /dev/null +++ b/.github/workflows/validate-studio-flow.yml @@ -0,0 +1,80 @@ +name: Validate Studio Flow (E2E) + +on: + workflow_dispatch: + inputs: + token: + description: 'E2E token (ex: e2e098...)' + required: true + default: '' + vite_broadcast_url: + description: 'VITE_BROADCASTPANEL_URL' + required: true + default: 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + vite_studio_url: + description: 'VITE_STUDIO_URL (optional)' + required: false + default: 'https://avanzacast-studio.bfzqqk.easypanel.host' + vite_livekit_ws: + description: 'VITE_LIVEKIT_WS_URL (optional)' + required: false + default: 'wss://livekit-server.bfzqqk.easypanel.host' + vite_token_server: + description: 'VITE_TOKEN_SERVER_URL (optional)' + required: false + default: 'https://avanzacast-servertokens.bfzqqk.easypanel.host' + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install system deps for Puppeteer + run: | + sudo apt-get update + sudo apt-get install -y ca-certificates fonts-liberation libatk1.0-0 libatk-bridge2.0-0 libc6 libcairo2 libdbus-1-3 libgdk-pixbuf2.0-0 libnspr4 libnss3 libx11-6 libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 libxss1 libasound2 libgbm1 libgtk-3-0 libpangocairo-1.0-0 + + - name: Install node dependencies + run: | + npm ci || npm install --no-audit --no-fund + + - name: Install puppeteer (if missing) and build + run: | + if [ ! -d node_modules/puppeteer ]; then npm install --no-audit --no-fund puppeteer@19.11.1; fi + + - name: Run studio flow validation + env: + TOKEN: ${{ github.event.inputs.token }} + VITE_BROADCASTPANEL_URL: ${{ github.event.inputs.vite_broadcast_url }} + VITE_STUDIO_URL: ${{ github.event.inputs.vite_studio_url }} + VITE_LIVEKIT_WS_URL: ${{ github.event.inputs.vite_livekit_ws }} + VITE_TOKEN_SERVER_URL: ${{ github.event.inputs.vite_token_server }} + # Run headless in CI + HEADLESS: '1' + run: | + echo "Running E2E validate script with TOKEN=${TOKEN}" + node e2e/validate-flow-domains-local.js + + - name: Upload results + if: always() + uses: actions/upload-artifact@v4 + with: + name: studio-flow-results + path: | + e2e/studio-flow-domains-result.json + e2e/studio_flow_result.png + + - name: Show small summary (result file) + if: always() + run: | + echo '---- results (if present) ----' + if [ -f e2e/studio-flow-domains-result.json ]; then cat e2e/studio-flow-domains-result.json; else echo 'No JSON result found'; fi + echo '------------------------------' + diff --git a/ARCHIVE_STUDIO.md b/ARCHIVE_STUDIO.md new file mode 100644 index 0000000..784e5a9 --- /dev/null +++ b/ARCHIVE_STUDIO.md @@ -0,0 +1,6 @@ +# studio-panel archived + +The `studio-panel` package has been archived and migrated into `packages/broadcast-panel/src/features/studio`. + +If you need to restore the old package, check `packages/studio-panel-deprecated` which contains the previous files and package.json (renamed to indicate deprecation). + diff --git a/README-E2E.md b/README-E2E.md new file mode 100644 index 0000000..3f4edc1 --- /dev/null +++ b/README-E2E.md @@ -0,0 +1,59 @@ +AvanzaCast - E2E / Token flow quick guide + +Resumen rápido +- backend-api: sirve endpoints para generar y almacenar sesiones/tokens (http://localhost:4000) +- broadcast-panel: UI frontend (http://localhost:5175 en dev) +- studioportal: abre con ?session= y debe pedir token a backend-api /api/session/:id/token +- LiveKit token generation se hace en backend-api (/api/session or /api/token) + +Setup local (prereqs) +- Node 18+ / npm +- Postgres accesible desde tu máquina (la URL que usamos: postgres://postgres:72ff3d8d80c352f89d99@192.168.1.20:5433/avanzacast) +- Google Chrome stable (usar scripts/start-chrome-remote.sh para abrir con remote-debugging) + +Instrucciones rápidas +1) Abrir Chrome con perfil persistente (para debugging visible y mantener cookies): + +```bash +chmod +x scripts/start-chrome-remote.sh +./scripts/start-chrome-remote.sh +# comprobar la API de debug +curl http://127.0.0.1:9222/json/version +``` + +2) Arrancar el backend-api (build + start): + +```bash +chmod +x scripts/start-backend.sh +# foreground (ver logs en terminal): +./scripts/start-backend.sh +# background: +./scripts/start-backend.sh background +# luego: +# tail -f packages/backend-api/logs/backend-.log +``` + +3) Probar endpoints con curl: + +```bash +curl http://localhost:4000/health +curl -X POST http://localhost:4000/api/session -H "Content-Type: application/json" -d '{"room":"demo-room","username":"testuser"}' | jq . +curl http://localhost:4000/api/session//token | jq . +``` + +4) Ejecutar E2E script (usa browserless o Chrome remoto): + +```bash +# usando browserless remote +BROWSERLESS_TOKEN=e2e098863b912f6a178b68e71ec3c58d BROADCAST_URL=http://localhost:5175 \ + node packages/broadcast-panel/scripts/browserless_e2e.cjs + +# usando Chrome local (si el script lo soporta, o adapta browserless_e2e.cjs a browserURL): +# primero asegúrate de que Chrome está abierto con --remote-debugging-port=9222 +# y luego adapta el script para usar puppeteer.connect({ browserURL: 'http://127.0.0.1:9222' }) +``` + +Notas importantes +- Si usas browserless remoto (wss://browserless...), el navegador remoto no podrá acceder a http://localhost:5175 de tu máquina; para pruebas locales usa Chrome con remote debugging o expón tu frontend públicamente. +- Por seguridad, en producción NO incluyas tokens en query params (INCLUDE_TOKEN_IN_REDIRECT solo para pruebas). Usa sesiones identificadas por id y que el studio portal haga GET al backend por token. + diff --git a/deploy/README-token-server.md b/deploy/README-token-server.md new file mode 100644 index 0000000..b44f973 --- /dev/null +++ b/deploy/README-token-server.md @@ -0,0 +1,51 @@ +AvanzaCast - Token Server (Docker) + +Este docker-compose levanta el `backend-api` (token server) en un contenedor y lo expone en el puerto 4000. + +Files created: +- deploy/token-server.Dockerfile +- deploy/docker-compose.token.yml + +Requisitos +- Docker y docker-compose instalados +- (Opcional) Redis corriendo localmente en la máquina host en el puerto 6379 + +Variables de entorno recomendadas +- LIVEKIT_API_KEY (default: devkey) +- LIVEKIT_API_SECRET (default: secret) +- LIVEKIT_WS_URL (default: wss://livekit-server.bfzqqk.easypanel.host) +- REDIS_URL (por defecto apunta a host.docker.internal:6379) +- VITE_BROADCASTPANEL_URL +- VITE_STUDIO_URL + +Cómo ejecutar +1) Desde la raíz del repo (donde está `deploy/`): + +```bash +# construir y levantar +docker compose -f deploy/docker-compose.token.yml up --build -d + +# ver logs +docker compose -f deploy/docker-compose.token.yml logs -f token-server + +# verificar salud +curl http://localhost:4000/health +``` + +Notas sobre Redis y Docker +- El compose usa `REDIS_URL=redis://host.docker.internal:6379` por defecto para que el contenedor se conecte al Redis que corre en el host (macOS/Windows y algunas configuraciones de Linux). Si usas Linux y `host.docker.internal` no funciona, puedes: + - usar `network_mode: host` en el servicio `token-server` (en `docker-compose.token.yml`) y poner `REDIS_URL=redis://127.0.0.1:6379`, o + - habilitar un servicio Redis en el mismo docker-compose (descomenta la sección `redis` en el archivo). + +Cómo pasar variables en tiempo de ejecución +```bash +LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret REDIS_URL=redis://127.0.0.1:6379 docker compose -f deploy/docker-compose.token.yml up --build -d +``` + +Debug +- Para entrar en el contenedor: + docker exec -it avanzacast-token-server sh +- Logs: + docker compose -f deploy/docker-compose.token.yml logs -f token-server + +Si quieres, puedo lanzar el compose aquí y verificar que /health responda y que la ruta /api/token genere tokens correctamente; dime si quieres que lo haga (el runner intentará ejecutar Docker en el entorno). diff --git a/deploy/docker-compose.token.yml b/deploy/docker-compose.token.yml new file mode 100644 index 0000000..ca3e004 --- /dev/null +++ b/deploy/docker-compose.token.yml @@ -0,0 +1,38 @@ +services: + backend-api: + build: + context: ../packages/backend-api + dockerfile: Dockerfile + image: avanzacast-backend-api:latest + container_name: avanzacast-backend-api + restart: unless-stopped + # En entornos Linux locales podemos usar network_mode: host para que el contenedor vea redis local y otros servicios + network_mode: "host" + environment: + - NODE_ENV=production + - LIVEKIT_API_KEY=${LIVEKIT_API_KEY:-devkey} + - LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET:-secret} + - LIVEKIT_WS_URL=${LIVEKIT_WS_URL:-wss://livekit-server.bfzqqk.easypanel.host} + - REDIS_URL=${REDIS_URL:-redis://127.0.0.1:6379} + - INCLUDE_TOKEN_IN_REDIRECT=${INCLUDE_TOKEN_IN_REDIRECT:-1} + - FRONTEND_URLS=${FRONTEND_URLS:-} + - VITE_BROADCASTPANEL_URL=${VITE_BROADCASTPANEL_URL:-https://avanzacast-broadcastpanel.bfzqqk.easypanel.host} + - VITE_STUDIO_URL=${VITE_STUDIO_URL:-https://avanzacast-studio.bfzqqk.easypanel.host} + healthcheck: + test: ["CMD-SHELL","curl -f http://localhost:4000/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + +# Optional: define local redis service if you want containerized redis (commented by default) +# redis: +# image: redis:7-alpine +# restart: unless-stopped +# ports: +# - "6379:6379" +# volumes: +# - redis-data:/data + +#volumes: +# redis-data: +# driver: local diff --git a/deploy/token-server.Dockerfile b/deploy/token-server.Dockerfile new file mode 100644 index 0000000..5f62e06 --- /dev/null +++ b/deploy/token-server.Dockerfile @@ -0,0 +1,41 @@ +# Dockerfile para token-server (multi-stage) +# Construye una imagen ligera que ejecute el token server en Node.js + +# Builder: install deps y preparar archivos +FROM node:20-alpine AS builder +WORKDIR /app + +# Instalar herramientas de construcción +RUN apk add --no-cache python3 make g++ bash + +# Copiar archivos del paquete token-server e instalar dependencias +COPY packages/token-server/package.json packages/token-server/package-lock.json* ./ +# Usar npm ci si existe package-lock; de lo contrario, usar npm install +RUN if [ -f package-lock.json ]; then \ + npm ci --silent || (npm install --silent --legacy-peer-deps); \ + else \ + npm install --silent --legacy-peer-deps; \ + fi + +# Copiar el código fuente del token-server +COPY packages/token-server ./ + +# No se requiere paso de construcción para este servidor simple (es JavaScript puro) + +# Runtime: imagen mínima con dependencias de producción y código fuente +FROM node:20-alpine AS runtime +WORKDIR /app + +# Copiar node_modules instalados del builder al runtime +COPY --from=builder /app/node_modules ./node_modules + +# Copiar archivos fuente +COPY --from=builder /app/src ./src +COPY --from=builder /app/package.json ./package.json + +ENV NODE_ENV=production +ENV PORT=4000 +EXPOSE 4000 + +# Ejecutar el servidor (el token-server usa src/index.js) +CMD ["node", "src/index.js"] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d7cc13a..a8f0ee7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -20,22 +20,6 @@ services: timeout: 5s retries: 5 - studio-panel: - build: - context: . - dockerfile: ./packages/studio-panel/Dockerfile.simple - environment: - - VITE_TOKEN_SERVER_URL=http://backend-api:4000 - - VITE_STUDIO_URL=https://avanzacast-studio.bfzqqk.easypanel.host - - VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host - restart: unless-stopped - networks: - - webnet - expose: - - "80" - volumes: - - ./docker/letsencrypt:/etc/letsencrypt:ro - broadcast-panel: build: context: ./packages/broadcast-panel @@ -69,7 +53,6 @@ services: - "443:443" depends_on: - backend-api - - studio-panel - broadcast-panel networks: - webnet diff --git a/docs/E2E-TOKEN-FLOW.md b/docs/E2E-TOKEN-FLOW.md new file mode 100644 index 0000000..2106af0 --- /dev/null +++ b/docs/E2E-TOKEN-FLOW.md @@ -0,0 +1,233 @@ +# Guía E2E — Flujo de generación de tokens entre broadcast-panel y backend-api + +Objetivo +--------- +Esta guía explica paso a paso cómo preparar el entorno y probar la generación/validación de tokens que el `backend-api` crea para que `broadcast-panel` (y su `studioportal`) inicien sesiones de LiveKit. + +Resumen rápido +-------------- +- Preparar la base de datos (Postgres) y Prisma. +- Levantar `backend-api` (con `prisma generate` aplicado). +- Levantar `broadcast-panel` (Vite). +- Opcional: levantar Playwright server local o usar browserless remoto para E2E. +- Ejecutar pruebas manuales (curl + UI) y automatizadas (plugin Playwright ya incluido: `packages/broadcast-panel/e2e/dify-plugin-playwright.mjs`). + +Checklist mínimo +----------------- +- [ ] Postgres accesible (connection string correcta) +- [ ] `prisma generate` ejecutado y migraciones aplicadas +- [ ] `backend-api` corriendo +- [ ] `broadcast-panel` corriendo en `http://localhost:5176` (o puerto elegido) +- [ ] Playwright server corriendo (opcional: `npx playwright run-server --port 3003`) +- [ ] `npx playwright install chromium` ejecutado si se usa fallback local +- [ ] Plugin E2E probado: `node packages/broadcast-panel/e2e/dify-plugin-playwright.mjs` + +Entorno y variables clave +------------------------- +- DATABASE_URL (Postgres): + - Ejemplo: `postgres://postgres:72ff3d8d80c352f89d99@192.168.1.20:5433/avanzacast?sslmode=disable` +- LIVEKIT_API_KEY / LIVEKIT_API_SECRET (para generación de tokens en backend) +- X-BACKEND-SECRET (si el backend requiere un header secreto entre servicios) + +1) Preparar la base de datos y Prisma +----------------------------------- +1. Asegúrate que la DB está accesible desde la máquina donde ejecutas las pruebas. +2. En `packages/backend-api` ejecuta: + +```bash +cd packages/backend-api +# Generar prisma client +npx prisma generate +# Aplicar migraciones dev o deploy según tu flujo +npx prisma migrate deploy +# o en desarrollo +npx prisma db push +``` + +3. Verifica tablas (ejemplo con psql): + +```bash +PGPASSWORD='72ff3d8d80c352f89d99' psql -h 192.168.1.20 -p 5433 -U postgres -d avanzacast -c '\dt' +``` + +2) Configurar y arrancar backend-api +------------------------------------ +1. Añade las variables de entorno necesarias (LIVEKIT_API_KEY / LIVEKIT_API_SECRET y DATABASE_URL). Puedes usar `.env` o exportarlas: + +```bash +export DATABASE_URL='postgres://postgres:...' +export LIVEKIT_API_KEY='devkey' +export LIVEKIT_API_SECRET='secret' +export X_BACKEND_SECRET='secreto-interno' +``` + +2. Lanza el backend: + +```bash +cd packages/backend-api +npm install +npm run dev # o la forma que uses para arrancar en dev +# Si dockerizas: docker-compose up --build +``` + +3. Comprueba salud: + +```bash +curl -sS http://localhost:4000/health +# o +curl -sS http://localhost:4000 | jq .' # según endpoint +``` + +3) Arrancar broadcast-panel (frontend) +-------------------------------------- +1. En otra terminal: + +```bash +cd packages/broadcast-panel +npm install +# Forzar puerto 5176 (ejemplo) +PORT=5176 npm run dev -- --port 5176 +# o: npx vite --port 5176 --host 127.0.0.1 +``` + +2. Abre `http://localhost:5176` y prueba el flujo manual (crear transmisión → omitir modal → Empezar ahora → Entrar al Estudio). + +4) Probar flujo manual con curl (backend) +---------------------------------------- +- Crear sesión (ejemplo): + +```bash +curl -X POST 'http://localhost:4000/api/session' \ + -H 'Content-Type: application/json' \ + -d '{"title":"Transmision e2e","ownerId":"user-casa"}' | jq +``` + +Respuesta esperada: JSON con `{ id, studioUrl, redirectUrl, ttlSeconds }`. + +- Generar token (si el backend ofrece endpoint explícito): + +```bash +curl -X POST 'http://localhost:4000/api/session//token' -H 'Content-Type: application/json' -d '{}' | jq +``` + +Respuesta esperada: `{ token: "ey...", ttlSeconds: 300 }`. + +5) Probar la conexión token → LiveKit (manual) +---------------------------------------------- +- En `studioportal` (la página que abre broadcast-panel), inspecciona consola de JS y network para ver que se obtiene el token y que `livekit-client` intenta conectar. +- Si la consola muestra errores relacionados con token inválido, revisa las credenciales LIVEKIT_API_* en el backend. + +6) E2E automatizado con Playwright (plugin incluido) +---------------------------------------------------- +He añadido un plugin CLI en `packages/broadcast-panel/e2e/dify-plugin-playwright.mjs` que realiza el flujo básico (con fallback local cuando falla la conexión al Playwright server remoto). + +Pasos para usarlo (recomendado): + +1. Instalar navegadores Playwright (si no lo hiciste): + +```bash +# helper que creé +./scripts/install-playwright-deps.sh +# o manualmente +cd packages/broadcast-panel +npm install +npx playwright install chromium +``` + +2. Levantar Playwright server (opcional) — útil para ejecutar E2E desde controlador externo: + +```bash +npx playwright run-server --port 3003 --host 0.0.0.0 +``` + +3. Ejecutar el plugin CLI (desde la raíz del repo): + +```bash +node packages/broadcast-panel/e2e/dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png +``` + +- Si `--ws` falla o no es compatible por versión, el plugin hace fallback e intenta lanzar Chromium local. +- Salida: JSON impreso en stdout con estructura { success, used: 'remote'|'local-launch', steps, out, error }. +- Captura: `/tmp/dify-shot.png`. + +Ejemplo de uso con npm script (ya añadido): + +```bash +cd packages/broadcast-panel +npm run e2e:dify +# esto ejecuta el plugin con las opciones por defecto definidas en package.json +``` + +7) E2E con Puppeteer + Browserless (alternativa) +------------------------------------------------ +- Si usas browserless remote, en `packages/broadcast-panel/e2e` hay scripts: `browserless_connect.mjs`, `puppeteer_browserless_e2e.js`. +- Para probar ws directo y debug, existe `e2e/ws_connect.mjs`. + +Ejemplo flujo rápido con browserless: + +```bash +# comprobar WSS +node packages/broadcast-panel/e2e/ws_connect.mjs +# ejecutar script que usa browserless +BROWSERLESS_WS='ws://browserless.bfzqqk.easypanel.host?token=TOKEN' node packages/broadcast-panel/e2e/browserless_connect.mjs +``` + +8) Comprobaciones y logs a recopilar en fallos +--------------------------------------------- +Cuando algo falle, pega estos outputs (útiles para diagnóstico): + +- Output del plugin Playwright (/tmp/dify-plugin-output.log o stdout) +- `ls -la /tmp/dify-shot.png` (si existe) +- Vite dev log (ej. `/tmp/broadcast_dev_5176.log` o `packages/broadcast-panel/vite-dev.log`) +- Backend logs (stdout / docker logs) +- Resultado de `npx prisma generate` o errores de Prisma +- Postgres connectivity test: + +```bash +PGPASSWORD='72ff3d8d80c352f89d99' psql -h 192.168.1.20 -p 5433 -U postgres -d avanzacast -c "select id, token, createdat from \"Session\" order by createdat desc limit 10;" +``` + +- Playwright server log (cuando ejecutas `npx playwright run-server` aparece en la terminal) + +9) Errores comunes y soluciones rápidas +-------------------------------------- +- Playwright version mismatch: sincroniza versión del cliente (en `packages/broadcast-panel`) con la del server que usas, o usa fallback local. Ej: + +```bash +cd packages/broadcast-panel +npm install playwright@1.51.0 +npx playwright install chromium +``` + +- "BEWARE: your OS is not officially supported... fallback ubuntu20.04-x64": es un aviso normal en distros no listadas; ejecutar `npx playwright install chromium` y/o `./scripts/install-playwright-deps.sh` para instalar dependencias de sistema. + +- `@prisma/client did not initialize yet`: ejecutar `npx prisma generate` antes de arrancar backend. + +- `ERR_CONNECTION_REFUSED` navegando a `http://localhost:5176/`: arranca el dev server (Vite) en ese puerto. + +10) Pruebas adicionales automáticas (sugerencia) +------------------------------------------------ +- Crear un script orquestador `scripts/e2e-run.sh` que: + - levante Postgres (container) si no existe, + - aplique migraciones, + - arranque backend-api en background, + - arranque broadcast-panel en background, + - arranque Playwright run-server, + - ejecute `node e2e/dify-plugin-playwright.mjs`, y + - recopile artefactos (`/tmp/*.png`, logs) como outputs. + +Puedo crear este script si quieres que lo automatice. + +11) Próximos pasos recomendados +------------------------------- +- Implementar `GET /api/session/:id/token` en `backend-api` si quieres flujo REST claro (session created -> separate token retrieval). +- Implementar tests unitarios para el generador del token (mock LiveKit) y un E2E que valide token TTL y reconexión. +- Añadir un `e2e:all` que levante servicios y ejecute el plugin y guarde artefactos (ideal para CI). + +Soporte inmediato +------------------ +Si pegas aquí la salida JSON del plugin (`node packages/broadcast-panel/e2e/dify-plugin-playwright.mjs ...`) o el log `/tmp/dify-plugin-output.log`, lo analizo y aplico correcciones inmediatas (selectores, timeouts, version mismatch, dependencia faltante) y vuelvo a ejecutar el flujo contigo. + +--- + +Si quieres, creo ahora mismo el script `scripts/e2e-run.sh` que orquesta todo (opción recomendada). ¿Lo genero? diff --git a/e2e/LOG.md b/e2e/LOG.md new file mode 100644 index 0000000..4a4f47c --- /dev/null +++ b/e2e/LOG.md @@ -0,0 +1,143 @@ +# E2E Run Log + +This file is auto-appended by E2E scripts in `e2e/` on each run. It contains a timestamped summary of the run, status, pointers to result JSON and screenshots, and a short summary of assertions and console issues. + + +## 2025-11-20T02:23:53.043Z — validate-flow-domains-local +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/studio-flow-domains-result.json +- Screenshot: studio_flow_result.png +- No assertions or console issues recorded +## 2025-11-20T02:24:45.033Z — test-run +- Status: UNKNOWN +- Result JSON: studio-flow-domains-result.json +- Screenshot: studio_flow_result.png +- No assertions or console issues recorded +## 2025-11-20T02:26:41.096Z — validate-flow-domains-local +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/studio-flow-domains-result.json +- Screenshot: studio_flow_result.png +- No assertions or console issues recorded +## 2025-11-20T02:27:21.084Z — validate-flow-remote-chrome +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-remote-chrome-result.json +- Screenshot: validate-remote-chrome-result.png +- Assertions: + - sessionStorage_has_token: FAIL - sessionStorage present but token missing or malformed + - studio_page_shows_token: FAIL - Studio page does not show token text +- Console issues: + - [warning] Failed to decode downloaded font: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/fonts/Requiner-6RRLM.woff + - [warning] OTS parsing error: invalid sfntVersion: 1008821359 +## 2025-11-20T02:27:30.843Z — validate-flow-browserless +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-browserless-result.json +- Screenshot: studio_flow_browserless_result.png +- Console issues: + - [warning] Failed to decode downloaded font: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/fonts/Requiner-6RRLM.woff + - [warning] OTS parsing error: invalid sfntVersion: 1008821359 + - [error] Failed to load resource: the server responded with a status of 502 () + - [error] Failed to load resource: the server responded with a status of 502 () +## 2025-11-20T02:31:06.062Z — validate-flow-domains-local +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/studio-flow-domains-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-31-06-041Z-validate-flow-domains-local.tgz +- Screenshot: studio_flow_result.png +- No assertions or console issues recorded +## 2025-11-20T02:31:17.094Z — validate-flow-remote-chrome +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-remote-chrome-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-31-17-076Z-validate-flow-remote-chrome.tgz +- Screenshot: validate-remote-chrome-result.png +- Assertions: + - sessionStorage_has_token: FAIL - sessionStorage present but token missing or malformed + - studio_page_shows_token: FAIL - Studio page does not show token text +- Console issues: + - [warning] Failed to decode downloaded font: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/fonts/Requiner-6RRLM.woff + - [warning] OTS parsing error: invalid sfntVersion: 1008821359 +## 2025-11-20T02:31:27.260Z — validate-flow-browserless +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-browserless-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-31-27-163Z-validate-flow-browserless.tgz +- Screenshot: studio_flow_browserless_result.png +- Console issues: + - [warning] Failed to decode downloaded font: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/fonts/Requiner-6RRLM.woff + - [warning] OTS parsing error: invalid sfntVersion: 1008821359 + - [error] Failed to load resource: the server responded with a status of 502 () + - [error] Failed to load resource: the server responded with a status of 502 () +## 2025-11-20T02:34:49.197Z — validate-flow-domains-local +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/studio-flow-domains-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-34-49-179Z-validate-flow-domains-local.tgz +- Screenshot: studio_flow_result.png +- No assertions or console issues recorded +## 2025-11-20T02:36:36.256Z — validate-flow-domains-local +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/studio-flow-domains-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-36-36-236Z-validate-flow-domains-local.tgz +- Screenshot: studio_flow_result.png +- No assertions or console issues recorded +## 2025-11-20T02:36:48.808Z — validate-flow-remote-chrome +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-remote-chrome-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-36-48-786Z-validate-flow-remote-chrome.tgz +- Screenshot: validate-remote-chrome-result.png +- Assertions: + - sessionStorage_has_token: FAIL - sessionStorage present but token missing or malformed + - studio_page_shows_token: FAIL - Studio page does not show token text +- Console issues: + - [warning] Failed to decode downloaded font: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/fonts/Requiner-6RRLM.woff + - [warning] OTS parsing error: invalid sfntVersion: 1008821359 +## 2025-11-20T02:36:55.809Z — validate-flow-browserless +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-browserless-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-36-55-737Z-validate-flow-browserless.tgz +- Console issues: + - [warning] Failed to decode downloaded font: https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/fonts/Requiner-6RRLM.woff + - [warning] OTS parsing error: invalid sfntVersion: 1008821359 +## 2025-11-20T02:37:47.685Z — validate-flow-remote-chrome +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-remote-chrome-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-37-47-667Z-validate-flow-remote-chrome.tgz +- Console issues: + - [warning] The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page. https://developer.chrome.com/blog/autoplay/#web_audio + - [warning] The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page. https://developer.chrome.com/blog/autoplay/#web_audio + - [error] WebSocket connection to 'wss://livekit-server.bfzqqk.easypanel.host/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiZTJlX3VzZXIiLCJ2aWRlbyI6eyJyb29tIjoiZTJlX3Jvb20iLCJyb29tSm9pbiI6dHJ1ZSwiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlfSwiaXNzIjoiZGV2a2V5IiwiZXhwIjoxNzYzNjI3ODQwLCJuYmYiOjAsInN1YiI6ImUyZV91c2VyIn0.73V4s3xFwfSaASDumZSX8vOcN0V5-5MNYUbxhBH905U&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16' failed: HTTP Authentication failed; no valid credentials available + - [warning] websocket closed JSHandle@object + - [error] WebSocket connection to 'wss://livekit-server.bfzqqk.easypanel.host/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiZTJlX3VzZXIiLCJ2aWRlbyI6eyJyb29tIjoiZTJlX3Jvb20iLCJyb29tSm9pbiI6dHJ1ZSwiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlfSwiaXNzIjoiZGV2a2V5IiwiZXhwIjoxNzYzNjI3ODQwLCJuYmYiOjAsInN1YiI6ImUyZV91c2VyIn0.73V4s3xFwfSaASDumZSX8vOcN0V5-5MNYUbxhBH905U&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16&adaptive_stream=1' failed: HTTP Authentication failed; no valid credentials available + - [warning] websocket closed JSHandle@object + - [error] Failed to load resource: the server responded with a status of 401 () + - [error] StudioPortal: failed to connect local room JSHandle@error + - [error] Failed to load resource: the server responded with a status of 401 () + - [error] StudioRoom connect failed JSHandle@error +## 2025-11-20T02:37:54.609Z — validate-flow-browserless +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-browserless-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T02-37-54-581Z-validate-flow-browserless.tgz +- Console issues: + - [warning] The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page. https://developer.chrome.com/blog/autoplay/#web_audio + - [warning] Item with key lk-user-choices does not exist in local storage. + - [warning] The AudioContext was not allowed to start. It must be resumed (or created) after a user gesture on the page. https://developer.chrome.com/blog/autoplay/#web_audio + - [error] WebSocket connection to 'wss://livekit-server.bfzqqk.easypanel.host/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiZTJlX3VzZXIiLCJ2aWRlbyI6eyJyb29tIjoiZTJlX3Jvb20iLCJyb29tSm9pbiI6dHJ1ZSwiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlfSwiaXNzIjoiZGV2a2V5IiwiZXhwIjoxNzYzNjI3ODQwLCJuYmYiOjAsInN1YiI6ImUyZV91c2VyIn0.73V4s3xFwfSaASDumZSX8vOcN0V5-5MNYUbxhBH905U&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16' failed: HTTP Authentication failed; no valid credentials available + - [warning] websocket closed JSHandle@object + - [error] Failed to load resource: the server responded with a status of 401 () + - [error] StudioPortal: failed to connect local room JSHandle@error + - [error] WebSocket connection to 'wss://livekit-server.bfzqqk.easypanel.host/rtc?access_token=eyJhbGciOiJIUzI1NiJ9.eyJuYW1lIjoiZTJlX3VzZXIiLCJ2aWRlbyI6eyJyb29tIjoiZTJlX3Jvb20iLCJyb29tSm9pbiI6dHJ1ZSwiY2FuUHVibGlzaCI6dHJ1ZSwiY2FuU3Vic2NyaWJlIjp0cnVlfSwiaXNzIjoiZGV2a2V5IiwiZXhwIjoxNzYzNjI3ODQwLCJuYmYiOjAsInN1YiI6ImUyZV91c2VyIn0.73V4s3xFwfSaASDumZSX8vOcN0V5-5MNYUbxhBH905U&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16&adaptive_stream=1' failed: HTTP Authentication failed; no valid credentials available + - [warning] websocket closed JSHandle@object + - [error] Failed to load resource: the server responded with a status of 401 () + - [error] StudioRoom connect failed JSHandle@error +## 2025-11-20T03:28:32.498Z — validate-flow-remote-chrome +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-remote-chrome-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T03-28-32-479Z-validate-flow-remote-chrome.tgz +- Console issues: + - [error] Failed to load resource: the server responded with a status of 404 () + - [error] Failed to load resource: net::ERR_CONNECTION_REFUSED + - [error] [SessionLoader] absolute token-server fetch failed JSHandle@error +## 2025-11-20T03:28:55.730Z — validate-flow-browserless +- Status: UNKNOWN +- Result JSON: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/validate-flow-browserless-result.json +- Artifact: /home/xesar/Documentos/Nextream/AvanzaCast/e2e/artifacts/2025-11-20T03-28-55-711Z-validate-flow-browserless.tgz +- Console issues: + - [error] Failed to load resource: the server responded with a status of 404 () + - [error] Failed to load resource: net::ERR_CONNECTION_REFUSED + - [error] [SessionLoader] absolute token-server fetch failed JSHandle@error diff --git a/e2e/README-validate-flow.md b/e2e/README-validate-flow.md new file mode 100644 index 0000000..ed00d8b --- /dev/null +++ b/e2e/README-validate-flow.md @@ -0,0 +1,56 @@ +# Validate Studio Flow (E2E) + +Este helper ejecuta un script Puppeteer para validar el flujo desde Broadcast Panel -> Studio Portal usando un token pre-generado y LiveKit. + +Archivos +- `e2e/validate-flow-domains-local.js` - script que navega al broadcast panel y trata de abrir el portal del estudio con el token proporcionado. +- `e2e/test-pages/broadcast.html` - página local de prueba con un enlace "Entrar al estudio" usada para validación sin red. +- `e2e/studio-flow-domains-result.json` - resultado JSON generado por el script. +- `e2e/studio_flow_result.png` - captura de pantalla generada durante la validación. + +Cómo ejecutar localmente + +1. Instala dependencias (desde la raíz del repo o dentro de `e2e`): + +```bash +cd /home/xesar/Documentos/Nextream/AvanzaCast/e2e +npm install --no-audit --no-fund +``` + +2. Ejecutar la prueba contra una instancia real (requiere token y URLs): + +```bash +cd /home/xesar/Documentos/Nextream/AvanzaCast/e2e +TOKEN="e2e098863b912f6a178b68e71ec3c58d" \ +VITE_LIVEKIT_WS_URL="wss://livekit-server.example" \ +VITE_TOKEN_SERVER_URL="https://token-server.example" \ +VITE_BROADCASTPANEL_URL="https://broadcastpanel.example" \ +VITE_STUDIO_URL="https://studio.example" \ +node validate-flow-domains-local.js +``` + +3. Ejecutar la prueba local (sin red) usando la página de prueba incluida: + +```bash +cd /home/xesar/Documentos/Nextream/AvanzaCast/e2e +BROADCAST_URL="file:///home/xesar/Documentos/Nextream/AvanzaCast/e2e/test-pages/broadcast.html" \ +TOKEN="testtoken123" \ +STUDIO_URL="about:blank" \ +node validate-flow-domains-local.js +``` + +4. Artefactos resultantes + +- `e2e/studio-flow-domains-result.json` (JSON con logs y navegaciones) +- `e2e/studio_flow_result.png` (captura de pantalla) + +Cómo usar en CI / GitHub Actions +- El repo incluye un workflow `.github/workflows/validate-studio-flow.yml` que puede dispararse manualmente (workflow_dispatch). Pasa los secretos/token como variables al workflow. + +Qué compartir al equipo +- Copiar y pegar `e2e/studio-flow-domains-result.json` +- Subir `e2e/studio_flow_result.png` + +Notas +- El script intenta hacer click en elementos con texto similar a "Entrar al estudio" y abre la URL del estudio con el token como query param. +- Si el click no abre el estudio, el script hace un fallback a navegar directamente a `STUDIO_URL` con el token. diff --git a/e2e/README.md b/e2e/README.md new file mode 100644 index 0000000..b75c02c --- /dev/null +++ b/e2e/README.md @@ -0,0 +1,187 @@ +# E2E — Validación del flujo Broadcast → Studio + +Este README recoge cómo ejecutar, depurar e interpretar las pruebas E2E del flujo Broadcast Panel → StudioPortal (activación de sesión LiveKit). Está pensado para desarrolladores e infra-ops que deben validar el flujo tanto localmente como contra entornos remotos (Browserless / Chrome remoto). + +Resumen rápido +- Objetivo: validar que al crear una sesión (POST /api/session) el Broadcast Panel abra el Studio y que el Studio reciba el token (vía postMessage o `?token=`) y lo persista en `sessionStorage` para que el cliente LiveKit se conecte. +- Ubicación de los scripts: `e2e/` +- Artefactos de salida: JSON y capturas PNG en `e2e/` + +Prerequisitos +- Node.js (v16+ recomendado) +- npm +- Chrome local o un Chrome remoto con remote-debugging habilitado (puerto 9222) para ejecución con `validate-flow-remote-chrome.js` o un servidor Browserless accesible (WSS) para `validate-flow-browserless.js`. + +Archivos principales en `e2e/` +- `validate-flow-domains-local.js` — validador local (usa file:// o URLs HTTP locales) +- `validate-flow-browserless.js` — validador que se conecta a Browserless (puppeteer-core) +- `validate-flow-remote-chrome.js` — validador que se conecta a Chrome remoto (debug port / ws) +- `simulate_token_query_browserless.js` — prueba que inyecta `?token=` y abre el Studio +- `mock_server.js` — servidor Express mock (dev) que emula endpoints `/api/session` y `/studio/:id` (útil para pruebas offline) +- `run_e2e_with_mock.js` — helper que arranca el mock en proceso y ejecuta el validador local +- `validate-flow-remote-chrome-result.json` (y otros JSON) — resultados de ejecución con consola, navigations y assertions + +Variables de entorno usadas (ejemplos) +- `BROWSERLESS_WS` — WebSocket endpoint a Browserless (ej. `wss://...`) +- `BROWSERLESS_TOKEN` — token del servicio Browserless +- `CHROME_HOST` / `CHROME_WS` — host:port (p.ej. `host:9222`) o websocket de Chrome remoto +- `BROADCAST_URL` — URL del Broadcast Panel (ej: `https://avanzacast-broadcastpanel.bfzqqk.easypanel.host`) +- `STUDIO_URL` — URL base del Studio (ej: `https://avanzacast-studio.bfzqqk.easypanel.host`) +- `TOKEN` — token de prueba (opcional, para forzar `?token=` fallback) + +Comandos (rápidos) — ejecuciones locales y remotas + +1) Prueba local rápida (usa la página de prueba `e2e/test-pages/broadcast.html`) +```bash +cd e2e +BROADCAST_URL="file://$(pwd)/test-pages/broadcast.html" \ +TOKEN="testtoken123" STUDIO_URL="about:blank" \ +node validate-flow-domains-local.js +``` +Salida: `e2e/studio-flow-domains-result.json` + `e2e/studio_flow_result.png` + +2) Usar el mock server (arranca servidor y valida fluxo realista) +```bash +# en una terminal +cd e2e +node mock_server.js +# en otra terminal +BROADCAST_URL="http://localhost:4001/broadcast" \ +TOKEN="local-mock-token" STUDIO_URL="http://localhost:4001/studio" \ +node validate-flow-domains-local.js +``` +Salida: JSON + capturas en `e2e/` + +3) Ejecutar contra Browserless remoto +```bash +cd /path/to/repo +BROWSERLESS_WS="wss://browserless.example" \ +BROWSERLESS_TOKEN="your_token" \ +BROADCAST_URL="https://avanzacast-broadcastpanel.bfzqqk.easypanel.host" \ +STUDIO_URL="https://avanzacast-studio.bfzqqk.easypanel.host" \ +node validate-flow-browserless.js +``` +Salida: `e2e/validate-flow-browserless-result.json` + screenshot + +4) Ejecutar contra Chrome remoto (remote debugging, puerto 9222) +```bash +# obtener websocket URL (ejemplo local) y ejecutar +WS=$(curl -s http://localhost:9222/json/version | python3 -c "import sys,json;print(json.load(sys.stdin).get('webSocketDebuggerUrl',''))") +cd e2e +CHROME_WS="$WS" \ +BROADCAST_URL="https://avanzacast-broadcastpanel.bfzqqk.easypanel.host" \ +STUDIO_URL="https://avanzacast-studio.bfzqqk.easypanel.host" \ +TOKEN="e2e098863b912f6a178b68e71ec3c58d" \ +node validate-flow-remote-chrome.js +``` +Salida: `e2e/validate-flow-remote-chrome-result.json` + screenshot + +### Validar flujo `/:id` (session id stored in DB -> broadcast panel) + +Hemos añadido un test E2E específico que valida el flujo en el que el token-server crea una sesión y el Broadcast Panel la consume a través de la ruta `/:id`. + +Script: `e2e/validate-session-id-flow.js` +Qué hace: +- POST `/api/session` en `VITE_BACKEND_TOKENS_URL` (o `https://avanzacast-servertokens.bfzqqk.easypanel.host` por defecto) para crear una sesión. +- Abre `BROADCAST_URL/:id` (usando `BROWSER_WS` / `BROWSERLESS_WS`) y verifica que `sessionStorage` contiene la sesión. +- Llama a `/api/session/:id/token` y guarda el resultado en JSON + captura. + +Ejecución (Browserless): +```bash +cd e2e +BROWSERLESS_WS="wss://browserless.bfzqqk.easypanel.host" \ +BROWSERLESS_TOKEN="" \ +node validate-session-id-flow.js +``` + +Ejecución (Chrome remoto): +```bash +WS=$(curl -s http://localhost:9222/json/version | python3 -c "import sys,json;print(json.load(sys.stdin).get('webSocketDebuggerUrl',''))") +cd e2e +BROWSER_WS="$WS" node validate-session-id-flow.js +``` + +Salida: +- `e2e/validate-session-id-flow-result.json` +- `e2e/validate-session-id-flow.png` + +Diagnóstico y debugging (token-server y 502) +- Si ves errores `Failed to load resource: 502`, verifica el token-server y el reverse-proxy: +```bash +curl -i 'https://avanzacast-servertokens.bfzqqk.easypanel.host/health' +curl -i -X POST 'https://avanzacast-servertokens.bfzqqk.easypanel.host/api/session' -H 'Content-Type: application/json' -d '{"room":"diag","username":"diag"}' +# script de diagnóstico (recopila headers y logs) +node ../scripts/check_token_server_node.mjs https://avanzacast-servertokens.bfzqqk.easypanel.host testroom e2euser +``` +- Si el POST/GET token responde 200 con JSON y token -> backend OK; si no, revisa logs del proxy (nginx/EasyPanel) y del contenedor del token-server. + +Infra: Alinear claves LiveKit (token-server ↔ LiveKit) + +Si las pruebas E2E fallan con errores 401 al conectar a LiveKit, normalmente significa que el token-server está firmando tokens con una clave (API key / secret) distinta a la que LiveKit espera. He incluido dos helpers en `scripts/` para actualizar y verificar la configuración del token-server: + +- `scripts/update_token_server_env.sh` — actualiza `LIVEKIT_API_KEY` y `LIVEKIT_API_SECRET` en el archivo `.env` del token-server y recrea el servicio (docker-compose). Uso seguro: hace backup del archivo `.env` antes de modificarlo. +- `scripts/verify_token_server_after_update.sh` — crea una sesión de prueba en el token-server, obtiene el token y (si tienes `websocat` instalado) intenta una conexión WebSocket básica a la URL reportada por el token-server (esto es un sanity-check, no un handshake RTC completo). + +Comandos de ejemplo + +1) Actualiza las claves en el `.env` del token-server y recrea el servicio (ajusta paths y nombre del servicio según tu despliegue): +```bash +./scripts/update_token_server_env.sh \ + --env-file /path/to/token-server/.env \ + --compose-file /path/to/docker-compose.yml \ + --service token-server \ + --livekit-key \ + --livekit-secret +``` + +2) Verifica que el token-server crea sesiones y devuelve token: +```bash +./scripts/verify_token_server_after_update.sh \ + --session-room e2e_room \ + --session-user e2e_user \ + --token-server-url https://avanzacast-servertokens.bfzqqk.easypanel.host +``` + +Notas de seguridad +- No guardes secretos en repositorios públicos. +- El script hace backup del `.env` antes de editarlo. + +Si quieres, puedo aplicar (probar) estas acciones en tu entorno si me indicas: +- el path al `.env` y al `docker-compose.yml` que controla `token-server`, o +- si prefieres que prepare los comandos para que el administrador los ejecute por SSH (yo te doy el playbook exacto). + +Estructura del JSON de resultados +- `startedAt` / `endedAt` — timestamps +- `console` — array de mensajes capturados de consola (type, text) +- `navigations` — array con pasos realizados (ej: `broadcast_loaded`, `studio_opened`, `direct_studio`) +- `assertions` — (cuando se configuraron) array con { name, ok, detail } +- `screenshot` — ruta absoluta del PNG generado + +Ejemplo de interpretación rápida +- Si `assertions` muestra `sessionStorage_has_token: ok: true` y `studio_page_shows_token: ok: true` → flujo verificado (la sesión fue creada o inyectada y el Studio la recibió). +- Si hay 502 en `console` → probable problema infra (proxy / token-server) o asset roto; ejecutar scripts de diagnóstico. + +Cómo añadir nuevas aserciones +- Edita `e2e/validate-flow-remote-chrome.js` o `validate-flow-browserless.js` y añade comprobaciones en el bloque que ejecuta `studioPage.evaluate(...)` o consulta `sessionStorage` en la página broadcast. +- Patrón de aserción: +```js +results.assertions.push({ name: 'my_assertion', ok: boolean, detail: 'mensaje' }) +``` +- Re-run el script y revisar `results.assertions` en el JSON. + +Integración en CI (recomendación) +- Usa el workflow `/.github/workflows/validate-studio-flow-browserless.yml` (ya incluido). El workflow requiere los inputs `browserless_ws` y `browserless_token`. Configura secrets y ejecútalo desde Actions para runs periódicos o en PRs. + +Preguntas frecuentes / troubleshooting +- Q: ¿Por qué el test abre el Studio directamente (`direct_studio`) en lugar de simular el click? A: Si el popup está bloqueado o el selector no se encuentra, el script hace fallback y navega directamente con `?token=`. Aumenta timeouts o agrega selectores más específicos si quieres forzar la simulación del click. +- Q: Veo OTS parsing errors en consola (fuentes). ¿Importa? A: No para la E2E funcional; afecta sólo la carga de un archivo `.woff` o la validación visual. Revisa la fuente y el servidor que la sirve. + +Contacto / notas finales +- Los scripts y la lógica han sido añadidos para que puedas automatizar y auditar la activación del StudioPortal. Si quieres, puedo: + - añadir aserciones adicionales específicas (ej. TTL, usuario, room), + - crear el README en `e2e/README.md` (este archivo), + - añadir tests en CI que fallen si alguna aserción no pasa. + +--- + +Si quieres que genere además un archivo `e2e/LOG.md` o un registro más formal por cada ejecución (con timestamp, link a artifact, resultado de aserciones), lo creo ahora y lo integro automáticamente cuando corre el validador. ¿Lo añado? (responde "Sí, crea LOG.md") diff --git a/e2e/analyze_external_page_browserless.js b/e2e/analyze_external_page_browserless.js new file mode 100644 index 0000000..5152dc8 --- /dev/null +++ b/e2e/analyze_external_page_browserless.js @@ -0,0 +1,149 @@ +// e2e/analyze_external_page_browserless.js +// Connects to Browserless and analyzes an external page (network, console, storage, ws, postMessage) +// Usage: set env TARGET_URL and BROWSERLESS_WS (and optional BROWSERLESS_TOKEN) + +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-core'); + +(async () => { + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), console: [], pageErrors: [], network: [], failures: [], wsEvents: [], postMessages: [], storage: { session: {}, local: {} }, cookies: [] }; + const ws = process.env.BROWSERLESS_WS; + const btoken = process.env.BROWSERLESS_TOKEN || ''; + if (!ws) { + console.error('BROWSERLESS_WS required'); + process.exit(2); + } + let wsUrl = ws; + try { if (btoken) wsUrl = ws.includes('?') ? `${ws}&token=${encodeURIComponent(btoken)}` : `${ws}?token=${encodeURIComponent(btoken)}` } catch(e){} + + const target = process.env.TARGET_URL || process.env.BROADCAST_URL || ''; + if (!target) { console.error('TARGET_URL required'); process.exit(2); } + + let browser; + try { + browser = await puppeteer.connect({ browserWSEndpoint: wsUrl, ignoreHTTPSErrors: true, defaultViewport: { width: 1366, height: 768 } }); + } catch (err) { + console.error('Failed to connect to browserless', err && err.message ? err.message : err); + process.exit(2); + } + + const page = await browser.newPage(); + page.setDefaultNavigationTimeout(45000); + + // capture console + page.on('console', msg => { + try { results.console.push({ type: msg.type(), text: msg.text(), location: msg.location() }); } catch(e){} + }); + page.on('pageerror', err => results.pageErrors.push(String(err && err.stack ? err.stack : err))); + + // capture network + page.on('request', req => { + results.network.push({ id: req._requestId || null, url: req.url(), method: req.method(), resourceType: req.resourceType(), headers: req.headers() }); + }); + page.on('requestfailed', req => { + results.failures.push({ url: req.url(), errorText: req.failure() && req.failure().errorText ? req.failure().errorText : 'failed' }); + }); + page.on('response', async res => { + try { + const req = res.request(); + const url = res.url(); + const status = res.status(); + const ct = res.headers()['content-type'] || null; + results.network.push({ url, status, contentType: ct, method: req.method() }); + } catch(e){} + }); + + // inject instrumentation for WebSocket/postMessage before any script runs + await page.evaluateOnNewDocument(() => { + try { + // WebSocket wrapper + (function(){ + const OriginalWS = window.WebSocket; + window.__ws_events = []; + function MyWS(url, protocols){ + const ws = protocols ? new OriginalWS(url, protocols) : new OriginalWS(url); + const id = Math.random().toString(36).slice(2,9); + window.__ws_events.push({ event: 'created', id, url, protocols: protocols || null, time: Date.now() }); + ws.addEventListener('open', () => window.__ws_events.push({ event: 'open', id, time: Date.now() })); + ws.addEventListener('close', () => window.__ws_events.push({ event: 'close', id, time: Date.now() })); + ws.addEventListener('error', (e) => window.__ws_events.push({ event: 'error', id, time: Date.now(), detail: String(e && e.message ? e.message : e) })); + ws.addEventListener('message', (m) => { + let data = m.data; + try { data = typeof data === 'string' ? data : JSON.stringify(data); } catch(e){} + window.__ws_events.push({ event: 'message', id, time: Date.now(), data: data }); + }); + const origSend = ws.send.bind(ws); + ws.send = function(data){ + try { window.__ws_events.push({ event: 'send', id, time: Date.now(), data: (typeof data==='string'?data:JSON.stringify(data)) }); } catch(e){} + return origSend(data); + } + return ws; + } + try { MyWS.prototype = OriginalWS.prototype } catch(e){} + window.WebSocket = MyWS; + })(); + + // postMessage wrapper + (function(){ + window.__post_messages = []; + const origPost = window.postMessage.bind(window); + window.postMessage = function(msg, targetOrigin, transfer){ + try { window.__post_messages.push({ time: Date.now(), msg: msg, targetOrigin: targetOrigin }); } catch(e){} + return origPost(msg, targetOrigin, transfer); + } + })(); + + } catch(e){} + }); + + try { + await page.goto(target, { waitUntil: 'networkidle2' }); + } catch (err) { + // still continue to gather whatever we can + results.console.push({ type: 'error', text: 'navigation_failed: ' + String(err && err.message ? err.message : err) }); + } + + // wait a bit for scripts + await page.waitForTimeout(4000); + + // gather sessionStorage/localStorage + try { + const storeKey = (await page.evaluate(() => (window && (window.__AVZ_STUDIO_SESSION_KEY__ || null)))) || null; + const session = await page.evaluate(() => { + try { const s = {}; for (let i=0;i { try { const s={}; for(let i=0;i { try { return window.__ws_events || []; } catch(e){ return []; } }); + results.wsEvents = events; + } catch(e){} + try { + const pms = await page.evaluate(() => { try { return window.__post_messages || []; } catch(e){ return []; } }); + results.postMessages = pms; + } catch(e){} + + // screenshot + const shot = path.join(outDir, 'analyze_external_page_result.png'); + try { await page.screenshot({ path: shot, fullPage: true }); results.screenshot = shot; } catch(e) { results.screenshot = null } + + // write JSON + results.endedAt = new Date().toISOString(); + const outJson = path.join(outDir, 'analyze_external_page_result.json'); + fs.writeFileSync(outJson, JSON.stringify(results, null, 2)); + console.log('Wrote results to', outJson); + + try { await page.close(); } catch(e){} + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(0); +})(); + diff --git a/e2e/check-browserless-conn.js b/e2e/check-browserless-conn.js new file mode 100644 index 0000000..18017a6 --- /dev/null +++ b/e2e/check-browserless-conn.js @@ -0,0 +1,18 @@ +// quick connectivity check to browserless WS +const puppeteer = require('puppeteer-core'); +(async ()=>{ + const ws = process.env.BROWSERLESS_WS; + if(!ws){ console.error('BROWSERLESS_WS required'); process.exit(2); } + console.log('Trying to connect to', ws); + try{ + const b = await puppeteer.connect({ browserWSEndpoint: ws, ignoreHTTPSErrors: true, defaultViewport: { width: 800, height: 600 } }); + console.log('Connected!'); + try{ await b.version().then(v=>console.log('Browser version:', v)); }catch(e){} + await b.disconnect(); + process.exit(0); + }catch(err){ + console.error('Connect error:', err && err.message ? err.message : err); + process.exit(1); + } +})(); + diff --git a/e2e/decode_token.js b/e2e/decode_token.js new file mode 100644 index 0000000..af1e88e --- /dev/null +++ b/e2e/decode_token.js @@ -0,0 +1,29 @@ +// e2e/decode_token.js +const fs = require('fs'); +const path = require('path'); +const file = '/tmp/token_resp.txt'; +if (!fs.existsSync(file)) { console.error('token response file not found:', file); process.exit(2); } +const s = fs.readFileSync(file, 'utf8'); +const m = s.match(/"token":"([^"]+)"/); +if (!m) { console.error('token not found in file'); process.exit(2); } +const t = m[1]; +console.log('TOKEN:', t); +const parts = t.split('.'); +if (parts.length < 2) { console.error('invalid token'); process.exit(2); } +let payload = parts[1]; +payload += '='.repeat((4 - (payload.length % 4)) % 4); +try { + const buf = Buffer.from(payload.replace(/-/g,'+').replace(/_/g,'/'), 'base64'); + const json = buf.toString('utf8'); + try { + const obj = JSON.parse(json); + console.log('PAYLOAD:'); + console.log(JSON.stringify(obj, null, 2)); + } catch (e) { + console.log('decoded payload (not json):', json); + } +} catch (err) { + console.error('decode error', err); + process.exit(1); +} + diff --git a/e2e/logging.js b/e2e/logging.js new file mode 100644 index 0000000..97e1c37 --- /dev/null +++ b/e2e/logging.js @@ -0,0 +1,63 @@ +const fs = require('fs'); +const path = require('path'); + +function summarizeResults(results) { + const lines = []; + if (results.assertions && Array.isArray(results.assertions)) { + lines.push(`- Assertions:`); + for (const a of results.assertions) { + lines.push(` - ${a.name}: ${a.ok ? 'PASS' : 'FAIL'} - ${a.detail || ''}`); + } + } + if (results.console && Array.isArray(results.console) && results.console.length) { + const errors = results.console.filter(c => c.type === 'error' || c.type === 'warning'); + if (errors.length) { + lines.push(`- Console issues:`); + for (const e of errors) { + lines.push(` - [${e.type}] ${e.text}`); + } + } + } + if (results.error) lines.push(`- Error: ${results.error}`); + return lines.join('\n'); +} + +function appendLog(runName, resultJsonPath, results, artifactUrl) { + try { + const logPath = path.resolve(__dirname, 'LOG.md'); + const now = new Date().toISOString(); + const summary = summarizeResults(results); + const screenshot = results.screenshot ? path.relative(path.dirname(logPath), results.screenshot) : ''; + const status = (results.assertions && Array.isArray(results.assertions) && results.assertions.every(a => a.ok)) ? 'PASS' : (results.error ? 'ERROR' : 'UNKNOWN'); + const entry = [ + `## ${now} — ${runName}`, + `- Status: ${status}`, + `- Result JSON: ${resultJsonPath}`, + artifactUrl ? `- Artifact: ${artifactUrl}` : '', + screenshot ? `- Screenshot: ${screenshot}` : '', + summary ? summary : '- No assertions or console issues recorded', + '', + ].filter(Boolean).join('\n'); + + fs.appendFileSync(logPath, entry + '\n'); + return true; + } catch (err) { + try { console.warn('Failed to append log', err); } catch(e){} + return false; + } +} + +// publishArtifact: runs publish_artifact.js and returns the printed path/URL or null +function publishArtifact(resultJsonPath, label) { + try { + const pub = require('child_process').execFileSync; + const script = path.resolve(__dirname, 'publish_artifact.js'); + const out = pub(process.execPath, [script, resultJsonPath, label], { encoding: 'utf8' }); + return out.trim(); + } catch (err) { + console.warn('publishArtifact failed', err && err.message ? err.message : err); + return null; + } +} + +module.exports = { appendLog, publishArtifact }; diff --git a/e2e/mock_server.js b/e2e/mock_server.js new file mode 100644 index 0000000..20fd40b --- /dev/null +++ b/e2e/mock_server.js @@ -0,0 +1,109 @@ +// e2e/mock_server.js +// Simple Express mock server to emulate token server + broadcast & studio pages for E2E testing +const express = require('express'); +const bodyParser = require('body-parser'); +const path = require('path'); + +const app = express(); +app.use(bodyParser.json()); +const port = process.env.MOCK_PORT ? Number(process.env.MOCK_PORT) : 4001; + +const sessions = new Map(); + +function generateId() { return 's' + Math.random().toString(36).slice(2,9); } + +app.post('/api/session', (req, res) => { + const body = req.body || {}; + const id = generateId(); + const token = 'mocktoken-' + Math.random().toString(36).slice(2,12); + sessions.set(id, { token, room: body.room || 'room', username: body.username || 'user' }); + const studioUrl = `http://localhost:${port}/studio/${id}`; + res.json({ id, studioUrl, redirectUrl: studioUrl, ttlSeconds: 300 }); +}); + +app.get('/api/session/:id/token', (req, res) => { + const id = req.params.id; + const s = sessions.get(id); + if (!s) return res.status(404).json({ error: 'not_found' }); + res.json({ token: s.token, ttlSeconds: 300, room: s.room, username: s.username, url: `ws://localhost:7880` }); +}); + +// Broadcast page +app.get('/broadcast', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.send(` + + Mock Broadcast + +

Broadcast Panel - Mock

+ + + +`); +}); + +// Studio page +app.get('/studio/:id', (req, res) => { + const id = req.params.id; + res.setHeader('Content-Type', 'text/html'); + res.send(` + + Mock Studio ${id} + +

Studio Portal - Mock

+
loading...
+ + +`); +}); + +app.get('/', (req, res) => res.redirect('/broadcast')); + +app.listen(port, () => console.log('Mock server listening on', port)); + diff --git a/e2e/playwright/token-flow.js b/e2e/playwright/token-flow.js new file mode 100644 index 0000000..821e8f9 --- /dev/null +++ b/e2e/playwright/token-flow.js @@ -0,0 +1,76 @@ +// e2e/playwright/token-flow.js +// Simple E2E script using Playwright to validate token -> broadcast-panel -> studio flow. +// Usage: WS_ENDPOINT=ws://192.168.1.20:3003 BACKEND=http://127.0.0.1:4000 BROADCAST_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host node e2e/playwright/token-flow.js + +const { chromium } = require('playwright'); +const fetch = require('node-fetch'); + +(async () => { + try { + const WS = process.env.WS_ENDPOINT || process.env.BROWSER_WS || 'ws://192.168.1.20:3003'; + const BACKEND = process.env.BACKEND || 'http://127.0.0.1:4000'; + const BROADCAST = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host'; + + console.log('Using ws endpoint:', WS); + console.log('Backend (token server):', BACKEND); + console.log('Broadcast panel url:', BROADCAST); + + // 1) Request session/token from backend + const sessionResp = await fetch(`${BACKEND}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: 'e2e-room', username: 'e2e-cli' }) + }); + + if (!sessionResp.ok) { + const text = await sessionResp.text(); + throw new Error(`Backend /api/session failed: ${sessionResp.status} ${text}`); + } + + const sessionJson = await sessionResp.json(); + console.log('Session response:', sessionJson); + + const redirectUrl = sessionJson.redirectUrl || sessionJson.studioUrl || `${BROADCAST}/?session=${sessionJson.id}`; + console.log('Will navigate to:', redirectUrl); + + // 2) Connect to remote browser + const browser = await chromium.connect({ wsEndpoint: WS, timeout: 60000 }); + const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); + const page = await context.newPage(); + + // capture console messages + page.on('console', msg => console.log('PAGE LOG:', msg.type(), msg.text())); + + // Navigate to redirect url + await page.goto(redirectUrl, { waitUntil: 'networkidle' }); + console.log('Navigated. Waiting for StudioPortal or livekit logs...'); + + // Wait for StudioPortal element or a known selector + const SELECTOR = '[data-testid="studio-portal"]'; + try { + await page.waitForSelector(SELECTOR, { timeout: 10000 }); + console.log('StudioPortal element found:', SELECTOR); + } catch (e) { + console.log('StudioPortal selector not found; will look for LiveKit logs in console.'); + } + + // Look for text in page that indicates livekit attempted connection + const livekitText = await page.evaluate(() => { + return document.body.innerText.slice(0, 2000); + }); + console.log('Page snippet:', livekitText.substring(0, 800)); + + // Wait some seconds for any WS attempts (validate calls) to appear in network logs + await page.waitForTimeout(5000); + + console.log('E2E script finished — closing browser.'); + await context.close(); + await browser.close(); + + process.exit(0); + } catch (err) { + console.error('E2E script error:', err); + process.exit(2); + } +})(); + diff --git a/e2e/print-log-summary.sh b/e2e/print-log-summary.sh new file mode 100755 index 0000000..aded87a --- /dev/null +++ b/e2e/print-log-summary.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# e2e/print-log-summary.sh +# Usage: ./print-log-summary.sh [N] +# Prints the last N entries from e2e/LOG.md (default 5 entries) + +set -euo pipefail +N=${1:-5} +LOG_FILE="$(dirname "$0")/LOG.md" +if [ ! -f "$LOG_FILE" ]; then + echo "LOG.md not found: $LOG_FILE" >&2 + exit 1 +fi + +# Collect blocks that start with '## ' +awk -v RS="^## " 'NR>1{print "## " $0}' "$LOG_FILE" | awk 'BEGIN{count=0} {blocks[count++]=$0} END{start=(count>='"$N"')?(count-"$N"):"0"; for(i=start;i-.tgz +// If AWS_S3_BUCKET is set and `aws` CLI is available, uploads it and prints the S3 URL. + +const fs = require('fs'); +const path = require('path'); +const { execSync } = require('child_process'); + +function usage() { + console.error('Usage: node publish_artifact.js [label]'); + process.exit(2); +} + +const resultJsonPath = process.argv[2]; +const label = process.argv[3] || 'run'; +if (!resultJsonPath || !fs.existsSync(resultJsonPath)) usage(); + +const outDir = path.resolve(__dirname, 'artifacts'); +if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + +try { + const results = JSON.parse(fs.readFileSync(resultJsonPath, 'utf8')); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + const name = `${ts}-${label}`; + const tarName = `${name}.tgz`; + const tarPath = path.join(outDir, tarName); + + // collect files: the JSON itself and any screenshot referenced + const files = [resultJsonPath]; + if (results && results.screenshot && fs.existsSync(results.screenshot)) files.push(results.screenshot); + + // create tar.gz with relative paths + const cwd = process.cwd(); + const relFiles = files.map(f => path.relative(cwd, f)); + const cmd = `tar -czf ${tarPath} ${relFiles.map(f => `'${f}'`).join(' ')}`; + execSync(cmd, { stdio: 'inherit' }); + + // If AWS_S3_BUCKET is present and aws CLI available, upload + const bucket = process.env.AWS_S3_BUCKET || process.env.AWS_BUCKET; + if (bucket) { + try { + // check aws CLI + execSync('aws --version', { stdio: 'ignore' }); + const s3Key = `e2e-artifacts/${tarName}`; + const uploadCmd = `aws s3 cp ${tarPath} s3://${bucket}/${s3Key} --acl public-read`; + execSync(uploadCmd, { stdio: 'inherit' }); + // derive public URL (best-effort) + const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || ''; + let url = ''; + if (region) url = `https://${bucket}.s3.${region}.amazonaws.com/${s3Key}`; + else url = `https://${bucket}.s3.amazonaws.com/${s3Key}`; + console.log(url); + process.exit(0); + } catch (err) { + // upload failed, fall back to local path + console.warn('AWS upload failed or aws CLI missing, returning local artifact path'); + console.log(tarPath); + process.exit(0); + } + } + + // no upload requested, print local path + console.log(tarPath); + process.exit(0); +} catch (err) { + console.error('publish_artifact failed:', err && err.stack ? err.stack : err); + process.exit(1); +} + diff --git a/e2e/puppeteer-runner/.env.example b/e2e/puppeteer-runner/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/e2e/puppeteer-runner/README.md b/e2e/puppeteer-runner/README.md new file mode 100644 index 0000000..e69de29 diff --git a/e2e/puppeteer-runner/debug-chrome.js b/e2e/puppeteer-runner/debug-chrome.js new file mode 100644 index 0000000..d28ee55 --- /dev/null +++ b/e2e/puppeteer-runner/debug-chrome.js @@ -0,0 +1,22 @@ +(async ()=>{ + // dynamic import to support ESM-only puppeteer builds + const mod = await import('puppeteer') + const puppeteer = (mod && mod.default) ? mod.default : mod + const chromePath = process.env.CHROME_PATH || '/usr/bin/google-chrome' + const url = process.env.VITE_BROADCASTPANEL_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + console.log('Using chromePath=', chromePath) + try { + const browser = await puppeteer.launch({ executablePath: chromePath, args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--headless=new'], defaultViewport: { width: 1280, height: 800 }, timeout: 20000 }) + const version = await browser.version() + console.log('Browser version:', version) + const page = await browser.newPage() + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }) + console.log('Page title:', await page.title()) + await page.screenshot({ path: 'debug-chrome-screenshot.png', fullPage: true }) + console.log('Screenshot saved: debug-chrome-screenshot.png') + await browser.close() + } catch (err) { + console.error('DEBUG-CHROME ERROR:', err && err.stack ? err.stack : err) + process.exit(2) + } +})() diff --git a/e2e/puppeteer-runner/debug-elements-screenshot.js b/e2e/puppeteer-runner/debug-elements-screenshot.js new file mode 100644 index 0000000..a1efd4d --- /dev/null +++ b/e2e/puppeteer-runner/debug-elements-screenshot.js @@ -0,0 +1,22 @@ +(async ()=>{ + const mod = await import('puppeteer') + const puppeteer = (mod && mod.default) ? mod.default : mod + const chromePath = process.env.CHROME_PATH || '/usr/bin/chromium' + const url = process.env.VITE_BROADCASTPANEL_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + console.log('launching', chromePath) + const browser = await puppeteer.launch({ executablePath: chromePath, args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage'], defaultViewport: { width: 1400, height: 900 } }) + const page = await browser.newPage() + try { + await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 }) + } catch(e) { console.warn('goto error', e && e.message ? e.message : e) } + await page.waitForTimeout(3000) + await page.screenshot({ path: 'elements-screenshot.png', fullPage: true }) + const buttons = await page.evaluate(()=>{ + const nodes = Array.from(document.querySelectorAll('button, a, [role="button"], [aria-label]')) + return nodes.slice(0,200).map(n=>({ text: (n.textContent||n.innerText||'').trim().slice(0,100), role: n.getAttribute && n.getAttribute('role'), aria: n.getAttribute && n.getAttribute('aria-label'), classes: (n.className||'').toString().slice(0,200) })) + }) + console.log('buttons:', buttons.length) + for (let i=0;i{ console.error(e && e.stack?e.stack:e); process.exit(2) }) + diff --git a/e2e/puppeteer-runner/debug-list-elements-local.js b/e2e/puppeteer-runner/debug-list-elements-local.js new file mode 100644 index 0000000..47292b1 --- /dev/null +++ b/e2e/puppeteer-runner/debug-list-elements-local.js @@ -0,0 +1,28 @@ +(async ()=>{ + const mod = await import('puppeteer') + const puppeteer = (mod && mod.default) ? mod.default : mod + const chromePath = process.env.CHROME_PATH || '/usr/bin/chromium' + const url = process.env.VITE_BROADCASTPANEL_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + console.log('Launching chrome at', chromePath, 'and opening', url) + const browser = await puppeteer.launch({ executablePath: chromePath, args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage','--headless=new'], defaultViewport: { width: 1400, height: 900 } }) + const page = await browser.newPage() + await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }) + await page.waitForTimeout(1500) + const results = await page.evaluate(() => { + function norm(s){ return (s||'').replace(/\s+/g,' ').trim() } + const nodes = Array.from(document.querySelectorAll('button, a, [role="button"], [aria-label], [data-testid]')) + return nodes.slice(0,300).map(n=>({ + tag: n.tagName, + text: norm(n.textContent||n.innerText||''), + aria: n.getAttribute && (n.getAttribute('aria-label') || n.getAttribute('aria-labelledby')), + testid: n.getAttribute && n.getAttribute('data-testid'), + classes: (n.className||'').toString().slice(0,200), + role: n.getAttribute && n.getAttribute('role') || null, + visible: (()=>{ const r = n.getBoundingClientRect(); return !!(r.width && r.height); })() + })) + }) + console.log('Found', results.length, 'interactive nodes') + for (let i=0;i{ console.error(e && e.stack?e.stack:e); process.exit(2) }) + diff --git a/e2e/puppeteer-runner/debug-list-elements.js b/e2e/puppeteer-runner/debug-list-elements.js new file mode 100644 index 0000000..d54bb28 --- /dev/null +++ b/e2e/puppeteer-runner/debug-list-elements.js @@ -0,0 +1,27 @@ +const puppeteer = require('puppeteer') +const dotenv = require('dotenv') +dotenv.config() + +async function run(ws, url) { + console.log('connecting to', ws) + const browser = await puppeteer.connect({ browserWSEndpoint: ws, defaultViewport: { width: 1400, height: 900 } }) + const page = await browser.newPage() + page.on('console', msg => console.log('PAGE LOG:', msg.text())) + await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 }) + await page.waitForTimeout(1500) + + const results = await page.evaluate(() => { + function normalize(s){ return (s||'').replace(/\s+/g,' ').trim() } + const nodes = Array.from(document.querySelectorAll('button,a,div[role="button"],input[type="button"]')) + return nodes.slice(0,100).map(n => ({ tag: n.tagName, text: normalize(n.textContent||n.innerText||''), classes: n.className, role: n.getAttribute('role') || null, visible: (()=>{ const r=n.getBoundingClientRect(); return !!(r.width && r.height) })() })) + }) + console.log('Found elements count:', results.length) + for (const r of results) console.log(JSON.stringify(r)) + await browser.close() +} + +const WS = process.env.BROWSERLESS_WS || process.argv[2] +const URL = process.env.VITE_BROADCASTPANEL_URL || process.argv[3] || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' +if (!WS) { console.error('Need ws endpoint'); process.exit(2) } +run(WS, URL).catch(e => { console.error(e && e.stack ? e.stack : e); process.exit(3) }) + diff --git a/e2e/puppeteer-runner/debug-post-token.js b/e2e/puppeteer-runner/debug-post-token.js new file mode 100644 index 0000000..9c49791 --- /dev/null +++ b/e2e/puppeteer-runner/debug-post-token.js @@ -0,0 +1,172 @@ +// Debug helper: post a JWT token to BroadcastPanel and wait for StudioPortal to react +// Usage: E2E_TOKEN=... node debug-post-token.js + +const puppeteer = require('puppeteer') +const fs = require('fs') +const path = require('path') +require('dotenv').config() + +const BROWSERLESS_WS = process.env.BROWSERLESS_WS || process.env.BROWSERLESS || '' +const CHROME_PATH = process.env.CHROME_PATH || '/usr/bin/chromium' +const BROADCAST_URL = process.env.VITE_BROADCASTPANEL_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' +let TOKEN = process.env.E2E_TOKEN || process.env.TOKEN || '' +const OUT = process.cwd() +const TOKEN_SERVER = (process.env.VITE_TOKEN_SERVER_URL || process.env.TOKEN_SERVER_URL || '').replace(/\/$/, '') + +async function fetchTokenFromServer(room = 'e2e-room', username = 'cli-run') { + if (!TOKEN_SERVER) return null + try { + console.info('Requesting token from token server:', TOKEN_SERVER) + const res = await fetch(`${TOKEN_SERVER}/api/session`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room, username }) + }) + const text = await res.text() + let json = null + try { json = JSON.parse(text) } catch (e) { console.warn('Token server returned non-json:', text) } + if (!res.ok) { + console.warn('Token server returned', res.status, text) + return null + } + if (json) { + if (json.token) return json.token + if (json.redirectUrl) { + try { + const u = new URL(json.redirectUrl) + const t = u.searchParams.get('token') + if (t) return t + } catch (e) {} + } + if (json.id) { + // try GET /api/session/:id + try { + const r2 = await fetch(`${TOKEN_SERVER}/api/session/${encodeURIComponent(json.id)}`) + if (r2.ok) { + const j2 = await r2.json() + if (j2.token) return j2.token + } + } catch (e) {} + } + } + return null + } catch (e) { + console.warn('Failed to fetch token from server', e && e.message ? e.message : e) + return null + } +} + +async function getBrowser() { + if (BROWSERLESS_WS) { + try { + console.info('Connecting to browserless:', BROWSERLESS_WS) + return await puppeteer.connect({ browserWSEndpoint: BROWSERLESS_WS, defaultViewport: { width: 1280, height: 800 } }) + } catch (e) { + console.warn('browserless connect failed', e && e.message) + } + } + console.info('Launching local Chromium at', CHROME_PATH) + return await puppeteer.launch({ executablePath: CHROME_PATH, headless: 'new', args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage'], defaultViewport: { width: 1280, height: 800 } }) +} + +async function run() { + // if no token, try to fetch one from token server automatically + if (!TOKEN) { + console.log('No E2E token provided, attempting to fetch from token server...') + const fetched = await fetchTokenFromServer() + if (fetched) { + TOKEN = fetched + console.log('Fetched token length=', String(TOKEN).length) + } else { + console.error('No token provided and token server fetch failed. Set E2E_TOKEN env var or configure VITE_TOKEN_SERVER_URL') + process.exit(2) + } + } + + if (!TOKEN) { + console.error('No token provided. Set E2E_TOKEN env var.') + process.exit(2) + } + const browser = await getBrowser() + const page = await browser.newPage() + try { + page.on('console', msg => console.log('[page]', msg.type(), msg.text())) + page.on('pageerror', err => console.error('[pageerr]', err && err.stack ? err.stack : err)) + + console.log('Navigating to', BROADCAST_URL) + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle2', timeout: 60000 }) + await page.waitForTimeout(1000) + + // clear local state that may contain old tokens + await page.evaluate(() => { + try { localStorage.removeItem('lk-user-choices') } catch(e) {} + try { sessionStorage.clear() } catch(e) {} + }) + + const shotBefore = path.join(OUT, 'debug-before.png') + await page.screenshot({ path: shotBefore, fullPage: true }) + console.log('Saved screenshot', shotBefore) + + const payload = { type: 'LIVEKIT_TOKEN', token: TOKEN, url: process.env.VITE_LIVEKIT_WS_URL || '', room: 'e2e-room' } + console.log('Posting token to page... token length=', String(TOKEN).length) + + // Attempt to post into iframe if present, else to window + await page.evaluate((payload) => { + try { + const iframe = document.querySelector('iframe#studio-portal') + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage(payload, '*') + window.__e2e_posted = 'iframe' + } else { + window.postMessage(payload, '*') + window.__e2e_posted = 'self' + } + } catch (e) { + try { window.postMessage(payload, '*'); window.__e2e_posted = 'self' } catch(_) { window.__e2e_posted = 'failed' } + } + }, payload) + + // Wait for StudioPortal to show token received or show error/connected + const start = Date.now() + const timeout = 25000 + let state = { status: 'unknown' } + while (Date.now() - start < timeout) { + const r = await page.evaluate(() => { + const out = { tokenFlag: !!(window as any).__e2e_posted, tokenTarget: (window as any).__e2e_posted || null } + try { + const tokenNotice = Array.from(document.querySelectorAll('div, span')) + .map(n => (n.textContent||'').toLowerCase()) + .find(t => t.includes('token recibido') || t.includes('esperando token') || t.includes('token')) + if (tokenNotice) out.tokenNotice = tokenNotice + } catch(e) {} + // detect studio connected element (common selectors) + const connected = !!document.querySelector('.studio-status[data-status="connected"]') + if (connected) out.connected = true + // detect error modal + const err = document.querySelector('.studio-error-modal') + if (err) out.error = (err.textContent||'').trim() + return out + }) + if (r.connected) { state = { status: 'connected', detail: r }; break } + if (r.error) { state = { status: 'error', detail: r }; break } + if (r.tokenFlag) state = { status: 'posted', detail: r } + await page.waitForTimeout(600) + } + + const shotAfter = path.join(OUT, 'debug-after.png') + await page.screenshot({ path: shotAfter, fullPage: true }) + console.log('Saved screenshot', shotAfter) + + const outPath = path.join(OUT, 'debug-post-result.json') + await fs.promises.writeFile(outPath, JSON.stringify({ state, timestamp: Date.now() }, null, 2), 'utf8') + console.log('Wrote result to', outPath) + console.log('STATE:', JSON.stringify(state, null, 2)) + + await browser.close() + return state + } catch (err) { + console.error('Runner error', err && err.stack ? err.stack : err) + try { await browser.close() } catch(e) {} + process.exit(3) + } +} + +run().catch(e=>{ console.error(e); process.exit(5) }) diff --git a/e2e/puppeteer-runner/debug-run.js b/e2e/puppeteer-runner/debug-run.js new file mode 100644 index 0000000..e0528b8 --- /dev/null +++ b/e2e/puppeteer-runner/debug-run.js @@ -0,0 +1,29 @@ +// diagnostic debug runner +const dotenv = require('dotenv') +const puppeteer = require('puppeteer-core') +dotenv.config() + +const ws = process.env.BROWSERLESS_WS || process.env.BROWSERLESS || '' +const url = process.env.VITE_BROADCASTPANEL_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + +console.log('Debug run. BROWSERLESS_WS=', ws) + +;(async ()=>{ + try { + if (!ws) throw new Error('BROWSERLESS_WS not provided') + console.log('Connecting to browserless...') + const browser = await puppeteer.connect({ browserWSEndpoint: ws, defaultViewport: { width: 1200, height: 900 }, timeout: 15000 }) + console.log('Connected. Opening page...') + const page = await browser.newPage() + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 }) + console.log('Page title:', await page.title()) + const html = await page.content() + console.log('Page length:', html.length) + await browser.close() + console.log('Done') + } catch (err) { + console.error('DEBUG ERROR:', err && err.stack ? err.stack : err) + process.exit(2) + } +})() + diff --git a/e2e/puppeteer-runner/index.ts b/e2e/puppeteer-runner/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/e2e/puppeteer-runner/package.json b/e2e/puppeteer-runner/package.json new file mode 100644 index 0000000..dd88501 --- /dev/null +++ b/e2e/puppeteer-runner/package.json @@ -0,0 +1,23 @@ +{ + "name": "avanzacast-puppeteer-runner", + "version": "0.1.0", + "description": "Runner E2E con Puppeteer conectado a browserless para validar flujo Broadcast->Studio", + "main": "index.js", + "scripts": { + "e2e": "node index.js", + "build": "tsc", + "start": "node dist/index.js" + }, + "author": "AvanzaCast", + "license": "MIT", + "dependencies": { + "puppeteer": "^20.9.0", + "axios": "^1.4.0", + "dotenv": "^16.0.0" + }, + "devDependencies": { + "ts-node": "^10.9.1", + "typescript": "^4.9.5", + "@types/node": "^18.0.0" + } +} diff --git a/e2e/puppeteer-runner/save-candidates.js b/e2e/puppeteer-runner/save-candidates.js new file mode 100644 index 0000000..bffa479 --- /dev/null +++ b/e2e/puppeteer-runner/save-candidates.js @@ -0,0 +1,20 @@ +(async ()=>{ + const mod = await import('puppeteer') + const puppeteer = (mod && mod.default) ? mod.default : mod + const fs = await import('fs') + const chromePath = process.env.CHROME_PATH || '/usr/bin/chromium' + const url = process.env.VITE_BROADCASTPANEL_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + const browser = await puppeteer.launch({ executablePath: chromePath, args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage'], defaultViewport: { width: 1400, height: 900 } }) + const page = await browser.newPage() + try { await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 }) } catch(e) { } + await page.waitForTimeout(3000) + const candidates = await page.evaluate(()=>{ + function norm(s){ return (s||'').replace(/\s+/g,' ').trim() } + const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]')) + return nodes.slice(0,500).map(n=>({ text: norm(n.textContent||n.innerText||''), aria: n.getAttribute && n.getAttribute('aria-label'), classes: (n.className||'').toString().slice(0,300), role: n.getAttribute && n.getAttribute('role') })) + }) + await fs.promises.writeFile('reports/candidates.json', JSON.stringify(candidates, null, 2), 'utf-8') + await browser.close() + console.log('saved candidates to reports/candidates.json') +})().catch(e=>{ console.error(e && e.stack?e.stack:e); process.exit(2) }) + diff --git a/e2e/puppeteer-runner/send-token-direct.js b/e2e/puppeteer-runner/send-token-direct.js new file mode 100644 index 0000000..e69de29 diff --git a/e2e/puppeteer-runner/send-token-to-studio.js b/e2e/puppeteer-runner/send-token-to-studio.js new file mode 100644 index 0000000..b4b00fa --- /dev/null +++ b/e2e/puppeteer-runner/send-token-to-studio.js @@ -0,0 +1,234 @@ +(async ()=>{ + const mod = await import('puppeteer') + const puppeteer = (mod && mod.default) ? mod.default : mod + const path = await import('path') + const fs = await import('fs') + const chromePath = process.env.CHROME_PATH || '/usr/bin/chromium' + const url = process.env.VITE_BROADCASTPANEL_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + const token = process.env.TOKEN || 'e2e098863b912f6a178b68e71ec3c58d' + const livekitUrl = process.env.VITE_LIVEKIT_WS_URL || 'wss://livekit-server.bfzqqk.easypanel.host' + const outDir = process.cwd() + console.log('Launching Chromium at', chromePath) + const browser = await puppeteer.launch({ executablePath: chromePath, args: ['--no-sandbox','--disable-setuid-sandbox','--disable-dev-shm-usage'], defaultViewport: { width: 1400, height: 900 }, headless: 'new' }) + const page = await browser.newPage() + + const consoles = [] + page.on('console', msg => { try { consoles.push({ type: msg.type(), text: msg.text() }) } catch(e) {} }) + const pageErrors = [] + page.on('pageerror', err => { pageErrors.push(String(err && err.stack ? err.stack : err)) }) + + try { + console.log('Navigating to', url) + await page.goto(url, { waitUntil: 'networkidle2', timeout: 90000 }) + console.log('Page loaded') + } catch(e){ console.warn('goto error', e && e.message?e.message:e) } + await page.waitForTimeout(2000) + + // Try to click 'Transmisión en vivo' card to open transmissions panel + const navClicked = await page.evaluate(()=>{ + const norm = (s)=> (s||'').toLowerCase().trim() + // 1) Try within create grid + try{ + const grid = document.querySelector('[class*="createGrid"], .createGrid') + if(grid){ + const buttons = Array.from(grid.querySelectorAll('button, a')) + for(const b of buttons){ + const t = norm(b.textContent || b.innerText || b.getAttribute('aria-label') || '') + if(t.includes('transmisión en vivo') || t.includes('transmision en vivo') || t.includes('transmis')){ + try{ b.click(); return {ok:true, from:'createGrid', text:t.slice(0,120)} }catch(e){} + } + } + } + }catch(e){} + + // 2) exact text anywhere among buttons/links + const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]')) + const hits = [] + for(const n of nodes){ + const t = norm(n.textContent || n.innerText || n.getAttribute('aria-label') || '') + if(!t) continue + if(t === 'transmisión en vivo' || t === 'transmision en vivo' || t.includes('transmis')){ + try{ n.click(); return {ok:true, from:'global', text:t.slice(0,120)} }catch(e){} + } + if(t.includes('transmis')) hits.push({text:t,tag:n.tagName,class:n.className.slice(0,120),aria:n.getAttribute('aria-label')||''}) + } + // expose hits to page console for debugging + try{ console.debug('transmis-candidates', JSON.stringify(hits.slice(0,20))) }catch(e){} + return {ok:false, hits: hits.slice(0,10)} + }) + console.log('navClicked:', navClicked) + await page.waitForTimeout(2000) + + // Wait for 'Entrar al estudio' button to appear (retry for up to 10s) + let foundEnter = false + for(let attempt=0; attempt<8; attempt++){ + foundEnter = await page.evaluate(()=>{ + const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]')) + const norm = (s)=> (s||'').toLowerCase().trim() + for(const n of nodes){ const t = norm(n.textContent||n.getAttribute('aria-label')||n.innerText||''); if(t.includes('entrar al estudio') || t === 'entrar al estudio' || t.includes('entrando...')) return true } + return false + }) + console.log('check enter button attempt', attempt, 'found=', foundEnter) + if(foundEnter) break + await page.waitForTimeout(1000 + attempt*500) + } + + // If enter button does not exist, attempt to create a new transmission via UI flow + if(!foundEnter){ + console.log('No Entrar al estudio found — attempting to create a transmission via UI') + try{ + // Attempt to click a 'Transmisión en vivo' or 'Nueva transmisión' card/button again + await page.evaluate(()=>{ + const nodes = Array.from(document.querySelectorAll('button, a, div')) + const norm = (s)=> (s||'').toLowerCase().trim() + for(const n of nodes){ + const t = norm(n.textContent || n.innerText || n.getAttribute('aria-label') || '') + if(!t) continue + if(t.includes('transmisión en vivo') || t.includes('transmisión') || t.includes('nueva escena') || t.includes('nueva transmisión') || t.includes('nueva')){ + try{ n.click(); break }catch(e){} + } + } + }) + await page.waitForTimeout(1200) + + // If a modal appears with 'Omitir ahora' (skip scheduling), click it + const clickedOmit = await page.evaluate(()=>{ + const nodes = Array.from(document.querySelectorAll('button, a')) + const norm = (s)=> (s||'').toLowerCase().trim() + for(const n of nodes){ + const t = norm(n.textContent || n.innerText || n.getAttribute('aria-label') || '') + if(!t) continue + if(t.includes('omitir') || t.includes('omitir ahora') || t.includes('skip') ){ + try{ n.click(); return {ok:true, text:t.slice(0,120)} }catch(e){ return {ok:false, err:String(e)} } + } + } + return {ok:false} + }) + console.log('clickedOmit result', clickedOmit) + await page.waitForTimeout(1000) + + // Fill title input if present and click 'Empezar ahora' or similar + const created = await page.evaluate(()=>{ + // Try to find an input/textarea for title + const inputs = Array.from(document.querySelectorAll('input, textarea')) + const norm = (s)=> (s||'').toLowerCase().trim() + let filled = false + for(const inp of inputs){ + try{ + const p = inp.getAttribute('placeholder') || inp.getAttribute('aria-label') || inp.name || '' + const t = norm(p) + if(t.includes('tít') || t.includes('titu') || t.includes('title') || t.includes('nombre') || t.includes('transmisi')){ + try{ inp.focus(); inp.value = 'Transmision'; inp.dispatchEvent(new Event('input', { bubbles: true })); }catch(e){} + filled = true; break + } + }catch(e){} + } + // If not filled, try first visible input + if(!filled && inputs.length>0){ try{ inputs[0].focus(); inputs[0].value = 'Transmision'; inputs[0].dispatchEvent(new Event('input', { bubbles: true })); filled = true }catch(e){} } + // Find and click 'Empezar ahora' / 'Empezar' / 'Start' / 'Iniciar ahora' + const buttons = Array.from(document.querySelectorAll('button, a')) + for(const b of buttons){ + const txt = norm(b.textContent||b.getAttribute('aria-label')||b.innerText||'') + if(txt.includes('empezar ahora') || txt.includes('empezar') || txt.includes('iniciar ahora') || txt.includes('start now') || txt.includes('comenzar')){ + try{ b.click(); return {ok:true, clicked: txt} }catch(e){ return {ok:false, err:String(e)} } + } + } + return {ok:false, filled} + }) + console.log('created transmission result', created) + await page.waitForTimeout(2000) + + // After creation, re-check for Enter button + for(let attempt=0; attempt<8; attempt++){ + foundEnter = await page.evaluate(()=>{ + const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]')) + const norm = (s)=> (s||'').toLowerCase().trim() + for(const n of nodes){ const t = norm(n.textContent||n.getAttribute('aria-label')||n.innerText||''); if(t.includes('entrar al estudio') || t === 'entrar al estudio' || t.includes('entrando...')) return true } + return false + }) + console.log('re-check enter button attempt', attempt, 'found=', foundEnter) + if(foundEnter) break + await page.waitForTimeout(1000 + attempt*500) + } + }catch(e){ console.warn('create transmission flow failed', e && e.message?e.message:e) } + } + + // If enter button exists, try to click the first one + let clickedEnter = { ok: false } + if(foundEnter){ + clickedEnter = await page.evaluate(()=>{ + const nodes = Array.from(document.querySelectorAll('button, a, [role="button"]')) + const norm = (s)=> (s||'').toLowerCase().trim() + for(const n of nodes){ const t = norm(n.textContent||n.getAttribute('aria-label')||n.innerText||''); if(t.includes('entrar al estudio')){ try{ n.click(); return {ok:true, text:t.slice(0,120)} }catch(e){ return {ok:false, err: String(e)} } } } + return {ok:false} + }) + console.log('clicked enter button result', clickedEnter) + } else { + console.log('Entrar al estudio button still not found after create attempt — will still postMessage to window') + } + + await page.waitForTimeout(1000) + + const beforePath = path.join(outDir, 'send-token-before.png') + try { await page.screenshot({ path: beforePath, fullPage: true }); console.log('Saved screenshot', beforePath) } catch(e){ console.warn('screenshot before failed', e && e.message)} + + const POST_ORIGIN = '*' + const payload = { type: 'LIVEKIT_TOKEN', token, url: livekitUrl, room: 'e2e-room' } + + const result = await page.evaluate(async (payload, POST_ORIGIN)=>{ + const tryPost = (w, origin) => { try{ w.postMessage(payload, origin); return true }catch(e){return false} } + const tried = [] + try{ + const globals = ['__studioPopup','popupForE2E','window.__studioPopup','__AVZ_LAST_MSG_SOURCE','__AVZ_LAST_MSG_SOURCE?.source'] + for(const g of globals){ try{ const w = window[g]; if(w && typeof w.postMessage === 'function') { tried.push({target:g,ok:tryPost(w, window.location.origin || '*')}) } }catch(e){} } + if(window.opener && !window.opener.closed){ tried.push({target:'opener', ok:tryPost(window.opener, POST_ORIGIN)}) } + try{ window.postMessage(payload, window.location.origin); tried.push({target:'self', ok:true}) } catch(e) { tried.push({target:'self', ok:false}) } + }catch(e){ tried.push({error: String(e)}) } + return tried + }, payload, POST_ORIGIN) + + console.log('postMessage attempts:', result) + + // Fallback: if not connected yet, navigate to URL with token query so app auto-opens studio + const fallbackUrl = `${url.replace(/\/$/, '')}/?token=${encodeURIComponent(token)}&room=e2e-room`; + console.log('fallbackUrl:', fallbackUrl) + try{ + await page.goto(fallbackUrl, { waitUntil: 'networkidle2', timeout: 15000 }) + console.log('Navigated to fallback token URL') + } catch(e){ console.warn('fallback navigation failed', e && e.message)} + + // wait and check for Studio status element added by App (id='status' or presence of StudioPortal root) + let connected = false + try { + const maxWait = 20000 + const start = Date.now() + while (Date.now() - start < maxWait) { + try { + const statusText = await page.evaluate(()=>{ + const el = document.getElementById('status') + if(el) return el.textContent + // try detecting studio overlay by looking for known texts + const nodes = Array.from(document.querySelectorAll('div,span')) + for(const n of nodes){ const t=(n.textContent||'').toLowerCase(); if(t.includes('validando token') || t.includes('validando') || t.includes('entrando') || t.includes('validando token') || t.includes('conectado')) return t } + return null + }) + if (statusText && statusText.toLowerCase().includes('conectado')) { connected = true; break } + } catch(e) {} + await page.waitForTimeout(1000) + } + } catch(e) { console.warn('status check failed', e && e.message)} + + await page.waitForTimeout(1000) + const afterPath = path.join(outDir, 'send-token-after.png') + try { await page.screenshot({ path: afterPath, fullPage: true }); console.log('Saved screenshot', afterPath) } catch(e){ console.warn('screenshot after failed', e && e.message)} + + try { + const out = { consoles, pageErrors, navClicked: navClicked, clickedEnter: clickedEnter, postAttempts: result, connected } + const logPath = path.join(outDir, 'send-token-browser-log.json') + await fs.promises.writeFile(logPath, JSON.stringify(out, null, 2), 'utf8') + console.log('Wrote browser log to', logPath) + } catch(e){ console.warn('failed to write browser log', e && e.message)} + + await browser.close() + console.log('done, connected=', connected) +})().catch(e=>{ console.error(e && e.stack?e.stack:e); process.exit(2) }) diff --git a/e2e/puppeteer-runner/ws-test.js b/e2e/puppeteer-runner/ws-test.js new file mode 100644 index 0000000..f41a24e --- /dev/null +++ b/e2e/puppeteer-runner/ws-test.js @@ -0,0 +1,9 @@ +const WebSocket = require('ws') +const url = process.argv[2] +if (!url) { console.error('Usage: node ws-test.js '); process.exit(2) } +console.log('Testing WS connect to', url) +const ws = new WebSocket(url, { handshakeTimeout: 5000 }) +ws.on('open', ()=>{ console.log('WS OPEN'); ws.close(); process.exit(0) }) +ws.on('error', (e)=>{ console.error('WS ERROR', e && e.message ? e.message : e); process.exit(3) }) +setTimeout(()=>{ console.error('WS TIMEOUT'); process.exit(4) }, 10000) + diff --git a/e2e/run_e2e_with_mock.js b/e2e/run_e2e_with_mock.js new file mode 100644 index 0000000..e8e62bc --- /dev/null +++ b/e2e/run_e2e_with_mock.js @@ -0,0 +1,121 @@ +// e2e/run_e2e_with_mock.js +// Starts an express mock server in-process, runs the local validator against it, then shuts down. +const express = require('express'); +const bodyParser = require('body-parser'); +const path = require('path'); +const { spawn } = require('child_process'); + +async function startMock(port = 4001) { + const app = express(); + app.use(bodyParser.json()); + + const sessions = new Map(); + function generateId() { return 's' + Math.random().toString(36).slice(2,9); } + + app.post('/api/session', (req, res) => { + const body = req.body || {}; + const id = generateId(); + const token = 'mocktoken-' + Math.random().toString(36).slice(2,12); + sessions.set(id, { token, room: body.room || 'room', username: body.username || 'user' }); + const studioUrl = `http://localhost:${port}/studio/${id}`; + res.json({ id, studioUrl, redirectUrl: studioUrl, ttlSeconds: 300 }); + }); + + app.get('/api/session/:id/token', (req, res) => { + const id = req.params.id; + const s = sessions.get(id); + if (!s) return res.status(404).json({ error: 'not_found' }); + res.json({ token: s.token, ttlSeconds: 300, room: s.room, username: s.username, url: `ws://localhost:7880` }); + }); + + app.get('/broadcast', (req, res) => { + res.setHeader('Content-Type', 'text/html'); + res.send(` + + Mock Broadcast + +

Broadcast Panel - Mock

+ Entrar al estudio + + +`); + }); + + app.get('/studio/:id', (req, res) => { + const id = req.params.id; + res.setHeader('Content-Type', 'text/html'); + res.send(` + + Mock Studio ${id} + +

Studio Portal - Mock

+
loading...
+ + +`); + }); + + return new Promise((resolve, reject) => { + const server = app.listen(port, () => { + console.log('Mock server listening on', port); + resolve(server); + }); + server.on('error', reject); + }); +} + +(async () => { + const port = 4001; + let server; + try { + server = await startMock(port); + } catch (err) { + console.error('Failed to start mock', err); + process.exit(1); + } + + console.log('Running local validator against mock...'); + const env = Object.assign({}, process.env, { + BROADCAST_URL: `http://localhost:${port}/broadcast`, + TOKEN: 'mock-runner-token-1', + STUDIO_URL: `http://localhost:${port}/studio`, + }); + + const child = spawn(process.execPath, [path.join(__dirname, 'validate-flow-domains-local.js')], { env, stdio: 'inherit' }); + child.on('exit', (code) => { + console.log('Validator exited with', code); + server.close(() => { + console.log('Mock server stopped'); + process.exit(code || 0); + }); + }); +})(); diff --git a/e2e/simulate_token_query_browserless.js b/e2e/simulate_token_query_browserless.js new file mode 100644 index 0000000..b45ab58 --- /dev/null +++ b/e2e/simulate_token_query_browserless.js @@ -0,0 +1,72 @@ +// e2e/simulate_token_query_browserless.js +// Simulate E2E by navigating to Broadcast Panel with ?token=... so frontend stores session in sessionStorage, +// then open Studio URL with that token and take a screenshot + write results. +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-core'); + +(async () => { + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; + let ws = process.env.BROWSERLESS_WS || ''; + const btoken = process.env.BROWSERLESS_TOKEN || ''; + try { if (ws && btoken) { ws = ws.includes('?') ? `${ws}&token=${encodeURIComponent(btoken)}` : `${ws}?token=${encodeURIComponent(btoken)}`; } } catch(e){} + if (!ws) { console.error('BROWSERLESS_WS required'); process.exit(2); } + + const BROADCAST_URL = process.env.BROADCAST_URL || process.env.VITE_BROADCASTPANEL_URL; + const STUDIO_URL = process.env.STUDIO_URL || process.env.VITE_STUDIO_URL; + const TOKEN = process.env.TOKEN || process.env.E2E_TOKEN || ''; + if (!BROADCAST_URL) { console.error('BROADCAST_URL required'); process.exit(2); } + console.log('Connecting to browserless at', ws); + const browser = await puppeteer.connect({ browserWSEndpoint: ws, ignoreHTTPSErrors: true, defaultViewport: { width: 1366, height: 768 } }); + const page = await browser.newPage(); + page.setDefaultNavigationTimeout(30000); + page.on('console', msg => { try { results.console.push({ type: msg.type(), text: msg.text() }); } catch(e){} }); + page.on('pageerror', err => { results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); }); + + try { + const urlWithToken = TOKEN ? `${BROADCAST_URL.replace(/\/$/, '')}?token=${encodeURIComponent(TOKEN)}` : BROADCAST_URL; + console.log('Navigating to', urlWithToken); + await page.goto(urlWithToken, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(2500); + // snapshot the broadcast page + const shot1 = path.join(outDir, 'simulate_broadcast_with_token.png'); + await page.screenshot({ path: shot1, fullPage: true }); + results.navigations.push({ type: 'broadcast_loaded', url: page.url(), screenshot: shot1 }); + + // Try to read sessionStorage key + const storeKey = process.env.STUDIO_SESSION_KEY || 'avanzacast_studio_session'; + const stored = await page.evaluate((k) => { try { return sessionStorage.getItem(k); } catch(e) { return null; } }, storeKey); + results.sessionStorage = { key: storeKey, value: stored }; + + // If STUDIO_URL provided, navigate to it with token + if (STUDIO_URL) { + const studioWithToken = `${STUDIO_URL.replace(/\/$/, '')}?token=${encodeURIComponent(TOKEN)}`; + console.log('Navigating to studio URL', studioWithToken); + const newPage = await browser.newPage(); + newPage.setDefaultNavigationTimeout(30000); + newPage.on('console', msg => { try { results.console.push({ type: msg.type(), text: msg.text() }); } catch(e){} }); + newPage.on('pageerror', err => { results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); }); + await newPage.goto(studioWithToken, { waitUntil: 'networkidle2' }); + await newPage.waitForTimeout(2500); + const shot2 = path.join(outDir, 'simulate_studio_with_token.png'); + await newPage.screenshot({ path: shot2, fullPage: true }); + results.navigations.push({ type: 'studio_loaded', url: newPage.url(), screenshot: shot2 }); + try { await newPage.close(); } catch(e){} + } + + results.endedAt = new Date().toISOString(); + const outFile = path.join(outDir, 'simulate_token_query_browserless-result.json'); + fs.writeFileSync(outFile, JSON.stringify(results, null, 2)); + console.log('Wrote', outFile); + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(0); + } catch (err) { + console.error('Error', err && err.stack ? err.stack : err); + results.error = String(err && err.stack ? err.stack : err); + results.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(outDir, 'simulate_token_query_browserless-result.json'), JSON.stringify(results, null, 2)); + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(1); + } +})(); diff --git a/e2e/streamyard-flow-browserless.js b/e2e/streamyard-flow-browserless.js new file mode 100644 index 0000000..2baa20f --- /dev/null +++ b/e2e/streamyard-flow-browserless.js @@ -0,0 +1,194 @@ +// e2e/streamyard-flow-browserless.js +// Puppeteer script to run StreamYard flow via remote browserless WebSocket +// Usage: +// npm install --save-dev puppeteer-core +// BROWSERLESS_WS=wss://browserless.bfzqqk.easypanel.host node e2e/streamyard-flow-browserless.js + +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-core'); + +(async () => { + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; + let ws = process.env.BROWSERLESS_WS || 'wss://browserless.bfzqqk.easypanel.host'; + const token = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS_API_KEY || null; + // If token provided and WS url doesn't already have query params, append it + try { + if (token) { + const hasQuery = ws.includes('?'); + ws = hasQuery ? `${ws}&token=${encodeURIComponent(token)}` : `${ws}?token=${encodeURIComponent(token)}`; + } + } catch (e) { + // ignore URL building errors and use raw ws + } + console.log('Connecting to browserless at', ws); + + let browser; + try { + browser = await puppeteer.connect({ browserWSEndpoint: ws, ignoreHTTPSErrors: true, defaultViewport: { width: 1366, height: 768 } }); + } catch (err) { + console.error('Failed to connect to browserless:', err && err.message ? err.message : err); + process.exit(2); + } + + const page = await browser.newPage(); + page.setDefaultNavigationTimeout(30000); + + page.on('console', msg => { + try { results.console.push({ type: msg.type(), text: msg.text() }); } catch(e){} + }); + page.on('pageerror', err => { results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); }); + + // Helper: perform login if STREAMYARD_EMAIL and STREAMYARD_PASSWORD are provided + async function tryLoginIfNeeded() { + const email = process.env.STREAMYARD_EMAIL || process.env.STREAMYARD_USER || null; + const password = process.env.STREAMYARD_PASSWORD || process.env.STREAMYARD_PASS || null; + if (!email || !password) return false; + + const loginUrls = ['https://streamyard.com/signin', 'https://streamyard.com/login', 'https://streamyard.com/', 'https://app.streamyard.com/login']; + console.log('Attempting StreamYard login for', email); + + for (const url of loginUrls) { + try { + await page.goto(url, { waitUntil: 'networkidle2', timeout: 15000 }).catch(() => null); + // Try to find email input + const emailSelector = await Promise.race([ + page.waitForSelector('input[type="email"]', { timeout: 3000 }).then(() => 'input[type="email"]').catch(() => null), + page.waitForSelector('input[name="email"]', { timeout: 3000 }).then(() => 'input[name="email"]').catch(() => null), + page.waitForSelector('input[id*="email"]', { timeout: 3000 }).then(() => 'input[id*="email"]').catch(() => null), + ]).catch(() => null); + + if (!emailSelector) { + // Maybe there's a button to open login modal + const accountBtn = await page.$x("//button[contains(normalize-space(.), 'Mi cuenta') or contains(normalize-space(.), 'Sign in') or contains(normalize-space(.), 'Inicia sesión')]"); + if (accountBtn && accountBtn.length) { + try { await accountBtn[0].click(); await page.waitForTimeout(1200); } catch(e){} + } + } + + // After clicking or direct page, try to find inputs again + const finalEmailSelector = await Promise.race([ + page.waitForSelector('input[type="email"]', { timeout: 3000 }).then(() => 'input[type="email"]').catch(() => null), + page.waitForSelector('input[name="email"]', { timeout: 3000 }).then(() => 'input[name="email"]').catch(() => null), + page.waitForSelector('input[id*="email"]', { timeout: 3000 }).then(() => 'input[id*="email"]').catch(() => null), + ]).catch(() => null); + + const passSelector = await Promise.race([ + page.waitForSelector('input[type="password"]', { timeout: 3000 }).then(() => 'input[type="password"]').catch(() => null), + page.waitForSelector('input[name="password"]', { timeout: 3000 }).then(() => 'input[name="password"]').catch(() => null), + page.waitForSelector('input[id*="password"]', { timeout: 3000 }).then(() => 'input[id*="password"]').catch(() => null), + ]).catch(() => null); + + if (finalEmailSelector && passSelector) { + try { + await page.fill(finalEmailSelector, email); + await page.fill(passSelector, password); + // Try pressing Enter in password field + await page.focus(passSelector); + await page.keyboard.press('Enter'); + // Wait for navigation or dashboard indicator + await page.waitForTimeout(2000); + // Wait for a known dashboard element (Transmisiones y grabaciones) or redirect to /broadcasts + try { + await Promise.race([ + page.waitForURL('**/broadcasts', { timeout: 8000 }), + page.waitForSelector('text=Transmisiones y grabaciones', { timeout: 8000 }) + ]); + console.log('Login appears successful (navigated to broadcasts)'); + return true; + } catch (e) { + console.warn('Login attempt did not detect broadcasts page yet, continuing'); + } + } catch (e) { + console.warn('Login fill/submit failed on', url, e && e.message); + } + } + } catch (e) { + // continue to next + console.warn('Login attempt at', url, 'error', e && e.message); + } + } + + console.warn('Automatic login did not succeed'); + return false; + } + + try { + const startUrl = 'https://streamyard.com/'; + // If credentials are provided, try to authenticate first + await tryLoginIfNeeded(); + console.log('goto', startUrl); + await page.goto(startUrl, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(1500); + + // Find elements with exact text 'Entrar al estudio' + const anchors = await page.$x("//a[contains(normalize-space(.), 'Entrar al estudio')]"); + const buttons = await page.$x("//button[contains(normalize-space(.), 'Entrar al estudio')]"); + const elems = anchors.concat(buttons); + console.log('found', elems.length, 'elements'); + results.found = elems.length; + + for (let i = 0; i < elems.length; i++) { + const el = elems[i]; + let beforeHref = null; + try { + beforeHref = await (await el.getProperty('href')).jsonValue().catch(() => null); + } catch(e) { beforeHref = null; } + const navRec = { index: i, beforeHref, attempts: [] }; + + let clicked = false; + for (let attempt = 1; attempt <= 3; attempt++) { + const att = { attempt, timestamp: new Date().toISOString() }; + try { + // Start waiting for navigation or a short timeout + const navPromise = page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 10000 }).catch(() => null); + await el.click({ delay: 50 }); + const nav = await navPromise; + att.afterUrl = page.url(); + att.navigated = !!nav; + att.ok = true; + navRec.attempts.push(att); + clicked = true; + + // if navigated, go back to start for next element + if (nav) { + await page.waitForTimeout(800); + await page.goto(startUrl, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(800); + } + break; + } catch (err) { + att.ok = false; + att.error = String(err.message || err); + navRec.attempts.push(att); + await page.waitForTimeout(700); + } + } + + navRec.success = clicked; + results.navigations.push(navRec); + } + + const screenshotPath = path.join(outDir, 'streamyard_flow_browserless.png'); + await page.screenshot({ path: screenshotPath, fullPage: true }); + results.screenshot = screenshotPath; + + results.endedAt = new Date().toISOString(); + const jsonOut = path.join(outDir, 'streamyard-flow-browserless-result.json'); + fs.writeFileSync(jsonOut, JSON.stringify(results, null, 2)); + console.log('Wrote results to', jsonOut); + + await page.close(); + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(0); + } catch (err) { + console.error('Error during flow', err); + results.error = String(err && err.stack ? err.stack : err); + results.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(outDir, 'streamyard-flow-browserless-result.json'), JSON.stringify(results, null, 2)); + try { await page.close(); } catch(e){} + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(1); + } +})(); diff --git a/e2e/streamyard-flow-remote.js b/e2e/streamyard-flow-remote.js new file mode 100644 index 0000000..48bce80 --- /dev/null +++ b/e2e/streamyard-flow-remote.js @@ -0,0 +1,99 @@ +// e2e/streamyard-flow-remote.js +// Connect to a remote Playwright server via wsEndpoint and run the StreamYard flow +// Usage: +// PW_WS=ws://192.168.1.20:3003 node e2e/streamyard-flow-remote.js + +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +(async () => { + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; + const ws = process.env.PW_WS || 'ws://192.168.1.20:3003'; + console.log('Connecting to Playwright WS at', ws); + let browser; + try { + browser = await chromium.connect({ wsEndpoint: ws, timeout: 30000 }); + } catch (err) { + console.error('Failed to connect to Playwright server:', err && err.message ? err.message : err); + process.exit(2); + } + + const context = await browser.newContext(); + const page = await context.newPage(); + + page.on('console', msg => { + try { results.console.push({ type: msg.type(), text: msg.text() }); } catch(e){} + }); + page.on('pageerror', err => { results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); }); + + try { + const startUrl = 'https://streamyard.com/'; + console.log('goto', startUrl); + await page.goto(startUrl, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(1500); + + const loc = page.locator('text=Entrar al estudio'); + const count = await loc.count(); + console.log('found', count, 'elements'); + results.found = count; + + for (let i = 0; i < count; i++) { + const el = loc.nth(i); + const beforeHref = await el.getAttribute('href'); + const navRec = { index: i, beforeHref, attempts: [] }; + + let clicked = false; + for (let attempt = 1; attempt <= 3; attempt++) { + const att = { attempt, timestamp: new Date().toISOString() }; + try { + const navPromise = page.waitForNavigation({ waitUntil: 'networkidle', timeout: 10000 }).catch(() => null); + await el.click({ force: true, timeout: 5000 }); + const nav = await navPromise; + att.afterUrl = page.url(); + att.navigated = !!nav; + att.ok = true; + navRec.attempts.push(att); + clicked = true; + + if (nav) { + await page.waitForTimeout(800); + await page.goto(startUrl, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(800); + } + break; + } catch (err) { + att.ok = false; + att.error = String(err.message || err); + navRec.attempts.push(att); + await page.waitForTimeout(700); + } + } + navRec.success = clicked; + results.navigations.push(navRec); + } + + const screenshotPath = path.join(outDir, 'streamyard_flow_remote.png'); + await page.screenshot({ path: screenshotPath, fullPage: true }); + results.screenshot = screenshotPath; + + results.endedAt = new Date().toISOString(); + const jsonOut = path.join(outDir, 'streamyard-flow-remote-result.json'); + fs.writeFileSync(jsonOut, JSON.stringify(results, null, 2)); + console.log('Wrote results to', jsonOut); + + await context.close(); + try { await browser.close(); } catch(e) { try { await browser.disconnect(); } catch(e){} } + process.exit(0); + } catch (err) { + console.error('Error during flow', err); + results.error = String(err && err.stack ? err.stack : err); + results.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(outDir, 'streamyard-flow-remote-result.json'), JSON.stringify(results, null, 2)); + try { await context.close(); } catch(e){} + try { await browser.close(); } catch(e){} + process.exit(1); + } +})(); + diff --git a/e2e/streamyard-flow.js b/e2e/streamyard-flow.js new file mode 100644 index 0000000..a9df833 --- /dev/null +++ b/e2e/streamyard-flow.js @@ -0,0 +1,98 @@ +// e2e/streamyard-flow.js +// Playwright standalone script to navigate StreamYard and click "Entrar al estudio" +// Usage: +// npm i -D playwright +// npx playwright install --with-deps +// node e2e/streamyard-flow.js + +const fs = require('fs'); +const path = require('path'); +const { chromium } = require('playwright'); + +(async () => { + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + + page.on('console', msg => { + try { results.console.push({ type: msg.type(), text: msg.text(), location: msg.location() }); } catch(e){} + }); + + page.on('pageerror', err => { + results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); + }); + + try { + const startUrl = 'https://streamyard.com/'; + console.log('goto', startUrl); + await page.goto(startUrl, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(1500); + + // find elements with visible text "Entrar al estudio" + const loc = page.locator('text=Entrar al estudio'); + const count = await loc.count(); + console.log('found', count, 'elements'); + results.found = count; + + for (let i = 0; i < count; i++) { + const el = loc.nth(i); + const beforeHref = await el.getAttribute('href'); + const navRec = { index: i, beforeHref, attempts: [] }; + + let clicked = false; + for (let attempt = 1; attempt <= 3; attempt++) { + const att = { attempt, timestamp: new Date().toISOString() }; + try { + // wait both for potential navigation and for popup events + const navPromise = page.waitForNavigation({ waitUntil: 'networkidle', timeout: 10000 }).catch(() => null); + await el.click({ force: true, timeout: 5000 }); + const nav = await navPromise; + att.afterUrl = page.url(); + att.navigated = !!nav; + att.ok = true; + navRec.attempts.push(att); + clicked = true; + + // if navigated, go back to start page for next iteration + if (nav) { + await page.waitForTimeout(800); + await page.goto(startUrl, { waitUntil: 'networkidle', timeout: 30000 }); + await page.waitForTimeout(800); + } + break; + } catch (err) { + att.ok = false; + att.error = String(err.message || err); + navRec.attempts.push(att); + // short delay then retry + await page.waitForTimeout(700); + } + } + + navRec.success = clicked; + results.navigations.push(navRec); + } + + // screenshot and save results + const screenshotPath = path.join(outDir, 'streamyard_flow.png'); + await page.screenshot({ path: screenshotPath, fullPage: true }); + results.screenshot = screenshotPath; + + results.endedAt = new Date().toISOString(); + const jsonOut = path.join(outDir, 'streamyard-flow-result.json'); + fs.writeFileSync(jsonOut, JSON.stringify(results, null, 2)); + console.log('Wrote results to', jsonOut); + + await browser.close(); + process.exit(0); + } catch (err) { + console.error('Error during flow', err); + results.error = String(err && err.stack ? err.stack : err); + results.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(outDir, 'streamyard-flow-result.json'), JSON.stringify(results, null, 2)); + try { await browser.close(); } catch(e){} + process.exit(1); + } +})(); diff --git a/e2e/test-pages/broadcast.html b/e2e/test-pages/broadcast.html new file mode 100644 index 0000000..7031bbd --- /dev/null +++ b/e2e/test-pages/broadcast.html @@ -0,0 +1,12 @@ + + + + + Broadcast Panel - Test + + +

Broadcast Panel Test Page

+ Entrar al estudio + + + diff --git a/e2e/validate-flow-browserless.js b/e2e/validate-flow-browserless.js new file mode 100644 index 0000000..44c2a35 --- /dev/null +++ b/e2e/validate-flow-browserless.js @@ -0,0 +1,159 @@ +// e2e/validate-flow-browserless.js +// Connects to a browserless WebSocket endpoint and validates the BroadcastPanel -> Studio flow +// Expects env vars: +// BROWSERLESS_WS (wss://...) +// BROWSERLESS_TOKEN (optional) +// VITE_BROADCASTPANEL_URL or BROADCAST_URL +// VITE_STUDIO_URL or STUDIO_URL (optional, fallback to page's url) +// TOKEN or E2E_TOKEN (token to pass to studio) + +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-core'); + +(async () => { + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; + + let ws = process.env.BROWSERLESS_WS || process.env.BWS || ''; + const btoken = process.env.BROWSERLESS_TOKEN || process.env.BWS_TOKEN || ''; + try { + if (ws && btoken) { + const hasQuery = ws.includes('?'); + ws = hasQuery ? `${ws}&token=${encodeURIComponent(btoken)}` : `${ws}?token=${encodeURIComponent(btoken)}`; + } + } catch (e) {} + + if (!ws) { + console.error('BROWSERLESS_WS is required'); + process.exit(2); + } + + const BROADCAST_URL = process.env.VITE_BROADCASTPANEL_URL || process.env.BROADCAST_URL || ''; + const STUDIO_URL = process.env.VITE_STUDIO_URL || process.env.STUDIO_URL || null; + const TOKEN = process.env.TOKEN || process.env.E2E_TOKEN || ''; + + console.log('Connecting to browserless at', ws); + let browser; + try { + browser = await puppeteer.connect({ browserWSEndpoint: ws, ignoreHTTPSErrors: true, defaultViewport: { width: 1366, height: 768 } }); + } catch (err) { + console.error('Failed to connect to browserless:', err && err.message ? err.message : err); + process.exit(2); + } + + const page = await browser.newPage(); + page.setDefaultNavigationTimeout(30000); + page.on('console', msg => { try { results.console.push({ type: msg.type(), text: msg.text() }); } catch (e) {} }); + page.on('pageerror', err => { results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); }); + + try { + if (!BROADCAST_URL) { + console.error('BROADCAST_URL (VITE_BROADCASTPANEL_URL) is required'); + await browser.disconnect(); + process.exit(2); + } + + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(1500); + + const texts = ['Entrar al estudio', 'Entrar al Studio', 'Entrar al Estudio', 'Entrar al Studio', 'Enter studio', 'Enter the studio', 'Enter the studio']; + let clicked = false; + + for (const t of texts) { + const els = await page.$x(`//a[contains(normalize-space(.), '${t}')] | //button[contains(normalize-space(.), '${t}')] | //span[contains(normalize-space(.), '${t}')]`); + if (els && els.length) { + console.log('Found element for text:', t, 'count=', els.length); + try { + // Try click and wait for targetcreated or navigation + const popupPromise = new Promise(resolve => { + const onTarget = target => { resolve(target); }; + browser.once('targetcreated', onTarget); + setTimeout(() => { try { browser.removeListener('targetcreated', onTarget) } catch(e){}; resolve(null); }, 3000); + }); + + await els[0].click({ delay: 50 }).catch(() => null); + const popupTarget = await popupPromise; + + let studioPage = null; + if (popupTarget) { + try { studioPage = await popupTarget.page(); } catch (e) { studioPage = null; } + } + + if (!studioPage) { + await page.waitForTimeout(800); + const url = page.url(); + if (url.includes('/studio') || url.includes('studio') || url.includes('avanzacast-studio')) studioPage = page; + } + + if (studioPage) { + console.log('Studio page found; navigating with token...'); + const targetStudioUrl = STUDIO_URL ? `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}` : `${studioPage.url().split('?')[0]}?token=${encodeURIComponent(TOKEN)}`; + await studioPage.goto(targetStudioUrl, { waitUntil: 'networkidle2' }); + results.navigations.push({ type: 'studio_opened', url: studioPage.url() }); + await studioPage.waitForTimeout(2500); + const shot = path.join(outDir, 'studio_flow_browserless_result.png'); + await studioPage.screenshot({ path: shot, fullPage: true }); + results.screenshot = shot; + clicked = true; + break; + } else { + console.log('Click did not open studio for text:', t); + } + + } catch (err) { + console.warn('Click attempt error', err && err.message); + } + } + } + + if (!clicked) { + // fallback: try alternative selectors + const altSel = 'a#enter-studio, button#enter-studio, a[data-enter-studio], button[data-enter-studio]'; + try { + const alt = await page.$(altSel); + if (alt) { + console.log('Found alternative selector, clicking...'); + await alt.click().catch(() => null); + await page.waitForTimeout(1000); + } + } catch (e) {} + + // fallback navigate directly to studio URL with token + if (STUDIO_URL) { + console.log('Fallback: navigating directly to STUDIO_URL with token'); + const directUrl = `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}`; + await page.goto(directUrl, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(1500); + const shot = path.join(outDir, 'studio_flow_browserless_result.png'); + await page.screenshot({ path: shot, fullPage: true }); + results.screenshot = shot; + results.navigations.push({ type: 'direct_studio', url: directUrl }); + } else { + console.error('No studio opened and no STUDIO_URL provided for fallback.'); + } + } + + results.endedAt = new Date().toISOString(); + const outJson = path.join(outDir, 'validate-flow-browserless-result.json'); + fs.writeFileSync(outJson, JSON.stringify(results, null, 2)); + console.log('Wrote results to', outJson); + + // Publish artifact and append run summary to LOG.md + try { + const { publishArtifact, appendLog } = require('./logging'); + const artifactUrl = publishArtifact(outJson, 'validate-flow-browserless') || null; + appendLog('validate-flow-browserless', outJson, results, artifactUrl); + } catch (e) { console.warn('Failed to write LOG.md entry', e); } + + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(0); + } catch (err) { + console.error('Error validating flow', err && err.stack ? err.stack : err); + results.error = String(err && err.stack ? err.stack : err); + results.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(outDir, 'validate-flow-browserless-result.json'), JSON.stringify(results, null, 2)); + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(1); + } +})(); diff --git a/e2e/validate-flow-domains-local.js b/e2e/validate-flow-domains-local.js new file mode 100644 index 0000000..4c7a8bc --- /dev/null +++ b/e2e/validate-flow-domains-local.js @@ -0,0 +1,162 @@ +// e2e/validate-flow-domains-local.js +// Local Puppeteer script to validate studio opening flow for AvanzaCast +// - Navigates to BROADCAST_URL (VITE_BROADCASTPANEL_URL) +// - Clicks 'Entrar al estudio' (or opens studio route) +// - Opens or navigates the Studio Portal with provided TOKEN and LIVEKIT WS URL +// - Captures logs and a screenshot and writes a result JSON + +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer'); + +(async () => { + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; + + const BROADCAST_URL = process.env.VITE_BROADCASTPANEL_URL || process.env.BROADCAST_URL; + const STUDIO_URL = process.env.VITE_STUDIO_URL || process.env.STUDIO_URL || null; + const TOKEN = process.env.TOKEN || process.env.E2E_TOKEN; + const LIVEKIT_WS = process.env.VITE_LIVEKIT_WS_URL || process.env.LIVEKIT_WS; + const TOKEN_SERVER = process.env.VITE_TOKEN_SERVER_URL || process.env.TOKEN_SERVER_URL; + const HEADLESS = process.env.HEADLESS === '0' ? false : (process.env.HEADLESS === '1' ? true : true); + + console.log('HEADLESS:', HEADLESS); + + if (!BROADCAST_URL) { + console.error('BROADCAST_URL (VITE_BROADCASTPANEL_URL) is required'); + process.exit(2); + } + if (!TOKEN) { + console.error('TOKEN env is required (the e2e token to pass to studio)'); + process.exit(2); + } + + console.log('Broadcast URL:', BROADCAST_URL); + console.log('Studio URL:', STUDIO_URL || '(not provided)'); + console.log('LiveKit WS:', LIVEKIT_WS || '(not provided)'); + console.log('Token server:', TOKEN_SERVER || '(not provided)'); + + const browser = await puppeteer.launch({ headless: HEADLESS, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); + const page = await browser.newPage(); + page.setDefaultNavigationTimeout(30000); + + page.on('console', msg => { + try { results.console.push({ type: msg.type(), text: msg.text() }); } catch(e){} + }); + page.on('pageerror', err => { results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); }); + + try { + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(1200); + + // Try to find a button or link with text Entrar al estudio or Enter studio + const texts = ['Entrar al estudio', 'Entrar al Studio', 'Enter studio', 'Enter the studio', 'Entrar al estudio']; + let clicked = false; + for (const t of texts) { + const els = await page.$x(`//a[contains(normalize-space(.), '${t}')] | //button[contains(normalize-space(.), '${t}')]`); + if (els && els.length) { + console.log('Found element for text:', t); + try { + // attempt to click and wait for popup or navigation + const popupPromise = new Promise(resolve => { + const onTarget = target => { + try { resolve(target); } catch (e) { resolve(null); } + }; + browser.once('targetcreated', onTarget); + // safety timeout + setTimeout(() => { browser.removeListener('targetcreated', onTarget); resolve(null); }, 3000); + }); + + await els[0].click({ delay: 50 }).catch(() => null); + const popupTarget = await popupPromise; + + // Check for new target (popup) + let studioPage = null; + if (popupTarget) { + try { + studioPage = await popupTarget.page(); + } catch(e) { studioPage = null; } + } + + // If no popup, maybe navigation in same page + if (!studioPage) { + // Wait for navigation or a url change indicating studio + await page.waitForTimeout(800); + const url = page.url(); + if (url.includes('/studio') || url.includes('studio')) { + studioPage = page; + } + } + + // If studioPage found and we have STUDIO_URL, force navigate with token + if (studioPage) { + console.log('Studio page found; navigating with token...'); + const targetStudioUrl = STUDIO_URL ? `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}` : `${page.url().split('?')[0]}?token=${encodeURIComponent(TOKEN)}`; + await studioPage.goto(targetStudioUrl, { waitUntil: 'networkidle2' }); + results.navigations.push({ type: 'studio_opened', url: studioPage.url() }); + // Wait a bit for LiveKit connect attempts (if any) + await studioPage.waitForTimeout(2500); + const shot = path.join(outDir, 'studio_flow_result.png'); + await studioPage.screenshot({ path: shot, fullPage: true }); + results.screenshot = shot; + clicked = true; + break; + } else { + console.log('Click did not open studio; will try next match'); + } + } catch (err) { + console.warn('Click attempt error', err && err.message); + } + } + } + + if (!clicked) { + // Fallback: try a generic selector or direct navigation + const altSel = 'a#enter-studio, button#enter-studio, a[data-enter-studio]'; + try { + const alt = await page.$(altSel); + if (alt) { + console.log('Found alternative selector, clicking...'); + await alt.click().catch(() => null); + await page.waitForTimeout(1000); + } + } catch(e){} + + // Fallback: navigate directly to studio with token if STUDIO_URL provided + if (STUDIO_URL) { + console.log('Fallback: navigating directly to STUDIO_URL with token'); + const directUrl = `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}`; + await page.goto(directUrl, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(1500); + const shot = path.join(outDir, 'studio_flow_result.png'); + await page.screenshot({ path: shot, fullPage: true }); + results.screenshot = shot; + results.navigations.push({ type: 'direct_studio', url: directUrl }); + } else { + console.error('No studio opened and no STUDIO_URL provided for fallback.'); + } + } + + results.endedAt = new Date().toISOString(); + const outJson = path.join(outDir, 'studio-flow-domains-result.json'); + fs.writeFileSync(outJson, JSON.stringify(results, null, 2)); + console.log('Wrote results to', outJson); + + // Publish artifact (JSON + screenshot) and append run summary to LOG.md + try { + const { publishArtifact, appendLog } = require('./logging'); + const artifactUrl = publishArtifact(outJson, 'validate-flow-domains-local') || null; + appendLog('validate-flow-domains-local', outJson, results, artifactUrl); + } catch (e) { console.warn('Failed to publish/appenda log entry', e); } + + await browser.close(); + process.exit(0); + } catch (err) { + console.error('Error validating flow', err && err.stack ? err.stack : err); + results.error = String(err && err.stack ? err.stack : err); + results.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(outDir, 'studio-flow-domains-result.json'), JSON.stringify(results, null, 2)); + try { await browser.close(); } catch(e){} + process.exit(1); + } +})(); diff --git a/e2e/validate-flow-remote-chrome.js b/e2e/validate-flow-remote-chrome.js new file mode 100644 index 0000000..a0c24c6 --- /dev/null +++ b/e2e/validate-flow-remote-chrome.js @@ -0,0 +1,198 @@ +// e2e/validate-flow-remote-chrome.js +// Connect to a remote Chrome (remote debugging port 9222) and run the Broadcast->Studio E2E flow. +// Usage: +// CHROME_WS (optional) = full websocketDebuggerUrl +// CHROME_HOST (optional) = host:port (default port 9222) e.g. 1.2.3.4 or 1.2.3.4:9222 +// BROADCAST_URL (required) - broadcast panel URL +// STUDIO_URL (optional) +// TOKEN (optional) - token to append when forcing studio navigation + +const fs = require('fs'); +const path = require('path'); +const fetch = require('node-fetch'); +const puppeteer = require('puppeteer-core'); + +async function getWsEndpointFromHost(host) { + // host maybe '1.2.3.4' or '1.2.3.4:9222' + const url = host.includes(':') ? `http://${host}/json/version` : `http://${host}:9222/json/version`; + const res = await fetch(url, { timeout: 5000 }).catch(err => { throw new Error(`Failed to fetch ${url}: ${err.message}`); }); + if (!res.ok) throw new Error(`Failed to get json/version from ${url}: status=${res.status}`); + const json = await res.json(); + if (!json.webSocketDebuggerUrl) throw new Error(`No webSocketDebuggerUrl in ${url} response`); + return json.webSocketDebuggerUrl; +} + +(async () => { + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), console: [], navigations: [] }; + + const CHROME_WS = process.env.CHROME_WS || null; + const CHROME_HOST = process.env.CHROME_HOST || process.env.CHROME_REMOTE || null; // host[:port] + let wsEndpoint = CHROME_WS; + + try { + if (!wsEndpoint) { + if (!CHROME_HOST) throw new Error('CHROME_WS or CHROME_HOST required (host[:port] or ws url)'); + wsEndpoint = await getWsEndpointFromHost(CHROME_HOST); + } + } catch (err) { + console.error('Failed to determine Chrome websocket endpoint:', err.message || err); + process.exit(2); + } + + console.log('Using Chrome websocket endpoint:', wsEndpoint); + + const BROADCAST_URL = process.env.BROADCAST_URL || process.env.VITE_BROADCASTPANEL_URL || null; + const STUDIO_URL = process.env.STUDIO_URL || process.env.VITE_STUDIO_URL || null; + const TOKEN = process.env.TOKEN || process.env.E2E_TOKEN || ''; + + if (!BROADCAST_URL) { + console.error('BROADCAST_URL is required'); + process.exit(2); + } + + let browser; + try { + browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, ignoreHTTPSErrors: true, defaultViewport: { width: 1366, height: 768 } }); + } catch (err) { + console.error('Failed to connect to remote Chrome:', err && err.message ? err.message : err); + process.exit(2); + } + + const page = await browser.newPage(); + page.setDefaultNavigationTimeout(30000); + page.on('console', msg => { try { results.console.push({ type: msg.type(), text: msg.text() }); } catch (e) {} }); + page.on('pageerror', err => { results.console.push({ type: 'pageerror', text: String(err && err.stack ? err.stack : err) }); }); + + try { + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(1500); + + // Try to find a button/link to enter the studio + const texts = ['Entrar al estudio', 'Entrar al Studio', 'Entrar al Estudio', 'Enter studio', 'Enter the studio']; + let studioOpened = false; + + for (const t of texts) { + const els = await page.$x(`//a[contains(normalize-space(.), '${t}')] | //button[contains(normalize-space(.), '${t}')] | //span[contains(normalize-space(.), '${t}')]`); + if (els && els.length) { + console.log('Found element for text:', t, 'count=', els.length); + try { + // prepare for new target + const popupPromise = new Promise(resolve => { + const onTarget = target => resolve(target); + browser.once('targetcreated', onTarget); + setTimeout(() => { try { browser.removeListener('targetcreated', onTarget); } catch(e){}; resolve(null); }, 4000); + }); + + await els[0].click({ delay: 50 }).catch(() => null); + const popupTarget = await popupPromise; + + let studioPage = null; + if (popupTarget) { + try { studioPage = await popupTarget.page(); } catch (e) { studioPage = null; } + } + + if (!studioPage) { + await page.waitForTimeout(800); + const url = page.url(); + if (url.includes('/studio') || url.includes('studio')) studioPage = page; + } + + if (studioPage) { + console.log('Studio page found; navigating with token...'); + // Prefer STUDIO_URL if provided; otherwise use BROADCAST_URL (strip trailing slash) as entry with ?token + const broadcastBase = (BROADCAST_URL || '').replace(/\/$/, ''); + const targetStudioUrl = STUDIO_URL && STUDIO_URL.length ? `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}` : `${broadcastBase}?token=${encodeURIComponent(TOKEN)}`; + await studioPage.goto(targetStudioUrl, { waitUntil: 'networkidle2' }); + results.navigations.push({ type: 'studio_opened', url: studioPage.url() }); + await studioPage.waitForTimeout(2500); + + // --- ASSERTIONS: check sessionStorage on broadcast page and content on studio page + results.assertions = results.assertions || []; + try { + const storeKey = 'avanzacast_studio_session'; + const stored = await page.evaluate((k) => { try { return sessionStorage.getItem(k); } catch(e) { return null; } }, storeKey); + if (stored) { + try { + const parsed = JSON.parse(stored); + // Accept any token returned by backend; if TOKEN env is set we also note if it matches + if (parsed && parsed.token) { + const note = (typeof TOKEN === 'string' && TOKEN.length && String(parsed.token).includes(TOKEN.slice(0,6))) ? 'sessionStorage contains token (matches provided TOKEN prefix)' : 'sessionStorage contains token (from backend)'; + results.assertions.push({ name: 'sessionStorage_has_token', ok: true, detail: note }); + } else { + results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage present but token missing or malformed' }); + } + } catch(e) { + results.assertions.push({ name: 'sessionStorage_parse', ok: false, detail: 'sessionStorage JSON parse failed: ' + String(e) }); + } + } else { + results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage key not found' }); + } + } catch (e) { + results.assertions.push({ name: 'sessionStorage_check_failed', ok: false, detail: String(e) }); + } + + try { + const bodyText = await studioPage.evaluate(() => (document.body && document.body.innerText) ? document.body.innerText : ''); + if (bodyText.includes('token=') || (TOKEN && bodyText.includes(TOKEN.slice(0,8)))) { + results.assertions.push({ name: 'studio_page_shows_token', ok: true, detail: 'Studio page contains token or token prefix' }); + } else { + results.assertions.push({ name: 'studio_page_shows_token', ok: false, detail: 'Studio page does not show token text' }); + } + } catch (e) { + results.assertions.push({ name: 'studio_page_check_failed', ok: false, detail: String(e) }); + } + + const shot = path.join(outDir, 'validate-remote-chrome-result.png'); + await studioPage.screenshot({ path: shot, fullPage: true }); + results.screenshot = shot; + studioOpened = true; + break; + } + } catch (err) { + console.warn('Click attempt error', err && err.message); + } + } + } + + if (!studioOpened) { + // fallback: navigate directly to studio with token + if (STUDIO_URL) { + console.log('Fallback: navigating directly to STUDIO_URL with token'); + const directUrl = `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}`; + await page.goto(directUrl, { waitUntil: 'networkidle2' }); + await page.waitForTimeout(1500); + const shot = path.join(outDir, 'validate-remote-chrome-result.png'); + await page.screenshot({ path: shot, fullPage: true }); + results.screenshot = shot; + results.navigations.push({ type: 'direct_studio', url: directUrl }); + } else { + console.error('No studio opened and no STUDIO_URL provided for fallback.'); + } + } + + results.endedAt = new Date().toISOString(); + const outJson = path.join(outDir, 'validate-flow-remote-chrome-result.json'); + fs.writeFileSync(outJson, JSON.stringify(results, null, 2)); + console.log('Wrote results to', outJson); + + // append log and publish artifact + try { + const { publishArtifact, appendLog } = require('./logging'); + const artifactUrl = publishArtifact(outJson, 'validate-flow-remote-chrome') || null; + appendLog('validate-flow-remote-chrome', outJson, results, artifactUrl); + } catch (e) { console.warn('Failed to write LOG.md entry', e); } + + try { await page.close(); } catch(e){} + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(0); + } catch (err) { + console.error('Error validating flow on remote chrome', err && err.stack ? err.stack : err); + results.error = String(err && err.stack ? err.stack : err); + results.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(outDir, 'validate-flow-remote-chrome-result.json'), JSON.stringify(results, null, 2)); + try { await page.close(); } catch(e){} + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e){} } + process.exit(1); + } +})(); diff --git a/e2e/validate-session-id-flow.js b/e2e/validate-session-id-flow.js new file mode 100644 index 0000000..d11d2d4 --- /dev/null +++ b/e2e/validate-session-id-flow.js @@ -0,0 +1,69 @@ +// e2e/validate-session-id-flow.js +// 1) create a session in token-server +// 2) open broadcast panel at /:id +// 3) verify sessionStorage contains session +// 4) optionally fetch token endpoint and decode token + +const fs = require('fs'); +const path = require('path'); +const fetch = require('node-fetch'); +const puppeteer = require('puppeteer-core'); + +(async ()=>{ + const outDir = path.resolve(__dirname); + const results = { startedAt: new Date().toISOString(), steps: [], console: [] }; + const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'; + const BROADCAST_BASE = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host'; + const BROWSER_WS = process.env.BROWSER_WS || process.env.BROWSERLESS_WS; + const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || ''; + if(!BROWSER_WS){ console.error('BROWSER_WS required'); process.exit(2); } + let ws = BROWSER_WS; + if(BROWSERLESS_TOKEN && ws.includes('?')) ws = `${ws}&token=${encodeURIComponent(BROWSERLESS_TOKEN)}`; else if(BROWSERLESS_TOKEN) ws = `${ws}?token=${encodeURIComponent(BROWSERLESS_TOKEN)}`; + + try{ + // create session + const room = 'e2e_room'; + const username = 'e2e_user'; + const createResp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ room, username }) }); + const createJson = await createResp.json(); + results.steps.push({ name:'create_session', ok: createResp.ok, status:createResp.status, body:createJson }); + if(!createResp.ok) { fs.writeFileSync(path.join(outDir,'validate-session-id-flow-result.json'), JSON.stringify(results,null,2)); process.exit(1); } + const sessionId = createJson.id; + + // open browser + const browser = await puppeteer.connect({ browserWSEndpoint: ws, ignoreHTTPSErrors:true }); + const page = await browser.newPage(); + page.on('console', m=> results.console.push({ type:m.type(), text:m.text() })); + await page.goto(`${BROADCAST_BASE.replace(/\/$/, '')}/${encodeURIComponent(sessionId)}`, { waitUntil:'networkidle2' }); + await page.waitForTimeout(1500); + + // read sessionStorage in page + const storeKey = 'avanzacast_studio_session'; + const stored = await page.evaluate((k)=> { try { return sessionStorage.getItem(k); } catch(e) { return null } }, storeKey); + results.steps.push({ name:'sessionStorage', key: storeKey, value: stored ? JSON.parse(stored) : null }); + + // verify token endpoint + const tokenResp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}/token`); + const tokenJson = await tokenResp.json().catch(()=>null); + results.steps.push({ name:'get_token', ok: tokenResp.ok, status: tokenResp.status, body: tokenJson }); + + // screenshot + const shot = path.join(outDir,'validate-session-id-flow.png'); + await page.screenshot({ path: shot, fullPage:true }); + results.screenshot = shot; + + results.endedAt = new Date().toISOString(); + const outJson = path.join(outDir,'validate-session-id-flow-result.json'); + fs.writeFileSync(outJson, JSON.stringify(results,null,2)); + await page.close(); await browser.disconnect(); + console.log('Wrote', outJson); + process.exit(0); + }catch(err){ + console.error('err', err); + results.error = String(err && err.stack?err.stack:err); + results.endedAt = new Date().toISOString(); + fs.writeFileSync(path.join(outDir,'validate-session-id-flow-result.json'), JSON.stringify(results,null,2)); + process.exit(1); + } +})(); + diff --git a/e2e/ws-test.js b/e2e/ws-test.js new file mode 100644 index 0000000..198a10a --- /dev/null +++ b/e2e/ws-test.js @@ -0,0 +1,61 @@ +// e2e/ws-test.js +// Simple WebSocket handshake tester using 'ws' +// Usage: +// node e2e/ws-test.js [--insecure] +// Or set env TOKEN to attach: TOKEN=abc node e2e/ws-test.js wss://host:port + +const url = process.argv[2] || process.env.WS_URL; +const insecure = process.argv.includes('--insecure'); +const token = process.env.TOKEN || process.argv.find(a => a.startsWith('token='))?.split('=')[1]; + +if (!url) { + console.error('Usage: node e2e/ws-test.js [--insecure]'); + process.exit(2); +} + +const WebSocket = require('ws'); +const finalUrl = token && !url.includes('?') ? `${url}?token=${token}` : (token ? `${url}&token=${token}` : url); +console.log('Connecting to', finalUrl, 'insecure=', insecure); + +const opts = {}; +if (insecure) opts.rejectUnauthorized = false; + +const ws = new WebSocket(finalUrl, opts); + +let opened = false; +const timer = setTimeout(() => { + if (!opened) { + console.error('TIMEOUT waiting for open (10s)'); + ws.terminate(); + process.exit(3); + } +}, 10000); + +ws.on('open', () => { + opened = true; + clearTimeout(timer); + console.log('WS_OPEN'); + try { + const ping = JSON.stringify({ type: 'ping', ts: Date.now() }); + ws.send(ping); + } catch(e){} + // Wait for message or close within 8s + setTimeout(() => { + console.log('Closing socket after wait'); + ws.close(); + }, 8000); +}); + +ws.on('message', (data) => { + console.log('WS_MESSAGE', data.toString().slice(0,1000)); +}); + +ws.on('close', (code, reason) => { + console.log('WS_CLOSE', code, (reason || '').toString().slice(0,300)); + process.exit(0); +}); + +ws.on('error', (err) => { + console.error('WS_ERROR', err && err.message ? err.message : err); + process.exit(4); +}); diff --git a/package-lock.json b/package-lock.json index 02c53c5..3ae006b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,10 +8,20 @@ "name": "avanzacast-monorepo", "version": "1.0.0", "workspaces": [ - "packages/*", + "packages/admin-panel", + "packages/avanza-ui", + "packages/backend-api", + "packages/broadcast-panel", + "packages/e2e", + "packages/landing-page", + "packages/shared-components", + "packages/studio-panel-deprecated", + "packages/vristo-react-main", "shared/*" ], "dependencies": { + "puppeteer": "^19.11.1", + "puppeteer-core": "^24.30.0", "react-icons": "^5.5.0" }, "devDependencies": { @@ -180,10 +190,6 @@ "resolved": "shared/utils", "link": true }, - "node_modules/@avanzacast/studio-panel": { - "resolved": "packages/studio-panel", - "link": true - }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -3929,40 +3935,6 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/@joshwooding/vite-plugin-react-docgen-typescript": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@joshwooding/vite-plugin-react-docgen-typescript/-/vite-plugin-react-docgen-typescript-0.5.0.tgz", - "integrity": "sha512-qYDdL7fPwLRI+bJNurVcis+tNgJmvWjH4YTBGXTA8xMuxFrnAz6E5o35iyzyKbq5J5Lr8mJGfrR5GXl+WGwhgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^10.0.0", - "magic-string": "^0.27.0", - "react-docgen-typescript": "^2.2.2" - }, - "peerDependencies": { - "typescript": ">= 4.3.x", - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@joshwooding/vite-plugin-react-docgen-typescript/node_modules/magic-string": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", - "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -4153,24 +4125,6 @@ "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==" }, - "node_modules/@mdx-js/react": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", - "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mdx": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", @@ -4530,106 +4484,36 @@ } }, "node_modules/@puppeteer/browsers": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", - "integrity": "sha512-x4BEjr2SjOPowNeiguzjozQbsc6h437ovD/wu+JpaenxVLm3jkgzHY2xOslMTp50HoTvQreMjiexiGQw1sqZlQ==", + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.13.tgz", + "integrity": "sha512-a9Ruw3j3qlnB5a/zHRTkruppynxqaeE4H9WNj5eYGRWqw0ZauZ23f4W2ARf3hghF5doozyD+CRtt7XSYuYRI/Q==", + "license": "Apache-2.0", "dependencies": { - "debug": "4.3.4", - "extract-zip": "2.0.1", - "progress": "2.0.3", - "proxy-agent": "6.3.0", - "tar-fs": "3.0.4", - "unbzip2-stream": "1.4.3", - "yargs": "17.7.1" + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" }, "engines": { - "node": ">=16.3.0" - }, - "peerDependencies": { - "typescript": ">= 4.7.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=18" } }, - "node_modules/@puppeteer/browsers/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" + "node_modules/@puppeteer/browsers/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/@puppeteer/browsers/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/@puppeteer/browsers/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/@puppeteer/browsers/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@puppeteer/browsers/node_modules/yargs": { - "version": "17.7.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", - "integrity": "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" + "node": ">=10" } }, "node_modules/@radix-ui/number": { @@ -5596,947 +5480,6 @@ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" }, - "node_modules/@storybook/addon-actions": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-8.6.14.tgz", - "integrity": "sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@types/uuid": "^9.0.1", - "dequal": "^2.0.2", - "polished": "^4.2.2", - "uuid": "^9.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-actions/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/@storybook/addon-backgrounds": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-backgrounds/-/addon-backgrounds-8.6.14.tgz", - "integrity": "sha512-l9xS8qWe5n4tvMwth09QxH2PmJbCctEvBAc1tjjRasAfrd69f7/uFK4WhwJAstzBTNgTc8VXI4w8ZR97i1sFbg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "memoizerific": "^1.11.3", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-controls": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-controls/-/addon-controls-8.6.14.tgz", - "integrity": "sha512-IiQpkNJdiRyA4Mq9mzjZlvQugL/aE7hNgVxBBGPiIZG6wb6Ht9hNnBYpap5ZXXFKV9p2qVI0FZK445ONmAa+Cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "dequal": "^2.0.2", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-docs": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-docs/-/addon-docs-8.6.14.tgz", - "integrity": "sha512-Obpd0OhAF99JyU5pp5ci17YmpcQtMNgqW2pTXV8jAiiipWpwO++hNDeQmLmlSXB399XjtRDOcDVkoc7rc6JzdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@mdx-js/react": "^3.0.0", - "@storybook/blocks": "8.6.14", - "@storybook/csf-plugin": "8.6.14", - "@storybook/react-dom-shim": "8.6.14", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-essentials": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-essentials/-/addon-essentials-8.6.14.tgz", - "integrity": "sha512-5ZZSHNaW9mXMOFkoPyc3QkoNGdJHETZydI62/OASR0lmPlJ1065TNigEo5dJddmZNn0/3bkE8eKMAzLnO5eIdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/addon-actions": "8.6.14", - "@storybook/addon-backgrounds": "8.6.14", - "@storybook/addon-controls": "8.6.14", - "@storybook/addon-docs": "8.6.14", - "@storybook/addon-highlight": "8.6.14", - "@storybook/addon-measure": "8.6.14", - "@storybook/addon-outline": "8.6.14", - "@storybook/addon-toolbars": "8.6.14", - "@storybook/addon-viewport": "8.6.14", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-highlight": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-highlight/-/addon-highlight-8.6.14.tgz", - "integrity": "sha512-4H19OJlapkofiE9tM6K/vsepf4ir9jMm9T+zw5L85blJZxhKZIbJ6FO0TCG9PDc4iPt3L6+aq5B0X29s9zicNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-interactions": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-interactions/-/addon-interactions-8.6.14.tgz", - "integrity": "sha512-8VmElhm2XOjh22l/dO4UmXxNOolGhNiSpBcls2pqWSraVh4a670EyYBZsHpkXqfNHo2YgKyZN3C91+9zfH79qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.6.14", - "@storybook/test": "8.6.14", - "polished": "^4.2.2", - "ts-dedent": "^2.2.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-links": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-links/-/addon-links-8.6.14.tgz", - "integrity": "sha512-DRlXHIyZzOruAZkxmXfVgTF+4d6K27pFcH4cUsm3KT1AXuZbr23lb5iZHpUZoG6lmU85Sru4xCEgewSTXBIe1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.14" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - } - } - }, - "node_modules/@storybook/addon-measure": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-measure/-/addon-measure-8.6.14.tgz", - "integrity": "sha512-1Tlyb72NX8aAqm6I6OICsUuGOP6hgnXcuFlXucyhKomPa6j3Eu2vKu561t/f0oGtAK2nO93Z70kVaEh5X+vaGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "tiny-invariant": "^1.3.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-outline": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-outline/-/addon-outline-8.6.14.tgz", - "integrity": "sha512-CW857JvN6OxGWElqjlzJO2S69DHf+xO3WsEfT5mT3ZtIjmsvRDukdWfDU9bIYUFyA2lFvYjncBGjbK+I91XR7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-toolbars": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-toolbars/-/addon-toolbars-8.6.14.tgz", - "integrity": "sha512-W/wEXT8h3VyZTVfWK/84BAcjAxTdtRiAkT2KAN0nbSHxxB5KEM1MjKpKu2upyzzMa3EywITqbfy4dP6lpkVTwQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/addon-viewport": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/addon-viewport/-/addon-viewport-8.6.14.tgz", - "integrity": "sha512-gNzVQbMqRC+/4uQTPI2ZrWuRHGquTMZpdgB9DrD88VTEjNudP+J6r8myLfr2VvGksBbUMHkGHMXHuIhrBEnXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "memoizerific": "^1.11.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/blocks": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/blocks/-/blocks-8.6.14.tgz", - "integrity": "sha512-rBMHAfA39AGHgkrDze4RmsnQTMw1ND5fGWobr9pDcJdnDKWQWNRD7Nrlxj0gFlN3n4D9lEZhWGdFrCbku7FVAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/icons": "^1.2.12", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "storybook": "^8.6.14" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/@storybook/builder-vite": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/builder-vite/-/builder-vite-8.6.14.tgz", - "integrity": "sha512-ajWYhy32ksBWxwWHrjwZzyC0Ii5ZTeu5lsqA95Q/EQBB0P5qWlHWGM3AVyv82Mz/ND03ebGy123uVwgf6olnYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/csf-plugin": "8.6.14", - "browser-assert": "^1.2.1", - "ts-dedent": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14", - "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" - } - }, - "node_modules/@storybook/components": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/components/-/components-8.6.14.tgz", - "integrity": "sha512-HNR2mC5I4Z5ek8kTrVZlIY/B8gJGs5b3XdZPBPBopTIN6U/YHXiDyOjY3JlaS4fSG1fVhp/Qp1TpMn1w/9m1pw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" - } - }, - "node_modules/@storybook/core": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/core/-/core-8.6.14.tgz", - "integrity": "sha512-1P/w4FSNRqP8j3JQBOi3yGt8PVOgSRbP66Ok520T78eJBeqx9ukCfl912PQZ7SPbW3TIunBwLXMZOjZwBB/JmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/theming": "8.6.14", - "better-opn": "^3.0.2", - "browser-assert": "^1.2.1", - "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0", - "esbuild-register": "^3.5.0", - "jsdoc-type-pratt-parser": "^4.0.0", - "process": "^0.11.10", - "recast": "^0.23.5", - "semver": "^7.6.2", - "util": "^0.12.5", - "ws": "^8.2.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "prettier": "^2 || ^3" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, - "node_modules/@storybook/core/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@storybook/csf-plugin": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/csf-plugin/-/csf-plugin-8.6.14.tgz", - "integrity": "sha512-dErtc9teAuN+eelN8FojzFE635xlq9cNGGGEu0WEmMUQ4iJ8pingvBO1N8X3scz4Ry7KnxX++NNf3J3gpxS8qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "unplugin": "^1.3.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/global": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@storybook/global/-/global-5.0.0.tgz", - "integrity": "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/icons": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@storybook/icons/-/icons-1.6.0.tgz", - "integrity": "sha512-hcFZIjW8yQz8O8//2WTIXylm5Xsgc+lW9ISLgUk1xGmptIJQRdlhVIXCpSyLrQaaRiyhQRaVg7l3BD9S216BHw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta" - } - }, - "node_modules/@storybook/instrumenter": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/instrumenter/-/instrumenter-8.6.14.tgz", - "integrity": "sha512-iG4MlWCcz1L7Yu8AwgsnfVAmMbvyRSk700Mfy2g4c8y5O+Cv1ejshE1LBBsCwHgkuqU0H4R0qu4g23+6UnUemQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@vitest/utils": "^2.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/instrumenter/node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@storybook/instrumenter/node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/manager-api": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/manager-api/-/manager-api-8.6.14.tgz", - "integrity": "sha512-ez0Zihuy17udLbfHZQXkGqwtep0mSGgHcNzGN7iZrMP1m+VmNo+7aGCJJdvXi7+iU3yq8weXSQFWg5DqWgLS7g==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" - } - }, - "node_modules/@storybook/preview-api": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/preview-api/-/preview-api-8.6.14.tgz", - "integrity": "sha512-2GhcCd4dNMrnD7eooEfvbfL4I83qAqEyO0CO7JQAmIO6Rxb9BsOLLI/GD5HkvQB73ArTJ+PT50rfaO820IExOQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" - } - }, - "node_modules/@storybook/react": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/react/-/react-8.6.14.tgz", - "integrity": "sha512-BOepx5bBFwl/CPI+F+LnmMmsG1wQYmrX/UQXgUbHQUU9Tj7E2ndTnNbpIuSLc8IrM03ru+DfwSg1Co3cxWtT+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/components": "8.6.14", - "@storybook/global": "^5.0.0", - "@storybook/manager-api": "8.6.14", - "@storybook/preview-api": "8.6.14", - "@storybook/react-dom-shim": "8.6.14", - "@storybook/theming": "8.6.14" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@storybook/test": "8.6.14", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.14", - "typescript": ">= 4.2.x" - }, - "peerDependenciesMeta": { - "@storybook/test": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/@storybook/react-dom-shim": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/react-dom-shim/-/react-dom-shim-8.6.14.tgz", - "integrity": "sha512-0hixr3dOy3f3M+HBofp3jtMQMS+sqzjKNgl7Arfuj3fvjmyXOks/yGjDImySR4imPtEllvPZfhiQNlejheaInw==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/react-vite": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/react-vite/-/react-vite-8.6.14.tgz", - "integrity": "sha512-FZU0xMPxa4/TO87FgcWwappOxLBHZV5HSRK5K+2bJD7rFJAoNorbHvB4Q1zvIAk7eCMjkr2GPCPHx9PRB9vJFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@joshwooding/vite-plugin-react-docgen-typescript": "0.5.0", - "@rollup/pluginutils": "^5.0.2", - "@storybook/builder-vite": "8.6.14", - "@storybook/react": "8.6.14", - "find-up": "^5.0.0", - "magic-string": "^0.30.0", - "react-docgen": "^7.0.0", - "resolve": "^1.22.8", - "tsconfig-paths": "^4.2.0" - }, - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "@storybook/test": "8.6.14", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta", - "storybook": "^8.6.14", - "vite": "^4.0.0 || ^5.0.0 || ^6.0.0" - }, - "peerDependenciesMeta": { - "@storybook/test": { - "optional": true - } - } - }, - "node_modules/@storybook/react-vite/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react-vite/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react-vite/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react-vite/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/react-vite/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/@storybook/react-vite/node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "json5": "^2.2.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@storybook/react-vite/node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@storybook/test": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/test/-/test-8.6.14.tgz", - "integrity": "sha512-GkPNBbbZmz+XRdrhMtkxPotCLOQ1BaGNp/gFZYdGDk2KmUWBKmvc5JxxOhtoXM2703IzNFlQHSSNnhrDZYuLlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/global": "^5.0.0", - "@storybook/instrumenter": "8.6.14", - "@testing-library/dom": "10.4.0", - "@testing-library/jest-dom": "6.5.0", - "@testing-library/user-event": "14.5.2", - "@vitest/expect": "2.0.5", - "@vitest/spy": "2.0.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.6.14" - } - }, - "node_modules/@storybook/test/node_modules/@testing-library/dom": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@storybook/test/node_modules/@testing-library/jest-dom": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz", - "integrity": "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "chalk": "^3.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "lodash": "^4.17.21", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@storybook/test/node_modules/@testing-library/jest-dom/node_modules/chalk": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", - "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/test/node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/test/node_modules/@testing-library/user-event": { - "version": "14.5.2", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", - "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@storybook/test/node_modules/@vitest/expect": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz", - "integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.0.5", - "@vitest/utils": "2.0.5", - "chai": "^5.1.1", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@storybook/test/node_modules/@vitest/pretty-format": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz", - "integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@storybook/test/node_modules/@vitest/spy": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz", - "integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@storybook/test/node_modules/@vitest/utils": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz", - "integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.0.5", - "estree-walker": "^3.0.3", - "loupe": "^3.1.1", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@storybook/test/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@storybook/test/node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/@storybook/test/node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/@storybook/test/node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@storybook/test/node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/@storybook/test/node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/@storybook/test/node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@storybook/test/node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/@storybook/test/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@storybook/test/node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@storybook/theming": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/@storybook/theming/-/theming-8.6.14.tgz", - "integrity": "sha512-r4y+LsiB37V5hzpQo+BM10PaCsp7YlZ0YcZzQP1OCkPlYXmUAFy2VvDKaFRpD8IeNPKug2u4iFm/laDEbs03dg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "storybook": "^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0" - } - }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -7038,301 +5981,6 @@ "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1" } }, - "node_modules/@tailwindcss/node": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz", - "integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.17" - } - }, - "node_modules/@tailwindcss/node/node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/@tailwindcss/node/node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz", - "integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-arm64": "4.1.17", - "@tailwindcss/oxide-darwin-x64": "4.1.17", - "@tailwindcss/oxide-freebsd-x64": "4.1.17", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.17", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.17", - "@tailwindcss/oxide-linux-x64-musl": "4.1.17", - "@tailwindcss/oxide-wasm32-wasi": "4.1.17", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.17", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.17" - } - }, - "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz", - "integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz", - "integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz", - "integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz", - "integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz", - "integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz", - "integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz", - "integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz", - "integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz", - "integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz", - "integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==", - "bundleDependencies": [ - "@napi-rs/wasm-runtime", - "@emnapi/core", - "@emnapi/runtime", - "@tybys/wasm-util", - "@emnapi/wasi-threads", - "tslib" - ], - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.6.0", - "@emnapi/runtime": "^1.6.0", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.0.7", - "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz", - "integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz", - "integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz", - "integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.17", - "@tailwindcss/oxide": "4.1.17", - "postcss": "^8.4.41", - "tailwindcss": "4.1.17" - } - }, - "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@tailwindcss/typography": { "version": "0.5.9", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.9.tgz", @@ -7418,96 +6066,6 @@ "deep-equal": "^2.0.5" } }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "14.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", - "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^9.0.0", - "@types/react-dom": "^18.0.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, - "node_modules/@testing-library/react/node_modules/@testing-library/dom": { - "version": "9.3.4", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", - "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@testing-library/react/node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "deep-equal": "^2.0.5" - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", - "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, "node_modules/@tippyjs/react": { "version": "4.2.6", "resolved": "https://registry.npmjs.org/@tippyjs/react/-/react-4.2.6.tgz", @@ -7532,7 +6090,8 @@ "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", - "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==" + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "license": "MIT" }, "node_modules/@trysound/sax": { "version": "0.2.0", @@ -7703,13 +6262,6 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, - "node_modules/@types/doctrine": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@types/doctrine/-/doctrine-0.0.9.tgz", - "integrity": "sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -7867,13 +6419,6 @@ "integrity": "sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==", "license": "MIT" }, - "node_modules/@types/mdx": { - "version": "2.0.13", - "resolved": "https://registry.npmjs.org/@types/mdx/-/mdx-2.0.13.tgz", - "integrity": "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -8132,13 +6677,6 @@ "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "9.0.8", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", - "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -8476,19 +7014,6 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/@vitest/runner": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", @@ -9441,6 +7966,7 @@ "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "license": "MIT", "dependencies": { "tslib": "^2.0.1" }, @@ -9824,9 +8350,10 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/bare-events": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.1.tgz", - "integrity": "sha512-oxSAxTS1hRfnyit2CL5QpAOS5ixfBjj6ex3yTNvXyY/kE719jQ/IjuESJBK2w5v4wwQRAHGseVJXx9QBYOtFGQ==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz", + "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==", + "license": "Apache-2.0", "peerDependencies": { "bare-abort-controller": "*" }, @@ -9836,6 +8363,83 @@ } } }, + "node_modules/bare-fs": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.1.tgz", + "integrity": "sha512-zGUCsm3yv/ePt2PHNbVxjjn0nNB1MkIaR4wOCxJ2ig5pCf5cCVAYJXVhQg/3OhhJV6DB1ts7Hv0oUaElc2TPQg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", + "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -9875,6 +8479,7 @@ "version": "5.0.5", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "license": "MIT", "engines": { "node": ">=10.0.0" } @@ -9890,37 +8495,6 @@ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" }, - "node_modules/better-opn": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", - "integrity": "sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "open": "^8.0.4" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/better-opn/node_modules/open": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", - "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-lazy-prop": "^2.0.0", - "is-docker": "^2.1.1", - "is-wsl": "^2.2.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bfj": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/bfj/-/bfj-7.1.0.tgz", @@ -9992,6 +8566,17 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -10072,12 +8657,6 @@ "resolved": "packages/broadcast-panel", "link": true }, - "node_modules/browser-assert": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/browser-assert/-/browser-assert-1.2.1.tgz", - "integrity": "sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==", - "dev": true - }, "node_modules/browser-process-hrtime": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", @@ -10704,16 +9283,24 @@ } }, "node_modules/chromium-bidi": { - "version": "0.4.16", - "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.16.tgz", - "integrity": "sha512-7ZbXdWERxRxSwo3txsBjjmc/NLxqb1Bk30mRb0BMS4YIaiV6zvKZqL/UAH+DdqcDYayDWk2n/y8klkBDODrPvA==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-11.0.0.tgz", + "integrity": "sha512-cM3DI+OOb89T3wO8cpPSro80Q9eKYJ7hGVXoGS3GkDPxnYSqiv+6xwpIf6XERyJ9Tdsl09hmNmY94BkgZdVekw==", + "license": "Apache-2.0", "dependencies": { - "mitt": "3.0.0" + "mitt": "^3.0.1", + "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, + "node_modules/chromium-bidi/node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/ci-info": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", @@ -11379,6 +9966,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "license": "MIT", "dependencies": { "node-fetch": "^2.6.12" } @@ -11900,6 +10488,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "license": "MIT", "engines": { "node": ">= 14" } @@ -12272,6 +10861,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "license": "MIT", "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", @@ -12310,16 +10900,6 @@ "node": ">= 0.8" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -12335,6 +10915,8 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -12387,9 +10969,10 @@ "license": "MIT" }, "node_modules/devtools-protocol": { - "version": "0.0.1147663", - "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1147663.tgz", - "integrity": "sha512-hyWmRrexdhbZ1tcJUGpO95ivbRhWXz++F4Ko+n21AY5PNln2ovoJw+8ZMNDTtip+CNFQfrtLVh/w4009dXO/eQ==" + "version": "0.0.1521046", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", + "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", + "license": "BSD-3-Clause" }, "node_modules/didyoumean": { "version": "1.2.2", @@ -13123,19 +11706,6 @@ "@esbuild/win32-x64": "0.18.20" } }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -13912,6 +12482,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "license": "Apache-2.0", "dependencies": { "bare-events": "^2.7.0" } @@ -14120,7 +12691,8 @@ "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==" + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" }, "node_modules/fast-glob": { "version": "3.3.3", @@ -14230,30 +12802,6 @@ "resolved": "https://registry.npmjs.org/fecha/-/fecha-4.2.3.tgz", "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==" }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -14774,19 +13322,6 @@ "node": ">= 6" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/formik": { "version": "2.4.9", "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", @@ -14850,6 +13385,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -15222,6 +13763,7 @@ "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "license": "MIT", "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", @@ -18244,16 +16786,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsdoc-type-pratt-parser": { - "version": "4.8.0", - "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.8.0.tgz", - "integrity": "sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/jsdom": { "version": "16.7.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", @@ -18689,6 +17221,8 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "dev": true, "license": "MPL-2.0", + "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -19008,9 +17542,10 @@ } }, "node_modules/livekit-server-sdk": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.14.0.tgz", - "integrity": "sha512-7lZBkiVOOnPIYz6XyQ9teVxlkLQVve7JFuiYgLkYQCLZQLSZPjIboqP1ZocbLbPx4ijceYwVfOZHktF0YbfvVw==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/livekit-server-sdk/-/livekit-server-sdk-2.14.1.tgz", + "integrity": "sha512-kdpNXKJXps+5jzN4SmGN1w3TVSSDlS45c99R73oqz69EAlApiRT7AeEd3hAn0j2VOCFQ4tr8tegxnL+NbPA/WQ==", + "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^1.10.1", "@livekit/protocol": "^1.42.0", @@ -19363,13 +17898,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/map-or-similar": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/map-or-similar/-/map-or-similar-1.5.0.tgz", - "integrity": "sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==", - "dev": true, - "license": "MIT" - }, "node_modules/marked": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-4.3.0.tgz", @@ -19421,16 +17949,6 @@ "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "license": "MIT" }, - "node_modules/memoizerific": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/memoizerific/-/memoizerific-1.11.3.tgz", - "integrity": "sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==", - "dev": true, - "license": "MIT", - "dependencies": { - "map-or-similar": "^1.5.0" - } - }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -19914,6 +18432,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", "engines": { "node": ">= 0.4.0" } @@ -19928,31 +18447,11 @@ "tslib": "^2.0.3" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", "dependencies": { "whatwg-url": "^5.0.0" }, @@ -20594,6 +19093,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "license": "MIT", "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", @@ -20612,6 +19112,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", "engines": { "node": ">= 14" } @@ -20620,6 +19121,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -20632,6 +19134,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -20644,6 +19147,7 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -20657,6 +19161,7 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "license": "MIT", "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" @@ -21077,19 +19582,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/polished": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/polished/-/polished-4.3.1.tgz", - "integrity": "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.17.8" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -22613,16 +21105,6 @@ "fsevents": "2.3.3" } }, - "node_modules/process": { - "version": "0.11.10", - "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", - "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -22719,18 +21201,19 @@ } }, "node_modules/proxy-agent": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.0.tgz", - "integrity": "sha512-0LdR757eTj/JfuU7TL2YCuAZnxWXu3tkJbg4Oq3geW/qFNT/32T0sp2HnZ9O0lMR4q3vwAt0+xCA8SR0WAD0og==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "^4.3.4", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", - "pac-proxy-agent": "^7.0.0", + "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", - "socks-proxy-agent": "^8.0.1" + "socks-proxy-agent": "^8.0.5" }, "engines": { "node": ">= 14" @@ -22740,6 +21223,7 @@ "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", "engines": { "node": ">= 14" } @@ -22748,6 +21232,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -22760,6 +21245,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -22772,6 +21258,7 @@ "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", "engines": { "node": ">=12" } @@ -22780,6 +21267,7 @@ "version": "8.0.5", "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", @@ -22824,55 +21312,45 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "19.11.1", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-19.11.1.tgz", + "integrity": "sha512-39olGaX2djYUdhaQQHDZ0T0GwEp+5f9UB9HmEP0qHfdQHIq0xGQZuAZ5TLnJIc/88SrPLpEflPC+xUqOTv3c5g==", + "deprecated": "< 24.15.0 is no longer supported", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "0.5.0", + "cosmiconfig": "8.1.3", + "https-proxy-agent": "5.0.1", + "progress": "2.0.3", + "proxy-from-env": "1.1.0", + "puppeteer-core": "19.11.1" + } + }, "node_modules/puppeteer-core": { - "version": "20.9.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-20.9.0.tgz", - "integrity": "sha512-H9fYZQzMTRrkboEfPmf7m3CLDN6JvbxXA3qTtS+dFt27tR+CsFHzPsT6pzp6lYL6bJbAPaR0HaPO6uSi+F94Pg==", + "version": "24.30.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz", + "integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==", + "license": "Apache-2.0", "dependencies": { - "@puppeteer/browsers": "1.4.6", - "chromium-bidi": "0.4.16", - "cross-fetch": "4.0.0", - "debug": "4.3.4", - "devtools-protocol": "0.0.1147663", - "ws": "8.13.0" + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "11.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1521046", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.8", + "ws": "^8.18.3" }, "engines": { - "node": ">=16.3.0" - }, - "peerDependencies": { - "typescript": ">= 4.7.4" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "node": ">=18" } }, - "node_modules/puppeteer-core/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/puppeteer-core/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/puppeteer-core/node_modules/ws": { - "version": "8.13.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", - "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -22889,6 +21367,286 @@ } } }, + "node_modules/puppeteer/node_modules/@puppeteer/browsers": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-0.5.0.tgz", + "integrity": "sha512-Uw6oB7VvmPRLE4iKsjuOh8zgDabhNX67dzo8U/BB0f9527qx+4eeUs+korU98OhG5C4ubg7ufBgVi63XYwS6TQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.1", + "progress": "2.0.3", + "proxy-from-env": "1.1.0", + "tar-fs": "2.1.1", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.1" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=14.1.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/puppeteer/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/puppeteer/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/puppeteer/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/puppeteer/node_modules/chromium-bidi": { + "version": "0.4.7", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.7.tgz", + "integrity": "sha512-6+mJuFXwTMU6I3vYLs6IL8A1DyQTPjCfIL971X0aMPVGRbGnNfl6i6Cl0NMbxi2bRYLGESt9T2ZIMRM5PAEcIQ==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.3.tgz", + "integrity": "sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "parse-json": "^5.0.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + } + }, + "node_modules/puppeteer/node_modules/cross-fetch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz", + "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==", + "license": "MIT", + "dependencies": { + "node-fetch": "2.6.7" + } + }, + "node_modules/puppeteer/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/puppeteer/node_modules/devtools-protocol": { + "version": "0.0.1107588", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1107588.tgz", + "integrity": "sha512-yIR+pG9x65Xko7bErCUSQaDLrO/P1p3JUzEk7JCU4DowPcGHkTGUGQapcfcLc4qj0UaALwZ+cr0riFgiqpixcg==", + "license": "BSD-3-Clause" + }, + "node_modules/puppeteer/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/puppeteer/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/puppeteer/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "node_modules/puppeteer/node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/puppeteer/node_modules/puppeteer-core": { + "version": "19.11.1", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-19.11.1.tgz", + "integrity": "sha512-qcuC2Uf0Fwdj9wNtaTZ2OvYRraXpAK+puwwVW8ofOhOgLPZyz1c68tsorfIZyCUOpyBisjr+xByu7BMbEYMepA==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "0.5.0", + "chromium-bidi": "0.4.7", + "cross-fetch": "3.1.5", + "debug": "4.3.4", + "devtools-protocol": "0.0.1107588", + "extract-zip": "2.0.1", + "https-proxy-agent": "5.0.1", + "proxy-from-env": "1.1.0", + "tar-fs": "2.1.1", + "unbzip2-stream": "1.4.3", + "ws": "8.13.0" + }, + "engines": { + "node": ">=14.14.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/puppeteer/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/puppeteer/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/puppeteer/node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/puppeteer/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/puppeteer/node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/puppeteer/node_modules/yargs": { + "version": "17.7.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", + "integrity": "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/q": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", @@ -23907,51 +22665,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-docgen": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.1.tgz", - "integrity": "sha512-hlSJDQ2synMPKFZOsKo9Hi8WWZTC7POR8EmWvTSjow+VDgKzkmjQvFm2fk0tmRw+f0vTOIYKlarR0iL4996pdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.18.9", - "@babel/traverse": "^7.18.9", - "@babel/types": "^7.18.9", - "@types/babel__core": "^7.18.0", - "@types/babel__traverse": "^7.18.0", - "@types/doctrine": "^0.0.9", - "@types/resolve": "^1.20.2", - "doctrine": "^3.0.0", - "resolve": "^1.22.1", - "strip-indent": "^4.0.0" - }, - "engines": { - "node": ">=16.14.0" - } - }, - "node_modules/react-docgen-typescript": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/react-docgen-typescript/-/react-docgen-typescript-2.4.0.tgz", - "integrity": "sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typescript": ">= 4.3.x" - } - }, - "node_modules/react-docgen/node_modules/strip-indent": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-4.1.1.tgz", - "integrity": "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -24466,46 +23179,6 @@ "node": ">=8.10.0" } }, - "node_modules/recast": { - "version": "0.23.11", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", - "integrity": "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ast-types": "^0.16.1", - "esprima": "~4.0.0", - "source-map": "~0.6.1", - "tiny-invariant": "^1.3.3", - "tslib": "^2.0.1" - }, - "engines": { - "node": ">= 4" - } - }, - "node_modules/recast/node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.1" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/recast/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", @@ -26378,37 +25051,11 @@ "node": ">= 0.4" } }, - "node_modules/storybook": { - "version": "8.6.14", - "resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.14.tgz", - "integrity": "sha512-sVKbCj/OTx67jhmauhxc2dcr1P+yOgz/x3h0krwjyMgdc5Oubvxyg4NYDZmzAw+ym36g/lzH8N0Ccp4dwtdfxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@storybook/core": "8.6.14" - }, - "bin": { - "getstorybook": "bin/index.cjs", - "sb": "bin/index.cjs", - "storybook": "bin/index.cjs" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/storybook" - }, - "peerDependencies": { - "prettier": "^2 || ^3" - }, - "peerDependenciesMeta": { - "prettier": { - "optional": true - } - } - }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "license": "MIT", "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", @@ -27362,19 +26009,24 @@ } }, "node_modules/tar-fs": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", - "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "license": "MIT", "dependencies": { - "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" } }, "node_modules/tar-stream": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", @@ -27385,6 +26037,7 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" }, @@ -27581,6 +26234,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", "dependencies": { "b4a": "^1.6.4" } @@ -27589,6 +26243,7 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "license": "Apache-2.0", "peerDependencies": { "react-native-b4a": "*" }, @@ -27736,16 +26391,6 @@ "node": ">=14.0.0" } }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/tinyspy": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", @@ -27867,16 +26512,6 @@ "resolved": "https://registry.npmjs.org/ts-debounce/-/ts-debounce-4.0.0.tgz", "integrity": "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==" }, - "node_modules/ts-dedent": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ts-dedent/-/ts-dedent-2.2.0.tgz", - "integrity": "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.10" - } - }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -28517,6 +27152,12 @@ "rxjs": "*" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "license": "MIT" + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -28695,20 +27336,6 @@ "node": ">= 0.8" } }, - "node_modules/unplugin": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", - "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.14.0", - "webpack-virtual-modules": "^0.6.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/unquote": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/unquote/-/unquote-1.1.1.tgz", @@ -28888,20 +27515,6 @@ "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -30908,22 +29521,18 @@ "minimalistic-assert": "^1.0.0" } }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/web-vitals": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", "integrity": "sha512-sVWcwhU5mX6crfI5Vd2dC4qchyTqxV8URinzt25XqVh+bHEPGH4C3NPrNionCP7Obx59wrYEbNlw4Z8sjALzZg==", "license": "Apache-2.0" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz", + "integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -31191,13 +29800,6 @@ "node": ">=10.13.0" } }, - "node_modules/webpack-virtual-modules": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", - "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", - "dev": true, - "license": "MIT" - }, "node_modules/webpack/node_modules/eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -32098,7 +30700,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -32124,7 +30725,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -32132,14 +30732,12 @@ "node_modules/yargs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/yargs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -32153,7 +30751,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -32201,6 +30798,15 @@ "node": ">=10" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "4.5.7", "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", @@ -32639,13 +31245,13 @@ "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", - "express": "^4.18.2", + "express": "^4.21.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "ioredis": "^5.3.2", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", - "livekit-server-sdk": "^2.14.0", + "livekit-server-sdk": "^2.14.1", "socket.io": "^4.6.2", "stripe": "^14.9.0", "winston": "^3.11.0" @@ -32666,7 +31272,11 @@ "dependencies": { "@livekit/components-react": "^2.9.15", "@livekit/components-styles": "^1.1.6", + "avanza-ui": "file:../avanza-ui", "livekit-client": "^2.15.14", + "node-fetch": "^2.7.0", + "puppeteer": "^24.30.0", + "puppeteer-core": "^20.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.5.0" @@ -32676,6 +31286,7 @@ "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.14", + "playwright": "^1.51.0", "postcss": "^8.4.24", "tailwindcss": "^4.1.0", "typescript": "^5.0.2", @@ -33056,6 +31667,62 @@ "node": ">=18" } }, + "packages/broadcast-panel/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "packages/broadcast-panel/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/broadcast-panel/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "packages/broadcast-panel/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/broadcast-panel/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "packages/broadcast-panel/node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -33116,6 +31783,59 @@ } } }, + "packages/broadcast-panel/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/broadcast-panel/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/broadcast-panel/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "packages/broadcast-panel/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "packages/broadcast-panel/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, "packages/broadcast-panel/node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", @@ -33129,6 +31849,174 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "packages/broadcast-panel/node_modules/proxy-agent": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.0.tgz", + "integrity": "sha512-0LdR757eTj/JfuU7TL2YCuAZnxWXu3tkJbg4Oq3geW/qFNT/32T0sp2HnZ9O0lMR4q3vwAt0+xCA8SR0WAD0og==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/broadcast-panel/node_modules/puppeteer": { + "version": "24.30.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.30.0.tgz", + "integrity": "sha512-A5OtCi9WpiXBQgJ2vQiZHSyrAzQmO/WDsvghqlN4kgw21PhxA5knHUaUQq/N3EMt8CcvSS0RM+kmYLJmedR3TQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "11.0.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1521046", + "puppeteer-core": "24.30.0", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "packages/broadcast-panel/node_modules/puppeteer-core": { + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-20.9.0.tgz", + "integrity": "sha512-H9fYZQzMTRrkboEfPmf7m3CLDN6JvbxXA3qTtS+dFt27tR+CsFHzPsT6pzp6lYL6bJbAPaR0HaPO6uSi+F94Pg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "1.4.6", + "chromium-bidi": "0.4.16", + "cross-fetch": "4.0.0", + "debug": "4.3.4", + "devtools-protocol": "0.0.1147663", + "ws": "8.13.0" + }, + "engines": { + "node": ">=16.3.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/broadcast-panel/node_modules/puppeteer-core/node_modules/@puppeteer/browsers": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", + "integrity": "sha512-x4BEjr2SjOPowNeiguzjozQbsc6h437ovD/wu+JpaenxVLm3jkgzHY2xOslMTp50HoTvQreMjiexiGQw1sqZlQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "progress": "2.0.3", + "proxy-agent": "6.3.0", + "tar-fs": "3.0.4", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.1" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=16.3.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/broadcast-panel/node_modules/puppeteer-core/node_modules/chromium-bidi": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.16.tgz", + "integrity": "sha512-7ZbXdWERxRxSwo3txsBjjmc/NLxqb1Bk30mRb0BMS4YIaiV6zvKZqL/UAH+DdqcDYayDWk2n/y8klkBDODrPvA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "packages/broadcast-panel/node_modules/puppeteer-core/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "packages/broadcast-panel/node_modules/puppeteer-core/node_modules/devtools-protocol": { + "version": "0.0.1147663", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1147663.tgz", + "integrity": "sha512-hyWmRrexdhbZ1tcJUGpO95ivbRhWXz++F4Ko+n21AY5PNln2ovoJw+8ZMNDTtip+CNFQfrtLVh/w4009dXO/eQ==", + "license": "BSD-3-Clause" + }, + "packages/broadcast-panel/node_modules/puppeteer/node_modules/puppeteer-core": { + "version": "24.30.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz", + "integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.13", + "chromium-bidi": "11.0.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1521046", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.8", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "packages/broadcast-panel/node_modules/puppeteer/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "packages/broadcast-panel/node_modules/rollup": { "version": "4.52.5", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", @@ -33171,12 +32059,63 @@ "fsevents": "~2.3.2" } }, + "packages/broadcast-panel/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/broadcast-panel/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/broadcast-panel/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "packages/broadcast-panel/node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", "dev": true }, + "packages/broadcast-panel/node_modules/tar-fs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "license": "MIT", + "dependencies": { + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, "packages/broadcast-panel/node_modules/vite": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.0.tgz", @@ -33252,6 +32191,45 @@ } } }, + "packages/broadcast-panel/node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "packages/broadcast-panel/node_modules/yargs": { + "version": "17.7.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", + "integrity": "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "packages/broadcast-studio": { "name": "@avanzacast/broadcast-studio", "version": "1.0.0", @@ -33286,6 +32264,269 @@ "@playwright/test": "^1.51.0" } }, + "packages/e2e/node_modules/@puppeteer/browsers": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-1.4.6.tgz", + "integrity": "sha512-x4BEjr2SjOPowNeiguzjozQbsc6h437ovD/wu+JpaenxVLm3jkgzHY2xOslMTp50HoTvQreMjiexiGQw1sqZlQ==", + "license": "Apache-2.0", + "dependencies": { + "debug": "4.3.4", + "extract-zip": "2.0.1", + "progress": "2.0.3", + "proxy-agent": "6.3.0", + "tar-fs": "3.0.4", + "unbzip2-stream": "1.4.3", + "yargs": "17.7.1" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=16.3.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/e2e/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "packages/e2e/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "packages/e2e/node_modules/chromium-bidi": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.16.tgz", + "integrity": "sha512-7ZbXdWERxRxSwo3txsBjjmc/NLxqb1Bk30mRb0BMS4YIaiV6zvKZqL/UAH+DdqcDYayDWk2n/y8klkBDODrPvA==", + "license": "Apache-2.0", + "dependencies": { + "mitt": "3.0.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "packages/e2e/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "packages/e2e/node_modules/devtools-protocol": { + "version": "0.0.1147663", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1147663.tgz", + "integrity": "sha512-hyWmRrexdhbZ1tcJUGpO95ivbRhWXz++F4Ko+n21AY5PNln2ovoJw+8ZMNDTtip+CNFQfrtLVh/w4009dXO/eQ==", + "license": "BSD-3-Clause" + }, + "packages/e2e/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "packages/e2e/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/e2e/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/e2e/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "packages/e2e/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "license": "MIT" + }, + "packages/e2e/node_modules/proxy-agent": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.3.0.tgz", + "integrity": "sha512-0LdR757eTj/JfuU7TL2YCuAZnxWXu3tkJbg4Oq3geW/qFNT/32T0sp2HnZ9O0lMR4q3vwAt0+xCA8SR0WAD0og==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.0.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.0.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.1" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/e2e/node_modules/puppeteer-core": { + "version": "20.9.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-20.9.0.tgz", + "integrity": "sha512-H9fYZQzMTRrkboEfPmf7m3CLDN6JvbxXA3qTtS+dFt27tR+CsFHzPsT6pzp6lYL6bJbAPaR0HaPO6uSi+F94Pg==", + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "1.4.6", + "chromium-bidi": "0.4.16", + "cross-fetch": "4.0.0", + "debug": "4.3.4", + "devtools-protocol": "0.0.1147663", + "ws": "8.13.0" + }, + "engines": { + "node": ">=16.3.0" + }, + "peerDependencies": { + "typescript": ">= 4.7.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "packages/e2e/node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "packages/e2e/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/e2e/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "packages/e2e/node_modules/tar-fs": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz", + "integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==", + "license": "MIT", + "dependencies": { + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + } + }, + "packages/e2e/node_modules/ws": { + "version": "8.13.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz", + "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "packages/e2e/node_modules/yargs": { + "version": "17.7.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz", + "integrity": "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "packages/landing-page": { "name": "@avanzacast/landing-page", "version": "1.0.0", @@ -34159,8 +33400,9 @@ } }, "packages/studio-panel": { - "name": "@avanzacast/studio-panel", + "name": "@avanzacast/studio-panel-deprecated", "version": "0.2.0", + "extraneous": true, "license": "ISC", "dependencies": { "@livekit/components-react": "^2.7.2", @@ -34192,898 +33434,6 @@ "vitest": "^1.1.8" } }, - "packages/studio-panel/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "packages/studio-panel/node_modules/@playwright/test": { - "version": "1.51.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.51.0.tgz", - "integrity": "sha512-dJ0dMbZeHhI+wb77+ljx/FeC8VBP6j/rj9OAojO08JI80wTZy6vRk9KvHKiDCUh4iMpEiseMgqRBIeW+eKX6RA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.51.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "packages/studio-panel/node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.2.tgz", - "integrity": "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.2.tgz", - "integrity": "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.2.tgz", - "integrity": "sha512-A6s4gJpomNBtJ2yioj8bflM2oogDwzUiMl2yNJ2v9E7++sHrSrsQ29fOfn5DM/iCzpWcebNYEdXpaK4tr2RhfQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.2.tgz", - "integrity": "sha512-e6XqVmXlHrBlG56obu9gDRPW3O3hLxpwHpLsBJvuI8qqnsrtSZ9ERoWUXtPOkY8c78WghyPHZdmPhHLWNdAGEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.2.tgz", - "integrity": "sha512-v0E9lJW8VsrwPux5Qe5CwmH/CF/2mQs6xU1MF3nmUxmZUCHazCjLgYvToOk+YuuUqLQBio1qkkREhxhc656ViA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.2.tgz", - "integrity": "sha512-ClAmAPx3ZCHtp6ysl4XEhWU69GUB1D+s7G9YjHGhIGCSrsg00nEGRRZHmINYxkdoJehde8VIsDC5t9C0gb6yqA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.2.tgz", - "integrity": "sha512-EPlb95nUsz6Dd9Qy13fI5kUPXNSljaG9FiJ4YUGU1O/Q77i5DYFW5KR8g1OzTcdZUqQQ1KdDqsTohdFVwCwjqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.2.tgz", - "integrity": "sha512-BOmnVW+khAUX+YZvNfa0tGTEMVVEerOxN0pDk2E6N6DsEIa2Ctj48FOMfNDdrwinocKaC7YXUZ1pHlKpnkja/Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.2.tgz", - "integrity": "sha512-Xt2byDZ+6OVNuREgBXr4+CZDJtrVso5woFtpKdGPhpTPHcNG7D8YXeQzpNbFRxzTVqJf7kvPMCub/pcGUWgBjA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.2.tgz", - "integrity": "sha512-+LdZSldy/I9N8+klim/Y1HsKbJ3BbInHav5qE9Iy77dtHC/pibw1SR/fXlWyAk0ThnpRKoODwnAuSjqxFRDHUQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.2.tgz", - "integrity": "sha512-8ms8sjmyc1jWJS6WdNSA23rEfdjWB30LH8Wqj0Cqvv7qSHnvw6kgMMXRdop6hkmGPlyYBdRPkjJnj3KCUHV/uQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.2.tgz", - "integrity": "sha512-3HRQLUQbpBDMmzoxPJYd3W6vrVHOo2cVW8RUo87Xz0JPJcBLBr5kZ1pGcQAhdZgX9VV7NbGNipah1omKKe23/g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.2.tgz", - "integrity": "sha512-fMjKi+ojnmIvhk34gZP94vjogXNNUKMEYs+EDaB/5TG/wUkoeua7p7VCHnE6T2Tx+iaghAqQX8teQzcvrYpaQA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.2.tgz", - "integrity": "sha512-XuGFGU+VwUUV5kLvoAdi0Wz5Xbh2SrjIxCtZj6Wq8MDp4bflb/+ThZsVxokM7n0pcbkEr2h5/pzqzDYI7cCgLQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.2.tgz", - "integrity": "sha512-w6yjZF0P+NGzWR3AXWX9zc0DNEGdtvykB03uhonSHMRa+oWA6novflo2WaJr6JZakG2ucsyb+rvhrKac6NIy+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.2.tgz", - "integrity": "sha512-yo8d6tdfdeBArzC7T/PnHd7OypfI9cbuZzPnzLJIyKYFhAQ8SvlkKtKBMbXDxe1h03Rcr7u++nFS7tqXz87Gtw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.2.tgz", - "integrity": "sha512-ah59c1YkCxKExPP8O9PwOvs+XRLKwh/mV+3YdKqQ5AMQ0r4M4ZDuOrpWkUaqO7fzAHdINzV9tEVu8vNw48z0lA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.2.tgz", - "integrity": "sha512-4VEd19Wmhr+Zy7hbUsFZ6YXEiP48hE//KPLCSVNY5RMGX2/7HZ+QkN55a3atM1C/BZCGIgqN+xrVgtdak2S9+A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.2.tgz", - "integrity": "sha512-IlbHFYc/pQCgew/d5fslcy1KEaYVCJ44G8pajugd8VoOEI8ODhtb/j8XMhLpwHCMB3yk2J07ctup10gpw2nyMA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.2.tgz", - "integrity": "sha512-lNlPEGgdUfSzdCWU176ku/dQRnA7W+Gp8d+cWv73jYrb8uT7HTVVxq62DUYxjbaByuf1Yk0RIIAbDzp+CnOTFg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.2.tgz", - "integrity": "sha512-S6YojNVrHybQis2lYov1sd+uj7K0Q05NxHcGktuMMdIQ2VixGwAfbJ23NnlvvVV1bdpR2m5MsNBViHJKcA4ADw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "packages/studio-panel/node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.2.tgz", - "integrity": "sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "packages/studio-panel/node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "packages/studio-panel/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "packages/studio-panel/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "packages/studio-panel/node_modules/rollup": { - "version": "4.53.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.2.tgz", - "integrity": "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.2", - "@rollup/rollup-android-arm64": "4.53.2", - "@rollup/rollup-darwin-arm64": "4.53.2", - "@rollup/rollup-darwin-x64": "4.53.2", - "@rollup/rollup-freebsd-arm64": "4.53.2", - "@rollup/rollup-freebsd-x64": "4.53.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", - "@rollup/rollup-linux-arm-musleabihf": "4.53.2", - "@rollup/rollup-linux-arm64-gnu": "4.53.2", - "@rollup/rollup-linux-arm64-musl": "4.53.2", - "@rollup/rollup-linux-loong64-gnu": "4.53.2", - "@rollup/rollup-linux-ppc64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-gnu": "4.53.2", - "@rollup/rollup-linux-riscv64-musl": "4.53.2", - "@rollup/rollup-linux-s390x-gnu": "4.53.2", - "@rollup/rollup-linux-x64-gnu": "4.53.2", - "@rollup/rollup-linux-x64-musl": "4.53.2", - "@rollup/rollup-openharmony-arm64": "4.53.2", - "@rollup/rollup-win32-arm64-msvc": "4.53.2", - "@rollup/rollup-win32-ia32-msvc": "4.53.2", - "@rollup/rollup-win32-x64-gnu": "4.53.2", - "@rollup/rollup-win32-x64-msvc": "4.53.2", - "fsevents": "~2.3.2" - } - }, - "packages/studio-panel/node_modules/tailwindcss": { - "version": "4.1.17", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", - "integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==", - "dev": true, - "license": "MIT" - }, - "packages/studio-panel/node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, "packages/ui-components": { "name": "avanza-ui", "version": "1.0.0", diff --git a/package.json b/package.json index 6cb3bb3..b42f6de 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,18 @@ "name": "avanzacast-monorepo", "version": "1.0.0", "private": true, + "type": "module", "description": "AvanzaCast - Plataforma de Streaming Modular", "workspaces": [ - "packages/*", + "packages/admin-panel", + "packages/avanza-ui", + "packages/backend-api", + "packages/broadcast-panel", + "packages/e2e", + "packages/landing-page", + "packages/shared-components", + "packages/studio-panel-deprecated", + "packages/vristo-react-main", "shared/*" ], "scripts": { @@ -12,14 +21,12 @@ "dev:landing": "npm run dev --workspace=packages/landing-page", "dev:api": "npm run dev --workspace=packages/backend-api", "dev:studio": "npm run dev --workspace=packages/broadcast-studio", - "dev:studio-panel": "npm run dev --workspace=packages/studio-panel", "dev:broadcast-panel": "npm run dev --workspace=packages/broadcast-panel", "dev:admin": "npm run dev --workspace=packages/admin-panel", "build": "npm run build --workspaces", "build:landing": "npm run build --workspace=packages/landing-page", "build:api": "npm run build --workspace=packages/backend-api", "build:studio": "npm run build --workspace=packages/broadcast-studio", - "build:studio-panel": "npm run build --workspace=packages/studio-panel", "build:broadcast-panel": "npm run build --workspace=packages/broadcast-panel", "build:admin": "npm run build --workspace=packages/admin-panel", "clean": "rm -rf packages/*/node_modules packages/*/dist shared/*/node_modules node_modules", @@ -37,6 +44,8 @@ "npm": ">=10.0.0" }, "dependencies": { + "puppeteer": "^19.11.1", + "puppeteer-core": "^24.30.0", "react-icons": "^5.5.0" } } diff --git a/packages/avanza-ui/package.json b/packages/avanza-ui/package.json index a991ce9..dc4cecf 100644 --- a/packages/avanza-ui/package.json +++ b/packages/avanza-ui/package.json @@ -2,9 +2,9 @@ "name": "avanza-ui", "version": "1.0.0", "description": "Biblioteca de componentes React para AvanzaCast basada en StreamYard y unificada con ui-components", - "main": "dist/index.js", - "module": "dist/index.esm.js", - "types": "dist/index.d.ts", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", "files": [ "dist", "src" diff --git a/packages/backend-api/.env.production b/packages/backend-api/.env.production index f56993e..67af2e2 100644 --- a/packages/backend-api/.env.production +++ b/packages/backend-api/.env.production @@ -11,5 +11,8 @@ LIVEKIT_URL=wss://livekit-server.bfzqqk.easypanel.host # Allow frontend origins (production) FRONTEND_URLS=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host,https://avanzacast-studio.bfzqqk.easypanel.host +# Database connection (Postgres) +DATABASE_URL="postgres://postgres:72ff3d8d80c352f89d99@192.168.1.20:5433/llmchats?sslmode=disable" + PORT=4000 NODE_ENV=production diff --git a/packages/backend-api/Dockerfile b/packages/backend-api/Dockerfile index 533dcfd..1c751fc 100644 --- a/packages/backend-api/Dockerfile +++ b/packages/backend-api/Dockerfile @@ -1,6 +1,6 @@ # Multi-stage Dockerfile for backend-api # Build stage: install deps and compile -FROM node:20-bullseye-slim AS builder +FROM node:20-bullseye AS builder WORKDIR /app # Install build dependencies @@ -9,20 +9,36 @@ COPY package.json package-lock.json* ./ RUN apt-get update && apt-get install -y --no-install-recommends python3 build-essential ca-certificates && rm -rf /var/lib/apt/lists/* \ && (npm ci --no-audit --no-fund || npm install --no-audit --no-fund) -# Copy source and build +# Copy source and generate Prisma client, then build COPY . . +# Generate Prisma client so that node_modules/@prisma/client contains the generated client +RUN npx prisma generate --schema=./prisma/schema.prisma || true + +# Build the TypeScript project RUN npm run build +# Remove dev dependencies to leave only production deps (smaller final copy) +# We run npm prune --production which keeps installed production deps in node_modules +RUN npm prune --production || true + # Production stage: copy built files and production deps -FROM node:20-bullseye-slim +FROM node:20-bullseye WORKDIR /app -# Copy node_modules from builder (includes production deps) -COPY --from=builder /app/node_modules ./node_modules +# Copy built output COPY --from=builder /app/dist ./dist +# Copy production node_modules (including generated Prisma client) +COPY --from=builder /app/node_modules ./node_modules +# Copy package.json (useful for runtime metadata) +COPY --from=builder /app/package.json ./package.json + +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=4000 EXPOSE 4000 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["node", "dist/index.js"] diff --git a/packages/backend-api/README.md b/packages/backend-api/README.md new file mode 100644 index 0000000..615d6b6 --- /dev/null +++ b/packages/backend-api/README.md @@ -0,0 +1,123 @@ +# AvanzaCast — backend-api + +Este servicio expone las APIs de backend para AvanzaCast: generación de tokens para LiveKit, gestión de sesiones para el Studio, registro de tokens provenientes de un token-server, gestión de usuarios y broadcasts. + +## Endpoints principales + +- `GET /health` — healthcheck. +- `POST /api/session` — generar sesión (token) para un `room` y `username`. + - Body: { room: string, username: string, ttl?: number } + - Respuesta: { id, studioUrl, redirectUrl, ttlSeconds } +- `GET /api/session/:id` — obtener sesión por id (token, url, room, username, ttlSeconds). +- `POST /api/session/:id/consume` — marcar sesión consumida (single-use). +- `POST /api/tokens` — registro de tokens generados por un token-server externo. + - Body: { sessionId: string, token: string, room?: string, username?: string, ttl?: number, createdBy?: number } + - (El token-server puede llamar este endpoint para persistir tokens con el usuario asociado.) +- `GET /api/token?room=<>&username=<>` — (legacy) generar token sin crear sesión almacenada (usa `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` si están configuradas). +- `POST /api/session/validate` — proxy para validar un token contra LiveKit. + +- User & Broadcasts management (pueden usar Prisma o fallback en memoria): + - `POST /api/users` — crear usuario { email, username, displayName?, isAdmin? } + - `GET /api/users/:id` — obtener usuario + - `POST /api/broadcasts` — crear broadcast { title, description?, ownerId? } + - `GET /api/broadcasts` — listar broadcasts + - `POST /api/broadcasts/:id/session` — crear sesión vinculada a un broadcast (genera token y guarda session) + +## Variables de entorno (más relevantes) + +- `PORT` — puerto de escucha (default 4000) +- `DATABASE_URL` — cadena de conexión PostgreSQL (opcional, para Prisma) +- `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` — credenciales LiveKit para generar AccessTokens reales +- `LIVEKIT_WS_URL` o `LIVEKIT_URL` — URL del servidor LiveKit (ws/wss o http(s)) +- `VITE_BROADCASTPANEL_URL`, `VITE_STUDIO_URL` — URLs del frontend para construir redirect/studioUrl +- `VITE_TOKEN_SERVER_URL` — token-server host (para CORS/allowed origins) +- `REDIS_URL` — Redis si lo quieres usar +- `ALLOW_ALL_CORS` — si se quiere permitir cualquier origen (debug only) +- `SESSION_TTL_SECONDS` — TTL por defecto para sesiones (300s por defecto) + +## Integración con token-server + +- Un token-server puede generar tokens y llamar `POST /api/tokens` para registrar la sesión/token en el backend. +- El `POST /api/tokens` persiste la sesión en la base de datos (Prisma) o en Redis/memoria si Prisma no está disponible. +- Para mayor seguridad puedes proteger `POST /api/tokens` con un secreto compartido. Recomendación: añadir `BACKEND_REGISTER_SECRET` y que el token-server envíe `X-BACKEND-SECRET`. + +## Cómo ejecutar localmente (quick start) + +En el monorepo, desde la raíz: + +1. Backend API (dev) + +```bash +cd packages/backend-api +npm install +# Arrancar en dev (usa tsx) +npm run dev +``` + +2. Generar tokens / registrar sesiones + +- Puedes usar la propia ruta `POST /api/session` del `backend-api` para crear una sesión y obtener un token si `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` están configuradas. De lo contrario el backend devolverá un token "dev" (mock) para pruebas. + +3. Probar generación y registro + +```bash +# crea session/token +curl -sS -X POST http://localhost:4000/api/session -H 'Content-Type: application/json' -d '{"room":"test-room","username":"e2e","ttl":300}' | jq . +# verifica que la sesión se puede obtener por id (usar id devuelto) +curl -sS http://localhost:4000/api/session/ | jq . +``` + +### Generar sesión internamente (server-to-server) + +Puedes pedir al `backend-api` que genere la sesión de forma segura usando el endpoint interno protegido `POST /api/internal/session`. Esto requiere que en tu `.env` configures `BACKEND_REGISTER_SECRET` y que la petición incluya el header `X-BACKEND-SECRET`. + +Para pruebas locales hay un script incluido: + +```bash +# establece el secreto en la misma sesión o en el .env +export BACKEND_REGISTER_SECRET=devregistersecret +npm run internal:request --workspace packages/backend-api +# o desde el package folder +cd packages/backend-api && npm run internal:request + +# el script usa por defecto room=test-room y username=e2e +``` + +El resultado incluirá `id`, `studioUrl`, `redirectUrl`, `ttlSeconds` y `token`. + +4. Probar desde el frontend `broadcast-panel` + +- Asegúrate `packages/broadcast-panel/.env` apunta a la URL correcta del backend (p. ej. `VITE_BACKEND_API_URL=http://localhost:4000`). +- Arranca `broadcast-panel` e intenta crear una transmisión y click en "Entrar al estudio". El flujo intentará obtener una session desde `/api/session` y el `StudioPortal` recoge la sesión desde `sessionStorage` o por evento. + +## Notas de seguridad y producción + +- No uses credenciales dev en producción. Protege el endpoint `/api/tokens` con autenticación (token mutual, HMAC o IP allowlist). +- Si usas Prisma, ejecuta `npm run prisma:migrate` y `npm run prisma:generate` antes de arrancar el servicio. + +## API examples (curl) + +Generate session (backend-api): +```bash +curl -sS -X POST http://localhost:4000/api/session -H 'Content-Type: application/json' -d '{"room":"room1","username":"cesar","ttl":300}' | jq . +``` + +Register token (token-server -> backend-api): +```bash +curl -sS -X POST http://localhost:4000/api/tokens -H 'Content-Type: application/json' -d '{"sessionId":"abcd123","token":"jwt..","room":"room1","username":"cesar","ttl":300}' | jq . +``` + +Get session by id: +```bash +curl -sS http://localhost:4000/api/session/abcd123 | jq . +``` + +## Next steps / mejoras + +- Añadir protección a `/api/tokens` (header secreto o auth) y actualizar el token-server para enviarlo. +- Añadir pruebas e2e que cubran el flujo completo (token-server -> backend-api -> broadcast-panel -> studio). +- Si quieres, implemento la protección con `BACKEND_REGISTER_SECRET` y actualizaré la documentación. + +--- + +Si quieres que implemente la protección `BACKEND_REGISTER_SECRET` ahora, lo hago y detallo cómo configurar ambas partes. diff --git a/packages/backend-api/docker-compose.yml b/packages/backend-api/docker-compose.yml new file mode 100644 index 0000000..d9d66cb --- /dev/null +++ b/packages/backend-api/docker-compose.yml @@ -0,0 +1,35 @@ +version: '3.8' +services: + backend-api: + build: . + image: avanzacast-backend-api:latest + ports: + - "4000:4000" # host:container — exponer en 4000 según petición + env_file: + - .env + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + - NODE_ENV=production + - PORT=4000 + - REDIS_URL=${REDIS_URL:-redis://redis:6379} + - ALLOW_ALL_CORS=1 + # Production friendly defaults (can be overridden in .env) + - VITE_BROADCASTPANEL_URL=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host + - VITE_STUDIO_URL=https://avanzacast-studio.bfzqqk.easypanel.host + - VITE_TOKEN_SERVER_URL=https://avanzacast-servertokens.bfzqqk.easypanel.host + # If you want redirectUrl to include token directly (convenience), set to 1 + - INCLUDE_TOKEN_IN_REDIRECT=0 + # LiveKit credentials and URL (set to values provided) + - LIVEKIT_API_KEY=devkey + - LIVEKIT_API_SECRET=secret + - LIVEKIT_URL=https://livekit-server.bfzqqk.easypanel.host + - LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host + depends_on: + - redis + restart: unless-stopped + + redis: + image: redis:7-alpine + container_name: avanzacast-redis + restart: unless-stopped diff --git a/packages/backend-api/docker-entrypoint.sh b/packages/backend-api/docker-entrypoint.sh new file mode 100755 index 0000000..35397b5 --- /dev/null +++ b/packages/backend-api/docker-entrypoint.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run prisma generate if @prisma/client not present or if prisma schema changed +if [ ! -d node_modules/@prisma/client ]; then + echo "[entrypoint] @prisma/client not found — running 'npx prisma generate'" + npx prisma generate --schema=./prisma/schema.prisma || true +else + echo "[entrypoint] @prisma/client present — skipping prisma generate" +fi + +# Allow passing custom command, otherwise default to node dist/index.js +if [ "$#" -eq 0 ]; then + exec node dist/index.js +else + exec "$@" +fi + diff --git a/packages/backend-api/package.json b/packages/backend-api/package.json index bad9f7f..d611a3d 100644 --- a/packages/backend-api/package.json +++ b/packages/backend-api/package.json @@ -10,6 +10,7 @@ "start": "node dist/index.js", "typecheck": "tsc --noEmit", "lint": "eslint src --ext ts --report-unused-disable-directives --max-warnings 0", + "internal:request": "node scripts/request_internal_session.js", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio" @@ -19,13 +20,13 @@ "bcryptjs": "^2.4.3", "cors": "^2.8.5", "dotenv": "^16.3.1", - "express": "^4.18.2", + "express": "^4.21.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", "ioredis": "^5.3.2", "joi": "^17.11.0", "jsonwebtoken": "^9.0.2", - "livekit-server-sdk": "^2.14.0", + "livekit-server-sdk": "^2.14.1", "socket.io": "^4.6.2", "stripe": "^14.9.0", "winston": "^3.11.0" diff --git a/packages/backend-api/prisma/schema.prisma b/packages/backend-api/prisma/schema.prisma new file mode 100644 index 0000000..ef4ee9a --- /dev/null +++ b/packages/backend-api/prisma/schema.prisma @@ -0,0 +1,80 @@ +// Prisma schema reconstructed from prisma/migrations/20251118223907_init/migration.sql +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Role { + id Int @id @default(autoincrement()) + name String @unique + label String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + UserRole UserRole[] +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + username String @unique + displayName String? + password String? + isAdmin Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + UserRole UserRole[] + sessions Session[] @relation("SessionCreatedBy") +} + +model UserRole { + id Int @id @default(autoincrement()) + userId Int + roleId Int + + user User @relation(fields: [userId], references: [id]) + role Role @relation(fields: [roleId], references: [id]) + + @@unique([userId, roleId]) +} + +model Session { + id String @id + token String + url String + room String + username String + createdAt DateTime @default(now()) + expiresAt DateTime + consumed Boolean @default(false) + created_by Int? @map("created_by") + + creator User? @relation("SessionCreatedBy", fields: [created_by], references: [id]) +} + +model Broadcast { + id Int @id @default(autoincrement()) + title String + description String? + ownerId Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model AuditLog { + id Int @id @default(autoincrement()) + actorId Int? + action String + resource String? + metadata Json? + createdAt DateTime @default(now()) +} + +model Setting { + id Int @id @default(autoincrement()) + key String @unique + value String? +} diff --git a/packages/backend-api/scripts/get_session_token.js b/packages/backend-api/scripts/get_session_token.js new file mode 100644 index 0000000..7defbe6 --- /dev/null +++ b/packages/backend-api/scripts/get_session_token.js @@ -0,0 +1,92 @@ +#!/usr/bin/env node +// ...existing code... +// packages/backend-api/scripts/get_session_token.js +const { PrismaClient } = require('@prisma/client'); + +function usage() { + console.log('Usage: node scripts/get_session_token.js --id [--db ]'); + console.log('You can also set DATABASE_URL env var.'); +} + +function parseArgs() { + const out = {}; + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (a === '--id' || a === '-i') { + out.id = args[i+1]; i++; + } else if (a === '--db' || a === '-d') { + out.db = args[i+1]; i++; + } else if (a === '--help' || a === '-h') { + out.help = true; + } + } + return out; +} + +function base64UrlDecode(s) { + if (!s) return ''; + s = s.replace(/-/g, '+').replace(/_/g, '/'); + while (s.length % 4) s += '='; + try { return Buffer.from(s, 'base64').toString('utf8'); } catch (e) { return null; } +} + +(async function main() { + try { + const argv = parseArgs(); + if (argv.help) { usage(); process.exit(0); } + const id = argv.id || process.env.SESSION_ID; + const dbUrl = argv.db || process.env.DATABASE_URL; + if (!id) { + console.error('Error: session id is required (--id)'); + usage(); process.exit(2); + } + if (!dbUrl) { + console.error('Error: DATABASE_URL not provided (use --db or set env DATABASE_URL)'); + process.exit(2); + } + + console.log('Connecting to database...'); + const prisma = new PrismaClient({ datasources: { db: { url: dbUrl } } }); + await prisma.$connect(); + + console.log(`Querying Session id=${id} ...`); + const s = await prisma.session.findUnique({ where: { id } }); + if (!s) { + console.error('Session not found'); + await prisma.$disconnect(); + process.exit(3); + } + + console.log('--- Session row ---'); + console.log(`id: ${s.id}`); + console.log(`room: ${s.room}`); + console.log(`username: ${s.username}`); + console.log(`createdAt: ${s.createdAt}`); + console.log(`expiresAt: ${s.expiresAt}`); + + const token = s.token || ''; + console.log('\n--- Token (truncated) ---'); + console.log(token.length > 200 ? token.slice(0, 80) + '...' + token.slice(-80) : token); + + // decode + const parts = token.split('.'); + if (parts.length >= 2) { + const header = base64UrlDecode(parts[0]); + const payload = base64UrlDecode(parts[1]); + console.log('\n--- Decoded header ---'); + try { console.log(JSON.stringify(JSON.parse(header), null, 2)); } catch (e) { console.log(header); } + console.log('\n--- Decoded payload ---'); + try { console.log(JSON.stringify(JSON.parse(payload), null, 2)); } catch (e) { console.log(payload); } + } else { + console.log('Token does not look like a JWT'); + } + + await prisma.$disconnect(); + process.exit(0); + } catch (err) { + console.error('Error running script:', err); + process.exit(1); + } +})(); + diff --git a/packages/backend-api/scripts/request_internal_session.js b/packages/backend-api/scripts/request_internal_session.js new file mode 100644 index 0000000..c850283 --- /dev/null +++ b/packages/backend-api/scripts/request_internal_session.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Simple script to request an internal session from backend-api +// Usage: node request_internal_session.js [room] [username] [ttl] [createdBy] + +const url = process.env.HOST || 'http://localhost:4000'; +const secret = process.env.BACKEND_REGISTER_SECRET || 'devregistersecret'; +const room = process.argv[2] || 'test-room'; +const username = process.argv[3] || 'e2e'; +const ttl = process.argv[4] ? Number(process.argv[4]) : 300; +const createdBy = process.argv[5] ? Number(process.argv[5]) : undefined; + +async function main() { + try { + if (typeof fetch === 'undefined') { + // Node older than 18 might not have fetch; try to use node-fetch + try { + global.fetch = (await import('node-fetch')).default; + } catch (e) { + console.error('Global fetch not available and node-fetch not installed. Please run on Node 18+ or install node-fetch.'); + process.exit(1); + } + } + + const body = { room, username, ttl }; + if (typeof createdBy === 'number') body.createdBy = createdBy; + + const res = await fetch(`${url}/api/internal/session`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-backend-secret': secret, + }, + body: JSON.stringify(body), + }); + + const text = await res.text(); + try { + console.log(JSON.stringify(JSON.parse(text), null, 2)); + } catch (e) { + console.log(text); + } + + if (!res.ok) process.exit(2); + } catch (err) { + console.error('Request failed:', err.message || String(err)); + process.exit(1); + } +} + +main(); + diff --git a/packages/backend-api/scripts/test-token-flow.js b/packages/backend-api/scripts/test-token-flow.js new file mode 100644 index 0000000..c988cd8 --- /dev/null +++ b/packages/backend-api/scripts/test-token-flow.js @@ -0,0 +1,74 @@ +// Script de prueba para generar una sesión y comprobar la URL en broadcast-panel +// Uso: BACKEND_URL=http://localhost:4000 BROADCAST_BASE=https://avanzacast-broadcastpanel.bfzqqk.easypanel.host node scripts/test-token-flow.js + +async function main() { + const backend = process.env.BACKEND_URL || 'http://localhost:4000'; + const broadcastBase = process.env.BROADCAST_BASE || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host'; + console.log('[test] Backend:', backend); + console.log('[test] Broadcast base:', broadcastBase); + + const payload = { room: 'test-room-' + Math.random().toString(36).slice(2,8), username: 'tester', ttl: 120 }; + + try { + const resp = await fetch(`${backend.replace(/\/$/, '')}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + const text = await resp.text(); + let json; + try { json = JSON.parse(text); } catch (e) { console.error('[test] respuesta no-JSON:', text); throw e; } + + if (!resp.ok) { + console.error('[test] fallo al crear session', resp.status, json); + process.exit(2); + } + + console.log('[test] session created:', JSON.stringify(json, null, 2)); + + const id = json.id; + if (!id) { + console.error('[test] la respuesta no incluyó id de session; revisa backend'); + process.exit(3); + } + + const urlToCheck = `${broadcastBase.replace(/\/$/, '')}/${encodeURIComponent(id)}`; + console.log('[test] comprobando URL de broadcast-panel ->', urlToCheck); + + // hacen un HEAD primero + try { + const head = await fetch(urlToCheck, { method: 'HEAD' }); + console.log(`[test] HEAD ${head.status} ${head.statusText}`); + } catch (e) { + console.warn('[test] HEAD falló, intentando GET', String(e)); + } + + // luego GET para ver contenido (limitado) + try { + const get = await fetch(urlToCheck, { method: 'GET' }); + console.log(`[test] GET ${get.status} ${get.statusText}`); + const ctype = get.headers.get('content-type') || ''; + console.log('[test] content-type:', ctype); + const body = await get.text(); + console.log('[test] body (first 1000 chars):\n', body.slice(0, 1000)); + } catch (e) { + console.error('[test] GET falló:', String(e)); + } + + console.log('[test] prueba finalizada'); + process.exit(0); + } catch (err) { + console.error('[test] error inesperado', err); + process.exit(1); + } +} + +// Node 18+ tiene fetch global. Si no, mostrar instrucción al usuario. +if (typeof fetch === 'undefined') { + console.error('fetch no está disponible en este entorno Node. Usa Node 18+ o instala una dependencia como node-fetch.'); + process.exit(1); +} + +main(); + diff --git a/packages/backend-api/scripts/test_generate_token.cjs b/packages/backend-api/scripts/test_generate_token.cjs new file mode 100644 index 0000000..db07bf4 --- /dev/null +++ b/packages/backend-api/scripts/test_generate_token.cjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// CommonJS diagnostic script: tries to load livekit-server-sdk and generate an AccessToken +// Usage: node scripts/test_generate_token.cjs + +require('dotenv').config(); +const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || 'devkey'; +const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'secret'; +const ROOM = process.argv[2] || 'diagnostic-room'; +const IDENTITY = process.argv[3] || 'diag-user'; + +(async function main(){ + console.log('LIVEKIT_API_KEY=', LIVEKIT_API_KEY ? '[set]' : '[not set]'); + console.log('LIVEKIT_API_SECRET=', LIVEKIT_API_SECRET ? '[set]' : '[not set]'); + try { + const sdk = require('livekit-server-sdk'); + console.log('livekit-server-sdk imported:', !!sdk); + const AccessToken = sdk.AccessToken || (sdk.default && sdk.default.AccessToken); + const VideoGrant = sdk.VideoGrant || (sdk.default && sdk.default.VideoGrant); + if (!AccessToken) { + console.error('AccessToken class not found on SDK exports. Export keys:', Object.keys(sdk)); + process.exit(2); + } + const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: IDENTITY, name: IDENTITY }); + try { + if (VideoGrant) { + const g = new VideoGrant({ room: ROOM }); + if (typeof at.addGrant === 'function') at.addGrant(g); + else if (typeof at.add_grant === 'function') at.add_grant(g); + else console.warn('No addGrant method on AccessToken instance; skipping addGrant'); + } else { + if (typeof at.addGrant === 'function') at.addGrant({ room: ROOM, roomJoin: true, canPublish: true, canSubscribe: true }); + else console.warn('No VideoGrant and no addGrant method; continuing'); + } + } catch (e) { + console.warn('Grant add failed:', String(e)); + } + const token = (typeof at.toJwt === 'function') ? await at.toJwt() : at.jwt; + console.log('Generated token (prefix 200 chars):', token ? token.slice(0,200) : token); + // try decode payload (unsafe inspect) + try { + const parts = token.split('.'); + if (parts.length >= 2) { + const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4); + const payloadJson = Buffer.from(padded, 'base64').toString('utf8'); + console.log('Token payload sample:', payloadJson.slice(0,1000)); + } + } catch (e) { console.warn('Failed to decode token payload', e); } + } catch (err) { + console.error('Failed to import or use livekit-server-sdk:', String(err)); + console.log('Attempting fallback: generate mock JWT (HMAC) to verify environment'); + try { + const jwt = require('jsonwebtoken'); + const payload = { iss: LIVEKIT_API_KEY, sub: IDENTITY, room: ROOM, iat: Math.floor(Date.now()/1000), exp: Math.floor(Date.now()/1000)+3600 }; + const token = jwt.sign(payload, LIVEKIT_API_SECRET, { algorithm: 'HS256' }); + console.log('Generated mock JWT (prefix 200 chars):', token.slice(0,200)); + console.log('Decoded payload:', jwt.decode(token)); + } catch (e) { + console.error('Fallback JWT generation failed:', String(e)); + } + process.exit(0); + } +})(); + diff --git a/packages/backend-api/scripts/test_generate_token.js b/packages/backend-api/scripts/test_generate_token.js new file mode 100644 index 0000000..fef35df --- /dev/null +++ b/packages/backend-api/scripts/test_generate_token.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +// Diagnosis script: tries to load livekit-server-sdk and generate an AccessToken +// Usage: node scripts/test_generate_token.js + +require('dotenv').config(); +const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY || 'devkey'; +const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET || 'secret'; +const ROOM = process.argv[2] || 'diagnostic-room'; +const IDENTITY = process.argv[3] || 'diag-user'; + +(async function main(){ + console.log('LIVEKIT_API_KEY=', LIVEKIT_API_KEY ? '[set]' : '[not set]'); + console.log('LIVEKIT_API_SECRET=', LIVEKIT_API_SECRET ? '[set]' : '[not set]'); + try { + const sdk = await import('livekit-server-sdk'); + console.log('livekit-server-sdk imported:', !!sdk); + const AccessToken = sdk.AccessToken || sdk.default?.AccessToken || sdk.AccessToken; + const VideoGrant = sdk.VideoGrant || sdk.default?.VideoGrant || sdk.VideoGrant; + if (!AccessToken) { + console.error('AccessToken class not found on SDK exports. Export keys:', Object.keys(sdk)); + process.exit(2); + } + const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: IDENTITY, name: IDENTITY }); + try { + if (VideoGrant) { + const g = new VideoGrant({ room: ROOM }); + if (typeof at.addGrant === 'function') at.addGrant(g); + else if (typeof at.add_grant === 'function') at.add_grant(g); + else console.warn('No addGrant method on AccessToken instance; skipping addGrant'); + } else { + if (typeof at.addGrant === 'function') at.addGrant({ room: ROOM, roomJoin: true, canPublish: true, canSubscribe: true }); + else console.warn('No VideoGrant and no addGrant method; continuing'); + } + } catch (e) { + console.warn('Grant add failed:', String(e)); + } + const token = (typeof at.toJwt === 'function') ? await at.toJwt() : at.jwt; + console.log('Generated token (prefix 100 chars):', token ? token.slice(0,100) : token); + // try decode payload (unsafe inspect) + try { + const parts = token.split('.'); + if (parts.length >= 2) { + const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const padded = payloadB64 + '='.repeat((4 - (payloadB64.length % 4)) % 4); + const payloadJson = Buffer.from(padded, 'base64').toString('utf8'); + console.log('Token payload sample:', payloadJson.slice(0,1000)); + } + } catch (e) { console.warn('Failed to decode token payload', e); } + } catch (err) { + console.error('Failed to import or use livekit-server-sdk:', String(err)); + console.log('Attempting fallback: generate mock JWT (HMAC) to verify environment'); + try { + const jwt = require('jsonwebtoken'); + const payload = { iss: LIVEKIT_API_KEY, sub: IDENTITY, room: ROOM, iat: Math.floor(Date.now()/1000), exp: Math.floor(Date.now()/1000)+3600 }; + const token = jwt.sign(payload, LIVEKIT_API_SECRET, { algorithm: 'HS256' }); + console.log('Generated mock JWT (prefix 100 chars):', token.slice(0,100)); + console.log('Decoded payload:', jwt.decode(token)); + } catch (e) { + console.error('Fallback JWT generation failed:', String(e)); + } + process.exit(0); + } +})(); + diff --git a/packages/backend-api/scripts/test_prisma_session.js b/packages/backend-api/scripts/test_prisma_session.js new file mode 100644 index 0000000..0ddd0cd --- /dev/null +++ b/packages/backend-api/scripts/test_prisma_session.js @@ -0,0 +1,72 @@ +// scripts/test_prisma_session.js +// Quick script to test Prisma connection and upsert/read a Session record. +// Usage: +// node scripts/test_prisma_session.js +// It will load DATABASE_URL from environment; if not present it will try to load packages/backend-api/.env via dotenv. + +(async () => { + try { + // Load dotenv from the backend-api package if DATABASE_URL not already set + if (!process.env.DATABASE_URL) { + try { + const path = require('path'); + const dotenv = require('dotenv'); + const envFile = path.resolve(__dirname, '..', '.env'); + // load but do not override existing env + dotenv.config({ path: envFile }); + } catch (e) { + // ignore if dotenv not available + } + } + + if (!process.env.DATABASE_URL) { + console.error('ERROR: DATABASE_URL env var is not set. Aborting.'); + console.error('You can set it manually or create packages/backend-api/.env with DATABASE_URL.'); + process.exit(2); + } + + // Normalize: remove surrounding single/double quotes if present + let dbUrl = process.env.DATABASE_URL; + if ((dbUrl.startsWith("'") && dbUrl.endsWith("'")) || (dbUrl.startsWith('"') && dbUrl.endsWith('"'))) { + dbUrl = dbUrl.slice(1, -1); + } + process.env.DATABASE_URL = dbUrl; + + console.log('Prisma test: using DATABASE_URL=', dbUrl.replace(/:[^:@]+@/, ':***@')); + + const { PrismaClient } = require('@prisma/client'); + const prisma = new PrismaClient(); + await prisma.$connect(); + console.log('[Prisma] connected'); + + const id = 'e2e_test_' + Math.random().toString(36).slice(2, 9); + const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5m + + console.log('Upserting session id=', id); + const created = await prisma.session.upsert({ + where: { id }, + update: { token: 'test-token-' + id, url: 'wss://test-local', room: 'testroom', username: 'tester', expiresAt }, + create: { id, token: 'test-token-' + id, url: 'wss://test-local', room: 'testroom', username: 'tester', expiresAt }, + }); + + console.log('Upsert result:', { id: created.id, room: created.room, username: created.username, expiresAt: created.expiresAt }); + + const found = await prisma.session.findUnique({ where: { id } }); + if (!found) { + console.error('Failed to read session after upsert'); + process.exit(1); + } + console.log('Read session ok:', { id: found.id, room: found.room, username: found.username, expiresAt: found.expiresAt }); + + // cleanup + await prisma.session.delete({ where: { id } }).catch(() => {}); + console.log('Cleanup done'); + + await prisma.$disconnect(); + console.log('Prisma test complete'); + process.exit(0); + } catch (err) { + console.error('Prisma test failed', err && err.message ? err.message : err); + process.exit(1); + } +})(); diff --git a/packages/backend-api/src/index.ts b/packages/backend-api/src/index.ts index ff41bc9..5c650e1 100644 --- a/packages/backend-api/src/index.ts +++ b/packages/backend-api/src/index.ts @@ -7,6 +7,19 @@ import Redis from 'ioredis'; dotenv.config(); +// Try to init Prisma if DATABASE_URL is provided +let prisma: any = null; +if (process.env.DATABASE_URL) { + try { + const { PrismaClient } = await import('@prisma/client'); + prisma = new PrismaClient(); + prisma.$connect().then(() => console.log('[Prisma] connected to DB')).catch((e: any) => console.warn('[Prisma] connect failed', e)); + } catch (e) { + console.warn('Prisma client not available or failed to initialize', e); + prisma = null; + } +} + const app = express(); const PORT = process.env.PORT ? Number(process.env.PORT) : 4000; @@ -136,6 +149,9 @@ app.use((req, res, next) => { app.get('/health', (_req, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() })); +// Register studio proxy (disabled - route not present in this package) +// app.use(studioProxy); + function generateShortId(len = 7) { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; let s = ''; @@ -145,18 +161,87 @@ function generateShortId(len = 7) { const SESSION_TTL = Number(process.env.SESSION_TTL_SECONDS || 300); const sessionStoreMemory = new Map(); +// In-memory stores for users and broadcasts when Prisma is not available +const usersStore = new Map(); +let nextUserId = 1000; +const broadcastsStore = new Map(); +let nextBroadcastId = 5000; + +async function saveSession(id: string, data: { token: string, url: string, room: string, username: string }, ttlSeconds: number, createdBy?: number) { + const expiresAt = new Date(Date.now() + ttlSeconds * 1000); + const payload = { token: data.token, url: data.url, room: data.room, username: data.username, expiresAt }; + try { + if (prisma) { + // Upsert session by id, include createdBy if provided + const createObj: any = { id, ...payload }; + if (typeof createdBy === 'number') createObj.createdBy = createdBy; + const updateObj: any = { ...payload }; + if (typeof createdBy === 'number') updateObj.createdBy = createdBy; + await prisma.session.upsert({ where: { id }, update: updateObj, create: createObj }); + return; + } + } catch (e) { + console.warn('Prisma saveSession failed, falling back', e); + } -async function saveSession(id: string, data: { token: string, url: string, room: string, username: string }, ttlSeconds: number) { - const payload = { ...data, expiresAt: Date.now() + ttlSeconds * 1000 }; if (redisClient && redisAvailable) { - await redisClient.setex(`session:${id}`, ttlSeconds, JSON.stringify(payload)); + await redisClient.setex(`session:${id}`, ttlSeconds, JSON.stringify({ ...payload, expiresAt: expiresAt.getTime() })); } else { - sessionStoreMemory.set(id, { ...payload }); + sessionStoreMemory.set(id, { ...payload, expiresAt: expiresAt.getTime() }); setTimeout(() => { sessionStoreMemory.delete(id) }, ttlSeconds * 1000 + 1000); } } +// Endpoint for external token-server to register a generated token/session with backend +app.post('/api/tokens', async (req, res) => { + try { + // If BACKEND_REGISTER_SECRET is set, require X-BACKEND-SECRET header to match + const registerSecret = process.env.BACKEND_REGISTER_SECRET; + if (registerSecret) { + const header = (req.headers['x-backend-secret'] || req.headers['x-backend-secret'.toLowerCase()]) as string | undefined; + if (!header || header !== registerSecret) { + console.warn('[backend-api] invalid or missing X-BACKEND-SECRET for /api/tokens from', req.ip); + return res.status(401).json({ error: 'invalid_registration_secret' }); + } + } + const body = req.body || {}; + const sessionId = typeof body.sessionId === 'string' ? body.sessionId : undefined; + const token = typeof body.token === 'string' ? body.token : undefined; + const room = typeof body.room === 'string' ? body.room : undefined; + const username = typeof body.username === 'string' ? body.username : undefined; + const ttl = body.ttl ? Number(body.ttl) : undefined; + const createdBy = body.createdBy ? Number(body.createdBy) : undefined; + + if (!sessionId || !token) return res.status(400).json({ error: 'sessionId and token are required' }); + // derive ws url like createLivekitTokenFor + const returnUrl = process.env.LIVEKIT_WS_URL || (process.env.LIVEKIT_URL ? process.env.LIVEKIT_URL.replace(/^https?:\/\//i, (m) => m.toLowerCase().startsWith('https') ? 'wss://' : 'ws://') : 'ws://localhost:7880'); + + const ttlSec = ttl || SESSION_TTL; + await saveSession(sessionId, { token, url: returnUrl, room: room || 'unknown', username: username || 'unknown' }, ttlSec, createdBy); + return res.json({ ok: true, sessionId }); + } catch (err) { + console.error('Failed to register token', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + async function getSession(id: string): Promise { + try { + if (prisma) { + const s = await prisma.session.findUnique({ where: { id } }); + if (!s) return null; + const expiresAtNum = (s.expiresAt instanceof Date) ? s.expiresAt.getTime() : Number(s.expiresAt); + if (expiresAtNum <= Date.now()) { + // delete expired + try { await prisma.session.delete({ where: { id } }); } catch(e){} + return null; + } + return { token: s.token, url: s.url, room: s.room, username: s.username, expiresAt: expiresAtNum }; + } + } catch (e) { + console.warn('Prisma getSession failed, falling back', e); + } + if (redisClient && redisAvailable) { const raw = await redisClient.get(`session:${id}`); if (!raw) return null; @@ -171,15 +256,84 @@ async function getSession(id: string): Promise m.toLowerCase().startsWith('https') ? 'wss://' : 'ws://') : 'ws://localhost:7880'); + console.log('[LIVEKIT] creating fake token (no API key/secret configured) for', username); + return { token: fakeToken, url: derivedUrl }; } - const { AccessToken } = await import('livekit-server-sdk'); - const at = new AccessToken(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: username, name: username }); - at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true }); - const token = await at.toJwt(); - return { token, url: process.env.LIVEKIT_URL || 'ws://localhost:7880' }; + // Try to dynamically import the official SDK and handle different export shapes + try { + const sdkModule = await import('livekit-server-sdk'); + // sdkModule may export AccessToken directly or as default.AccessToken + const sdkAny: any = sdkModule; + const AccessTokenClass = sdkAny.AccessToken || sdkAny.default?.AccessToken || sdkAny.default || sdkAny; + // VideoGrant may be missing in some SDK builds; guard access via any + const VideoGrantClass = sdkAny.VideoGrant || sdkAny.default?.VideoGrant || undefined; + if (!AccessTokenClass) { + console.warn('[LIVEKIT] AccessToken class not found on livekit-server-sdk, falling back to mock token'); + } else { + // Create token using detected classes + const at = new AccessTokenClass(LIVEKIT_API_KEY, LIVEKIT_API_SECRET, { identity: username, name: username }); + try { + if (VideoGrantClass) { + const g = new VideoGrantClass({ room }); + // Some SDK versions expect addGrant(grant) and some expect raw object; try both + if (typeof at.addGrant === 'function') at.addGrant(g); + else if (typeof at.add_grant === 'function') at.add_grant(g); + } else if (typeof at.addGrant === 'function') { + at.addGrant({ room, roomJoin: true, canPublish: true, canSubscribe: true }); + } else if (typeof at.add_grant === 'function') { + at.add_grant({ room, roomJoin: true, canPublish: true, canSubscribe: true }); + } + } catch (e) { + console.warn('[LIVEKIT] addGrant error', String(e)); + } + const token = typeof at.toJwt === 'function' ? await at.toJwt() : at.jwt; + console.log('[LIVEKIT] generated token using SDK for', username, 'room', room); + // debug: if token looks like JWT, decode header to log alg + try { + if (typeof token === 'string' && token.split('.').length >= 2) { + const header = token.split('.')[0]; + const padded = header + '='.repeat((4 - (header.length % 4)) % 4); + const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + let h = decoded; + try { h = JSON.parse(decoded); } + catch (e) { /* keep raw */ } + console.log('[LIVEKIT] token header:', h); + } + } catch (e) { console.warn('[LIVEKIT] failed to decode token header', String(e)); } + // Prefer explicit websocket URL env, else derive from LIVEKIT_URL (http(s) -> ws(s)) + const returnUrl = LIVEKIT_WS || (LIVEKIT_HTTP ? LIVEKIT_HTTP.replace(/^https?:\/\//i, (m) => m.toLowerCase().startsWith('https') ? 'wss://' : 'ws://') : 'ws://localhost:7880'); + return { token, url: returnUrl }; + } + } catch (e) { + console.warn('[LIVEKIT] livekit-server-sdk import failed, falling back to mock token', String(e)); + } + // fallback: mocked JWT token if SDK not used + const payload = { + iss: LIVEKIT_API_KEY, + sub: username || 'user', + room: room || 'room', + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + (60 * 60), + }; + const token = require('jsonwebtoken').sign(payload, LIVEKIT_API_SECRET, { algorithm: 'HS256' }); + // log that we used fallback HS256 + try { + const header = token.split('.')[0]; + const padded = header + '='.repeat((4 - (header.length % 4)) % 4); + const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + let h = decoded; + try { h = JSON.parse(decoded); } catch (e) {} + console.log('[LIVEKIT] fallback token header:', h); + } catch (e) { console.warn('[LIVEKIT] failed to decode fallback token header', String(e)); } + const derivedUrl = LIVEKIT_WS || (LIVEKIT_HTTP ? LIVEKIT_HTTP.replace(/^https?:\/\//i, (m) => m.toLowerCase().startsWith('https') ? 'wss://' : 'ws://') : 'ws://localhost:7880'); + return { token, url: derivedUrl }; } app.get('/api/token', async (req, res) => { @@ -195,6 +349,38 @@ app.get('/api/token', async (req, res) => { } }); +// Debug/validate endpoint: validate a token against LiveKit from the backend +app.all('/api/session/validate', async (req, res) => { + try { + const token = (req.method === 'GET' ? req.query.token : req.body?.token) || req.query.token || req.body?.token; + if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' }); + + // derive http(s) origin from LIVEKIT_WS_URL + const raw = process.env.LIVEKIT_WS_URL || process.env.LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'; + let httpUrl = (raw as string).replace(/^wss:\/\//i, 'https://').replace(/^ws:\/\//i, 'http://'); + try { + const u = new URL(httpUrl); + httpUrl = `${u.protocol}//${u.host}`; + } catch (e) { + // keep as-is + } + + const validateUrl = `${httpUrl}/rtc/validate?access_token=${encodeURIComponent(token)}&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16`; + console.log('[backend-api] proxy validate to', validateUrl.slice(0, 200)); + + const resp = await fetch(validateUrl, { method: 'GET' }); + const ct = resp.headers.get('content-type') || ''; + const text = await resp.text(); + if (ct.includes('application/json')) { + try { const json = JSON.parse(text); return res.status(resp.status).json({ ok: resp.ok, status: resp.status, body: json }); } catch (e) {} + } + return res.status(resp.status).send(text); + } catch (err) { + console.error('[backend-api] validate proxy failed', err); + return res.status(500).json({ error: 'validate_failed', details: String(err) }); + } +}); + app.post('/api/session', async (req, res) => { try { const body = req.body || {}; @@ -217,18 +403,23 @@ app.post('/api/session', async (req, res) => { const ttlSec = ttl || SESSION_TTL; await saveSession(id, { token, url, room, username }, ttlSec); - const studioBase = (process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); + // If the studio UI is integrated inside the broadcast-panel app, prefer that URL. + const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); // Optionally include token directly in redirectUrl when env var is set (convenience for direct entry) const includeToken = process.env.INCLUDE_TOKEN_IN_REDIRECT === '1' || process.env.INCLUDE_TOKEN_IN_REDIRECT === 'true'; + // When studio is embedded as a feature inside the broadcast panel we redirect to the broadcast panel + // and include a session id by default. If INCLUDE_TOKEN_IN_REDIRECT is set, include the token for quick testing. const redirectUrl = includeToken ? `${studioBase}/?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}` - : `${studioBase}/${id}`; + : `${studioBase}/?session=${encodeURIComponent(id)}`; return res.json({ id, studioUrl: `${studioBase}/${id}`, redirectUrl, ttlSeconds: ttlSec, + room, + username, }); } catch (err) { console.error('Failed to create session', err); @@ -238,19 +429,116 @@ app.post('/api/session', async (req, res) => { app.get('/api/session/:id', async (req, res) => { const id = req.params.id; + console.log(`[backend-api] GET /api/session/${id} requested from ${req.ip}`); const s = await getSession(id); - if (!s) return res.status(404).json({ error: 'not found' }); + if (!s) { + console.warn(`[backend-api] session ${id} not found`); + return res.status(404).json({ error: 'not found' }); + } const ttlLeft = Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000)); + try { + const tokenTrunc = typeof s.token === 'string' && s.token.length > 16 ? `${s.token.slice(0,8)}...${s.token.slice(-8)}` : String(s.token || ''); + console.log(`[backend-api] session ${id} found room=${s.room} username=${s.username} ttlLeft=${ttlLeft} token=${tokenTrunc}`); + } catch (e) { /* ignore logging errors */ } return res.json({ token: s.token, url: s.url, room: s.room, username: s.username, ttlSeconds: ttlLeft }); }); -app.get('/s/:id', async (req, res) => { - const id = req.params.id; - const s = await getSession(id); - if (!s) return res.status(404).send('Not found'); - const studioBase = (process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); - const redirectTo = `${studioBase}/studio_receiver.html?token=${encodeURIComponent(s.token)}&room=${encodeURIComponent(s.room)}&username=${encodeURIComponent(s.username)}`; - return res.redirect(302, redirectTo); +// NEW: get token for an existing session (read-only) +app.get('/api/session/:id/token', async (req, res) => { + try { + const id = req.params.id; + const s = await getSession(id); + if (!s) return res.status(404).json({ error: 'not_found' }); + const ttlLeft = Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000)); + return res.json({ token: s.token, ttlSeconds: ttlLeft, room: s.room, username: s.username, url: s.url }); + } catch (err) { + console.error('GET /api/session/:id/token failed', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + +// NEW: generate (or regenerate) a token for an existing session id +app.post('/api/session/:id/token', async (req, res) => { + try { + const id = req.params.id; + // Accept optional overrides in body + const body = req.body || {}; + const overrideRoom = typeof body.room === 'string' ? body.room : undefined; + const overrideUsername = typeof body.username === 'string' ? body.username : undefined; + const ttl = body.ttl ? Number(body.ttl) : undefined; + + // Load existing session data + const existing = await getSession(id); + if (!existing) { + return res.status(404).json({ error: 'session_not_found' }); + } + + const room = overrideRoom || existing.room || 'default-room'; + const username = overrideUsername || existing.username || 'guest'; + + // Generate token via existing helper + const { token, url } = await createLivekitTokenFor(room, username); + const ttlSec = ttl || SESSION_TTL; + + // Persist the generated token to the session store, updating url/room/username + await saveSession(id, { token, url, room, username }, ttlSec); + + return res.json({ ok: true, sessionId: id, token, ttlSeconds: ttlSec, url }); + } catch (err) { + console.error('POST /api/session/:id/token failed', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + +// Debug: list sessions (only allowed in development or when ALLOW_SESSION_LIST=1) +app.get('/api/session/list', async (req, res) => { + try { + if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_SESSION_LIST !== '1') { + return res.status(403).json({ error: 'not_allowed' }); + } + + const out: Array<{ id: string; room: string; username: string; ttlSeconds: number; expiresAt: number }> = []; + + if (redisClient && redisAvailable) { + try { + const keys: string[] = await redisClient.keys('session:*'); + if (keys.length === 0) return res.json({ sessions: [] }); + // pipeline to fetch multiple keys + const pipeline = redisClient.pipeline(); + keys.forEach(k => pipeline.get(k)); + const results = await pipeline.exec(); + if (!results) return res.json({ sessions: out }); + for (let i = 0; i < keys.length; i++) { + const item = results[i]; + const raw = item && item[1] ? item[1] : null; + if (!raw) continue; + try { + const parsed = JSON.parse(raw as string); + const id = keys[i].replace(/^session:/, ''); + out.push({ id, room: parsed.room, username: parsed.username, ttlSeconds: Math.max(0, Math.floor((parsed.expiresAt - Date.now()) / 1000)), expiresAt: parsed.expiresAt }); + } catch (e) { /* ignore parse errors */ } + } + return res.json({ sessions: out }); + } catch (err) { + console.warn('Failed to list sessions from redis', err); + return res.status(500).json({ error: 'redis_list_failed', details: String(err) }); + } + } + + // fallback: in-memory store + try { + for (const [id, val] of sessionStoreMemory.entries()) { + out.push({ id, room: val.room, username: val.username, ttlSeconds: Math.max(0, Math.floor((val.expiresAt - Date.now()) / 1000)), expiresAt: val.expiresAt }); + } + return res.json({ sessions: out }); + } catch (err) { + console.warn('Failed to list sessions from memory', err); + return res.status(500).json({ error: 'memory_list_failed', details: String(err) }); + } + } catch (err) { + console.error('session list failed', err); + return res.status(500).json({ error: 'session_list_failed', details: String(err) }); + } }); // Optional: mark session as consumed to prevent replay (single-use) @@ -272,6 +560,177 @@ app.post('/api/session/:id/consume', async (req, res) => { } }); +// --- Users endpoints --- +app.post('/api/users', async (req, res) => { + try { + const { email, username, displayName, isAdmin } = req.body || {}; + if (!email || !username) return res.status(400).json({ error: 'email and username required' }); + if (prisma) { + try { + const user = await prisma.user.create({ data: { email, username, displayName: displayName || null, isAdmin: !!isAdmin } }); + return res.json({ ok: true, user }); + } catch (e) { + console.warn('[backend-api] prisma create user failed', e); + return res.status(500).json({ error: 'user_create_failed', details: String(e) }); + } + } + const id = nextUserId++; + const u = { id, email, username, displayName: displayName || null, isAdmin: !!isAdmin }; + usersStore.set(id, u); + return res.json({ ok: true, user: u }); + } catch (err) { + console.error('Failed to create user', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + +app.get('/api/users/:id', async (req, res) => { + try { + const id = Number(req.params.id); + if (prisma) { + const u = await prisma.user.findUnique({ where: { id } }); + if (!u) return res.status(404).json({ error: 'not_found' }); + return res.json({ user: u }); + } + const u = usersStore.get(id as number); + if (!u) return res.status(404).json({ error: 'not_found' }); + return res.json({ user: u }); + } catch (err) { + console.error('Get user failed', err); + return res.status(500).json({ error: 'failed' }); + } +}); + +// --- Broadcast endpoints --- +app.post('/api/broadcasts', async (req, res) => { + try { + const { title, description, ownerId } = req.body || {}; + if (!title) return res.status(400).json({ error: 'title required' }); + if (prisma) { + try { + const b = await prisma.broadcast.create({ data: { title, description: description || null, ownerId: ownerId || null } }); + return res.json({ ok: true, broadcast: b }); + } catch (e) { + console.warn('[backend-api] prisma create broadcast failed', e); + return res.status(500).json({ error: 'broadcast_create_failed', details: String(e) }); + } + } + const id = nextBroadcastId++; + const now = Date.now(); + const b = { id, title, description: description || null, ownerId: ownerId || null, createdAt: now, updatedAt: now }; + broadcastsStore.set(id, b); + return res.json({ ok: true, broadcast: b }); + } catch (err) { + console.error('Create broadcast failed', err); + return res.status(500).json({ error: 'failed' }); + } +}); + +app.get('/api/broadcasts', async (req, res) => { + try { + if (prisma) { + const list = await prisma.broadcast.findMany({ orderBy: { createdAt: 'desc' } }); + return res.json({ broadcasts: list }); + } + const out = Array.from(broadcastsStore.values()).sort((a, b) => b.createdAt - a.createdAt); + return res.json({ broadcasts: out }); + } catch (err) { + console.error('List broadcasts failed', err); + return res.status(500).json({ error: 'failed' }); + } +}); + +// Create a session associated with a broadcast (convenience endpoint for UI) +app.post('/api/broadcasts/:id/session', async (req, res) => { + try { + const bid = Number(req.params.id); + let ownerId: number | undefined = undefined; + let roomName = `broadcast-${bid}`; + try { + if (prisma) { + const b = await prisma.broadcast.findUnique({ where: { id: bid } }); + if (!b) return res.status(404).json({ error: 'broadcast_not_found' }); + ownerId = b.ownerId || undefined; + roomName = b.title || roomName; + } else { + const b = broadcastsStore.get(bid); + if (!b) return res.status(404).json({ error: 'broadcast_not_found' }); + ownerId = b.ownerId || undefined; + roomName = b.title || roomName; + } + } catch (e) { console.warn('Error fetching broadcast', e) } + + const { username, ttl } = req.body || {}; + if (!username || typeof username !== 'string') return res.status(400).json({ error: 'username required' }); + const { token, url } = await createLivekitTokenFor(roomName, username); + + // create a session id and save session + let sid = generateShortId(7); + let attempt = 0; + while (attempt < 6) { + const exists = await getSession(sid); + if (!exists) break; + sid = generateShortId(7); + attempt++; + } + const ttlSec = ttl ? Number(ttl) : SESSION_TTL; + await saveSession(sid, { token, url, room: roomName, username }, ttlSec, ownerId); + + const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); + const redirectUrl = `${studioBase}/?session=${encodeURIComponent(sid)}`; + return res.json({ id: sid, studioUrl: `${studioBase}/${sid}`, redirectUrl, ttlSeconds: ttlSec }); + } catch (err) { + console.error('Failed to create broadcast session', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + +// Internal endpoint: generate a session/token server-to-server. Requires X-BACKEND-SECRET when configured. +app.post('/api/internal/session', async (req, res) => { + try { + const registerSecret = process.env.BACKEND_REGISTER_SECRET; + if (registerSecret) { + const header = (req.headers['x-backend-secret'] || req.headers['x-backend-secret'.toLowerCase()]) as string | undefined; + if (!header || header !== registerSecret) { + console.warn('[backend-api] invalid or missing X-BACKEND-SECRET for /api/internal/session from', req.ip); + return res.status(401).json({ error: 'invalid_registration_secret' }); + } + } + + const body = req.body || {}; + const room = typeof body.room === 'string' ? body.room : undefined; + const username = typeof body.username === 'string' ? body.username : undefined; + const ttl = body.ttl ? Number(body.ttl) : undefined; + const createdBy = body.createdBy ? Number(body.createdBy) : undefined; + + if (!room) return res.status(400).json({ error: 'room is required' }); + if (!username) return res.status(400).json({ error: 'username is required' }); + + // create token via existing helper + const { token, url } = await createLivekitTokenFor(room, username); + + // create a unique session id and save it + let id = generateShortId(7); + let attempt = 0; + while (attempt < 6) { + const exists = await getSession(id); + if (!exists) break; + id = generateShortId(7); + attempt++; + } + const ttlSec = ttl || SESSION_TTL; + await saveSession(id, { token, url, room, username }, ttlSec, createdBy); + + const studioBase = (process.env.VITE_BROADCASTPANEL_URL || process.env.VITE_STUDIO_URL || 'http://localhost:3020').replace(/\/$/, ''); + const redirectUrl = `${studioBase}/?session=${encodeURIComponent(id)}`; + + return res.json({ id, studioUrl: `${studioBase}/${id}`, redirectUrl, ttlSeconds: ttlSec, token }); + } catch (err) { + console.error('Failed to create internal session', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + app.use((_req, res) => res.status(404).json({ error: 'Not found' })); app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { @@ -295,3 +754,31 @@ app.listen(Number(PORT), HOST, () => { if (addresses.length > 0) addresses.forEach(a => console.log(`🔗 Accessible at: http://${a}:${PORT}`)); } catch (e) { console.warn('Could not enumerate network interfaces', e) } }); + +// Debug endpoint: return decoded JWT header for a session id (safe-guarded) +app.get('/debug/session/:id/header', async (req, res) => { + try { + // Only allow in development or when explicitly enabled + if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_TOKEN_HEADER !== '1') { + return res.status(403).json({ error: 'not_allowed' }); + } + const id = String(req.params.id || ''); + if (!id) return res.status(400).json({ error: 'missing_id' }); + const s = await getSession(id); + if (!s) return res.status(404).json({ error: 'not_found' }); + const token = s.token || ''; + if (!token) return res.status(404).json({ error: 'no_token' }); + const parts = token.split('.'); + if (parts.length < 2) return res.json({ header: null, message: 'not_jwt', tokenPreview: token.slice(0, 80) }); + const header = parts[0]; + const padded = header + '='.repeat((4 - (header.length % 4)) % 4); + const decoded = Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); + let parsed = decoded; + try { parsed = JSON.parse(decoded); } catch (e) { /* keep raw decoded string */ } + return res.json({ header: parsed }); + } catch (err) { + console.error('[DEBUG] /debug/session/:id/header failed', err); + return res.status(500).json({ error: 'failed', details: String(err) }); + } +}); + diff --git a/packages/broadcast-panel/README-NGINX.md b/packages/broadcast-panel/README-NGINX.md new file mode 100644 index 0000000..69e2cf4 --- /dev/null +++ b/packages/broadcast-panel/README-NGINX.md @@ -0,0 +1,82 @@ +# broadcast-panel - Proxy / Nginx & Studio flow + +Este README explica cómo configurar el proxy (Nginx) para que las rutas de API de tokens (`/api/session`) se enruten al `backend-api` (token server), cómo probar el flujo "Entrar al estudio" y cómo ejecutar la automatización de prueba (Playwright / browserMCP). + +Archivo de ejemplo de Nginx +- El archivo `packages/broadcast-panel/nginx.conf` ya contiene una ubicación para `/api/session`: + +```nginx +location ~ ^/api/session(/.*)?$ { + proxy_pass http://backend-api:4000$request_uri; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; + proxy_read_timeout 20s; +} +``` + +Importante: +- Ese `proxy_pass` funciona si Nginx se ejecuta en la misma red Docker y `backend-api` es resolvible como nombre de servicio. En el proxy público (easypanel/traefik/nginx del host) debes apuntar a `http://127.0.0.1:4000` o al host/IP donde corre `backend-api`. + +Ejemplo de bloque `server` para proxy público (host): + +```nginx +upstream backend_api { + server 127.0.0.1:4000; # o la IP/host real del token server +} + +server { + listen 80; + server_name avanzacast-servertokens.bfzqqk.easypanel.host; + + location ~ ^/api(/.*)?$ { + proxy_pass http://backend_api$request_uri; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +Probar el flujo del estudio (manualmente) +1. Crear sesión en token server: + +```bash +curl -v -X POST 'https://avanzacast-servertokens.bfzqqk.easypanel.host/api/session' \ + -H 'Content-Type: application/json' \ + -d '{"room":"studio-live","username":"mcp-runner","ttl":300}' --max-time 15 +``` + +2. Debes recibir JSON con `studioUrl` (o `id` + `token`). Copia la `studioUrl` y ábrela en un navegador. +3. En la UI del broadcast-panel, en la ventana del estudio, debería aparecer "Esperando token..." y cuando el postMessage/auto-connect se realice, verás "Token recibido desde Broadcast Panel" y/o "Conectado". + +Automatizar con browserMCP / Playwright +- Usa el script `run_studio_flow.mjs` (puedo generarlo y ejecutarlo) que: + - Llama por POST al token-server para crear sesión, + - Abre la `studioUrl` y pulsa los botones "Conectar" / "Entrar" / "Ir en vivo" según sea necesario, + - Espera la confirmación en el DOM y devuelve screenshot + logs. + +Script Playwright (resumen): +```javascript +// POST /api/session -> parse JSON +// goto(studioUrl) +// click('text=Conectar' | 'text=Entrar al estudio' | ...) +// wait 2s +// get document.body.innerText +// screenshot +``` + +¿Quieres que cree y ejecute el script Playwright ahora? +- Si confirmas: ejecutaré el POST y el flujo en browserMCP y te devolveré screenshot + logs. + +Notas finales +- Si `curl` devuelve HTML en vez de JSON es señal de que el proxy público no enruta `/api` al token server. Revisa la configuración del proxy y aplica las reglas mostradas en este README. + + diff --git a/packages/broadcast-panel/e2e/README.md b/packages/broadcast-panel/e2e/README.md new file mode 100644 index 0000000..f8eca8d --- /dev/null +++ b/packages/broadcast-panel/e2e/README.md @@ -0,0 +1,38 @@ +Local E2E runner + +This E2E runner automates the UI flow for Broadcast Panel -> StudioPortal -> LiveKit token handshake. + +Prereqs +- Node 18+ +- npm packages installed in `packages/broadcast-panel` (run `npm install` there) +- Either a local Chrome launched with remote debugging or access to a remote browser service like browserless + +Start local Chrome with remote debugging (example): + +```bash +cd packages/broadcast-panel/e2e +chmod +x start-chrome-remote.sh +./start-chrome-remote.sh +# verify +curl http://localhost:9222/json/version +``` + +Run the E2E runner connecting to a remote Chrome (default) or browserless: + +```bash +# Connect to local remote-debugging chrome +cd packages/broadcast-panel +node e2e/run_local_e2e.js --ws http://localhost:9222 --show + +# Or connect to browserless remote (example) +REMOTE_WS="wss://browserless.bfzqqk.easypanel.host?token=e2e098863b912f6a178b68e71ec3c58d" node e2e/run_local_e2e.js --show + +# To point to specific backend/broadcast hosts (useful when running remote browserless): +REMOTE_WS="..." BROADCAST_URL="https://avanzacast-broadcastpanel.bfzqqk.easypanel.host" TOKEN_SERVER="https://avanzacast-servertokens.bfzqqk.easypanel.host" node e2e/run_local_e2e.js --show +``` + +Notes +- The script will postMessage the LIVEKIT_TOKEN to the StudioPortal when a token is created in backend-api. +- If StudioPortal does not auto-connect, the runner will attempt to click a "Conectar" button (class .btn-small). +- Logs and screenshots are saved to `packages/broadcast-panel/e2e/out`. + diff --git a/packages/broadcast-panel/e2e/browserless_connect.mjs b/packages/broadcast-panel/e2e/browserless_connect.mjs new file mode 100644 index 0000000..962dcfd --- /dev/null +++ b/packages/broadcast-panel/e2e/browserless_connect.mjs @@ -0,0 +1,84 @@ +// browserless_connect.mjs +// Conecta a un browserless remoto vía WebSocket y ejecuta el flujo en http://localhost:5175 +// Uso: node --input-type=module browserless_connect.mjs + +import puppeteer from 'puppeteer-core'; + +const BROWSERLESS_WSS = process.env.BROWSERLESS_WS || 'wss://browserless.bfzqqk.easypanel.host?token=e2e098863b912f6a178b68e71ec3c58d'; +const BROADCAST_URL = process.env.BROADCAST_URL || 'http://localhost:5175'; + +function byTextXPath(text){ + return `//*[contains(translate(normalize-space(string(.)), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')]`; +} +async function clickByText(page, text){ + const els = await page.$x(byTextXPath(text)); + if(els && els.length){ await els[0].click(); return true; } + return false; +} + +(async ()=>{ + console.log('Connecting to browserless at', BROWSERLESS_WSS); + const browser = await puppeteer.connect({ browserWSEndpoint: BROWSERLESS_WSS, defaultViewport: null, timeout: 60000 }); + try{ + const page = await browser.newPage(); + page.on('console', msg => console.log('PAGE:', msg.text())); + + console.log('Navigating to', BROADCAST_URL); + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle2', timeout: 60000 }); + await page.waitForTimeout(1000); + + // Try click 'Entrar al Estudio' + if(await clickByText(page, 'Entrar al Estudio')){ + console.log('Clicked Entrar al Estudio'); + await page.waitForTimeout(2000); + } else { + console.log('Entrar al Estudio not found, trying create-flow'); + // open modal buttons + const opened = await (async ()=>{ + if(await clickByText(page, 'Crear transmisión')) return true; + if(await clickByText(page, 'Crear transmisión en vivo')) return true; + if(await clickByText(page, 'Nueva transmisión')) return true; + if(await clickByText(page, 'Crear')) return true; + if(await clickByText(page, 'Transmitir')) return true; + return false; + })(); + if(opened){ + console.log('Modal opened, trying skip and start'); + await page.waitForTimeout(800); + await clickByText(page, 'Omitir por ahora').catch(()=>{}); + // fill input + const inputs = await page.$$('input'); + for(const inp of inputs){ + try{ + const box = await inp.boundingBox(); + if(box){ + await inp.focus(); + await page.keyboard.type('Transmitir', {delay:100}); + break; + } + }catch(e){} + } + await clickByText(page, 'Empezar ahora').catch(()=>{}); + await page.waitForTimeout(800); + await clickByText(page, 'Entrar al Estudio').catch(()=>{}); + await page.waitForTimeout(1500); + } else { + console.log('Could not open modal - aborting flow'); + } + } + + const shot = '/tmp/browserless_final.png'; + await page.screenshot({ path: shot, fullPage: true }).catch(e=>console.log('screenshot failed', e)); + console.log('Screenshot saved to', shot); + + // keep page open briefly so browserless trace available + await page.waitForTimeout(2000); + await browser.disconnect(); + console.log('Disconnected'); + }catch(err){ + console.error('Error during flow:', err); + try{ await browser.disconnect(); }catch(e){} + process.exitCode = 1; + } +})(); + diff --git a/packages/broadcast-panel/e2e/dify-plugin-playwright.mjs b/packages/broadcast-panel/e2e/dify-plugin-playwright.mjs new file mode 100644 index 0000000..fc9ec96 --- /dev/null +++ b/packages/broadcast-panel/e2e/dify-plugin-playwright.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +// packages/broadcast-panel/e2e/dify-plugin-playwright.mjs +// Minimal Playwright "plugin" runner inspired by dify-plugin-playwright +// Usage (CLI): +// node dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png +// Or import and call runPlaywright(options) + +import { chromium } from 'playwright'; +import fs from 'fs'; +import path from 'path'; + +async function runPlaywright({ wsEndpoint, url, out = '/tmp/dify-playwright-shot.png', steps = [] }){ + const result = { success: false, wsEndpoint, url, out, steps: [], error: null, used: null }; + let browser; + try{ + // Try to connect to remote Playwright server if wsEndpoint provided + if(wsEndpoint){ + result.steps.push(`attempt_connect:${wsEndpoint}`); + try{ + browser = await chromium.connect({ wsEndpoint, timeout: 30000 }); + result.used = 'remote'; + result.steps.push({ action: 'connect', ok: true, meta: { wsEndpoint } }); + }catch(connectErr){ + // Log the error and fallback to launching local browser + const em = String(connectErr && (connectErr.stack || connectErr.message || connectErr)); + result.steps.push({ action: 'connect', ok: false, meta: { error: em } }); + result.steps.push({ action: 'fallback', ok: true, meta: { reason: 'connect-failed, launching local chromium' } }); + try{ + browser = await chromium.launch({ headless: true }); + result.used = 'local-launch'; + result.steps.push({ action: 'launch', ok: true, meta: { launched: true } }); + }catch(launchErr){ + result.error = 'Failed to connect remote and failed to launch local chromium: ' + String(launchErr && (launchErr.stack || launchErr.message)); + return result; + } + } + } else { + // No wsEndpoint provided -> launch local + result.steps.push('no-wsendpoint-launch-local'); + browser = await chromium.launch({ headless: true }); + result.used = 'local-launch'; + result.steps.push({ action: 'launch', ok: true }); + } + + const context = await browser.newContext(); + const page = await context.newPage(); + + // default flow if no custom steps + if(!steps || steps.length === 0){ + steps = [ + { action: 'goto', url }, + { action: 'wait', ms: 1000 }, + { action: 'clickText', text: 'Entrar al Estudio' }, + { action: 'wait', ms: 1000 }, + { action: 'screenshot', path: out } + ]; + } + + for(const s of steps){ + const step = { action: s.action || 'unknown', ok: false, meta: null }; + try{ + if(s.action === 'goto'){ + await page.goto(s.url, { waitUntil: 'networkidle', timeout: s.timeout || 30000 }); + step.ok = true; step.meta = { url: page.url() }; + } else if(s.action === 'wait'){ + await page.waitForTimeout(s.ms || 500); + step.ok = true; + } else if(s.action === 'clickText'){ + const locator = page.locator(`text=${s.text}`); + const c = await locator.count(); + if(c > 0){ await locator.first().click({ timeout: 5000 }); step.ok = true; } else { step.ok = false; step.meta = { found: 0 }; } + } else if(s.action === 'type'){ + await page.locator(s.selector).fill(s.text); step.ok = true; + } else if(s.action === 'screenshot'){ + const p = s.path || out; + await page.screenshot({ path: p, fullPage: true }); step.ok = true; step.meta = { path: p }; + } else if(s.action === 'eval'){ + const r = await page.evaluate(s.fn); + step.ok = true; step.meta = { result: r }; + } else { + // unknown + step.ok = false; step.meta = { reason: 'unknown action' }; + } + }catch(err){ + step.ok = false; step.meta = { error: String(err && (err.stack || err.message || err)) }; + } + result.steps.push(step); + } + + result.success = result.steps.every(st => typeof st === 'object' ? st.ok || st.action==='connect' : true); + + await context.close(); + try{ await browser.close(); }catch(e){} + return result; + }catch(err){ + if(browser){ try{ await browser.close(); }catch(e){} } + result.error = String(err && (err.stack || err.message || err)); + return result; + } +} + +// CLI runner +if(process.argv[1] && process.argv[1].endsWith('dify-plugin-playwright.mjs')){ + // parse args simply + const args = process.argv.slice(2); + const opts = {}; + for(let i=0;i{ + const res = await runPlaywright({ wsEndpoint: opts.wsEndpoint, url: opts.url, out: opts.out }); + console.log(JSON.stringify(res, null, 2)); + if(!res.success) process.exit(2); + process.exit(0); + })(); +} + +export { runPlaywright }; diff --git a/packages/broadcast-panel/e2e/gemini_agent_server.py b/packages/broadcast-panel/e2e/gemini_agent_server.py new file mode 100644 index 0000000..946b0c5 --- /dev/null +++ b/packages/broadcast-panel/e2e/gemini_agent_server.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +gemini_agent_server.py + +Server HTTP mínimo (FastAPI) que expone un endpoint /query para recibir un prompt, +opcionalmente enviar ese prompt a la API de Gemini (si GEMINI_API_KEY está configurada), +interpretar la instrucción y ejecutar la acción usando `gemini_log_agent`. + +- Instalar dependencias: + python3 -m pip install --user fastapi uvicorn requests + +- Ejecutar: + export GEMINI_API_KEY="YOUR_KEY" + export PLAYWRIGHT_WS="ws://192.168.1.20:3003" + python3 -m uvicorn packages.broadcast_panel.e2e.gemini_agent_server:app --host 0.0.0.0 --port 5001 + +Nota: el módulo se implementa para usarse en el workspace; si uvicorn no encuentra +el módulo, ejecuta desde la raíz del repo: + uvicorn packages.broadcast-panel.e2e.gemini_agent_server:app --reload +""" +from __future__ import annotations +import os +import json +import traceback +from typing import Optional + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +# Import local agent +from .gemini_log_agent import interpret_prompt, handle_action + +import requests + +app = FastAPI(title="Gemini Log Agent Server") + +GEMINI_API_KEY = os.environ.get('GEMINI_API_KEY') +PLAYWRIGHT_WS = os.environ.get('PLAYWRIGHT_WS', 'ws://192.168.1.20:3003') + +class QueryRequest(BaseModel): + prompt: str + use_llm: Optional[bool] = False + lines: Optional[int] = 200 + ws: Optional[str] = None + +class QueryResponse(BaseModel): + ok: bool + mapping: dict + result: str + + +@app.get('/health') +def health(): + return {"status": "ok", "playwright_ws": PLAYWRIGHT_WS, "llm": bool(GEMINI_API_KEY)} + + +def call_gemini(prompt: str) -> Optional[str]: + """Call Google Generative Language (Gemini) REST endpoint using API key if available. + Returns the generated text or None on failure. + Note: we use the text-bison endpoint (v1beta2) as a best-effort; if the network fails + we return None and fallback to local heuristics. + """ + if not GEMINI_API_KEY: + return None + try: + url = f"https://generativelanguage.googleapis.com/v1beta2/models/text-bison-001:generateText?key={GEMINI_API_KEY}" + payload = { "prompt": { "text": prompt }, "temperature": 0.2, "maxOutputTokens": 256 } + headers = { 'Content-Type': 'application/json' } + resp = requests.post(url, json=payload, headers=headers, timeout=15) + if resp.status_code != 200: + return None + body = resp.json() + # Response shape: {'candidates':[{'content':'...'}], ...} + if 'candidates' in body and len(body['candidates'])>0: + return body['candidates'][0].get('content') + # Older shapes may have 'output' or 'result' + if 'output' in body and isinstance(body['output'], list) and len(body['output'])>0: + return body['output'][0].get('content') + if 'result' in body and isinstance(body['result'], dict): + return body['result'].get('content') + return None + except Exception: + return None + + +@app.post('/query', response_model=QueryResponse) +def query(req: QueryRequest): + try: + prompt = req.prompt + use_llm = bool(req.use_llm) + lines = int(req.lines or 200) + ws = req.ws or PLAYWRIGHT_WS + + if use_llm and GEMINI_API_KEY: + gen = call_gemini(prompt) + if gen: + # Use the generated text to form a mapping prompt (pass-through) + interpreted = interpret_prompt(gen) + else: + interpreted = interpret_prompt(prompt) + else: + interpreted = interpret_prompt(prompt) + + # Attach ws info if available for run_session + if interpreted.get('action') == 'run_session': + interpreted['ws'] = ws + + result = handle_action(interpreted, lines=lines) + return QueryResponse(ok=True, mapping=interpreted, result=result) + except Exception as e: + traceback.print_exc() + raise HTTPException(status_code=500, detail=str(e)) + diff --git a/packages/broadcast-panel/e2e/gemini_log_agent.py b/packages/broadcast-panel/e2e/gemini_log_agent.py new file mode 100644 index 0000000..d906f95 --- /dev/null +++ b/packages/broadcast-panel/e2e/gemini_log_agent.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +""" +gemini_log_agent.py + +Script local que interpreta un prompt en lenguaje natural (heurístico) y devuelve +archivos de log o información de diagnóstico útiles para el flujo E2E/backend/frontend. +Diseñado para usarse junto al runner Playwright y el orquestador que hay en el repo. + +Uso básico: + python3 packages/broadcast-panel/e2e/gemini_log_agent.py --prompt "muéstrame logs backend" --lines 200 + python3 packages/broadcast-panel/e2e/gemini_log_agent.py --interactive + +Notas: +- No llama a servicios externos por defecto. Si quieres integrar un LLM, se puede añadir + soporte para pasar el prompt a una API y usar la respuesta para decidir acciones. +- Solo ejecuta comandos seguros predefinidos (leer archivos de log, listar artefactos). +""" +from __future__ import annotations +import argparse +import shlex +import subprocess +import sys +import os +from typing import List, Tuple, Dict + +# Configuración: archivos y rutas que el agente puede leer +LOG_FILES = { + 'backend': [ + '/tmp/backend_run.log', + '/tmp/e2e-backend.log', + 'packages/backend-api/logs/backend.log' + ], + 'frontend': [ + '/tmp/e2e-frontend.log', + 'packages/broadcast-panel/vite-dev.log' + ], + 'playwright': [ + '/tmp/e2e-playwright.log' + ], + 'plugin': [ + '/tmp/e2e-plugin.log', + '/tmp/dify-plugin-output.log' + ], + 'prisma': [ + '/tmp/backend_prisma_generate.log', + '/tmp/backend_api_npm_install_verbose.log', + '/tmp/backend_api_install_verbose.log' + ], + 'artifacts': [ + '/tmp/e2e-artifacts' + ], + 'screenshot': [ + '/tmp/py-playwright-shot.png', + '/tmp/dify-shot.png' + ] +} + +# Prefer repo-local out directory for artifacts +REPO_OUT_DIR = os.path.join(os.path.dirname(__file__), 'out') +REPO_OUT_DIR = os.path.abspath(REPO_OUT_DIR) +# Ensure it exists +os.makedirs(REPO_OUT_DIR, exist_ok=True) + +# Update LOG_FILES to prefer repo-relative out dir instead of /tmp where possible +LOG_FILES['artifacts'] = [REPO_OUT_DIR] +# map screenshots to out dir +LOG_FILES['screenshot'] = [os.path.join(REPO_OUT_DIR, 'py-playwright-shot.png'), os.path.join(REPO_OUT_DIR, 'dify-shot.png')] + +# Limits +MAX_OUTPUT_LINES = 500 +MAX_BYTES = 200000 # 200KB cap per file + + +def run_cmd(cmd: List[str], timeout: int = 10) -> Tuple[int, str, str]: + try: + proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout) + return proc.returncode, proc.stdout, proc.stderr + except subprocess.TimeoutExpired as e: + return 124, '', f'Timeout after {timeout}s' + except Exception as e: + return 1, '', str(e) + + +def tail_file(path: str, lines: int = 200) -> str: + if not os.path.exists(path): + return f'(no existe) {path}' + # Try using tail for efficiency + cmd = ['tail', f'-n{lines}', path] + code, out, err = run_cmd(cmd) + if code == 0 and out: + if len(out.encode('utf-8')) > MAX_BYTES: + # truncate by bytes + return out.encode('utf-8')[:MAX_BYTES].decode('utf-8', errors='replace') + '\n...[truncated by bytes]' + return out + # fallback: read file manually + try: + with open(path, 'rb') as f: + data = f.read(MAX_BYTES + 1) + if len(data) > MAX_BYTES: + return data[:MAX_BYTES].decode('utf-8', errors='replace') + '\n...[truncated by bytes]' + return data.decode('utf-8', errors='replace') + except Exception as e: + return f'(error reading {path}): {e}' + + +def list_artifact_dir(path: str) -> str: + if not os.path.exists(path): + return f'(no existe) {path}' + try: + cmd = ['ls', '-la', path] + code, out, err = run_cmd(cmd) + if code == 0: + return out + return f'(ls error) {err}' + except Exception as e: + return f'(error listing {path}): {e}' + + +def interpret_prompt(prompt: str) -> Dict[str, object]: + """Heuristics to map a prompt to actions. + Returns dict with keys: action: 'get_logs'|'list_artifacts'|'run_session' etc., and params. + """ + p = prompt.lower() + # simple checks + if 'backend' in p or 'api' in p or 'session' in p and ('log' in p or 'logs' in p or 'error' in p): + return {'action': 'get_logs', 'target': 'backend'} + if 'frontend' in p or 'vite' in p or 'broadcast-panel' in p: + return {'action': 'get_logs', 'target': 'frontend'} + if 'playwright' in p or 'browser' in p or 'puppeteer' in p: + return {'action': 'get_logs', 'target': 'playwright'} + if 'plugin' in p or 'dify' in p: + return {'action': 'get_logs', 'target': 'plugin'} + if 'prisma' in p or 'db' in p or 'database' in p: + return {'action': 'get_logs', 'target': 'prisma'} + if 'artifacts' in p or 'screenshot' in p or 'artifact' in p: + return {'action': 'list_artifacts', 'target': 'artifacts'} + if 'screenshot' in p or 'imagen' in p or 'captura' in p: + return {'action': 'get_screenshot', 'target': 'screenshot'} + if 'crear' in p or 'create session' in p or 'start session' in p or 'crear sesión' in p: + return {'action': 'run_session', 'target': 'backend', 'room': 'test-room', 'username': 'e2e-agent'} + # default: fallback to all logs + return {'action': 'get_logs', 'target': 'all'} + + +def handle_action(mapping: Dict[str, object], lines: int = 200) -> str: + action = mapping.get('action') + target = mapping.get('target') + output_parts: List[str] = [] + if action == 'get_logs': + targets = [target] if target != 'all' else list(LOG_FILES.keys()) + for t in targets: + output_parts.append(f'==== LOGS: {t} ====' ) + paths = LOG_FILES.get(t, []) + if not paths: + output_parts.append('(no configured paths)') + continue + for p in paths: + # if path points to directory, skip + if os.path.isdir(p): + output_parts.append(f'(es dir) {p}:') + output_parts.append(list_artifact_dir(p)) + continue + output_parts.append(f'-- file: {p} --') + output_parts.append(tail_file(p, lines)) + return '\n'.join(output_parts) + if action == 'list_artifacts': + p = LOG_FILES.get('artifacts', ['/tmp/e2e-artifacts'])[0] + return list_artifact_dir(p) + if action == 'get_screenshot': + parts = [] + for p in LOG_FILES.get('screenshot', []): + parts.append(f'-- screenshot candidate: {p} --') + if os.path.exists(p): + try: + st = os.stat(p) + parts.append(f'exists: size={st.st_size} bytes') + except Exception as e: + parts.append(f'error stat: {e}') + else: + parts.append('(no existe)') + return '\n'.join(parts) + if action == 'run_session': + # invoke the playwright python runner with create-session + backend = mapping.get('backend', 'http://localhost:4000') + room = mapping.get('room', 'test-room') + username = mapping.get('username', 'e2e-agent') + out = os.path.join(REPO_OUT_DIR, 'py-playwright-shot-from-agent.png') + # if ws endpoint provided in mapping or env, include it + ws = mapping.get('ws') or os.environ.get('PLAYWRIGHT_WS') + cmd = ['python3', 'packages/broadcast-panel/e2e/playwright_py_runner.py', '--create-session', '--backend', backend, '--room', room, '--username', username, '--out', out] + if ws: + cmd.extend(['--ws', ws]) + output_parts.append(f'Running: {shlex.join(cmd)}') + code, outp, err = run_cmd(cmd, timeout=120) + output_parts.append(f'Exit {code}') + if outp: + output_parts.append('STDOUT:') + output_parts.append(outp) + if err: + output_parts.append('STDERR:') + output_parts.append(err) + if os.path.exists(out): + st = os.stat(out) + output_parts.append(f'Screenshot generated: {out} (size={st.st_size} bytes)') + else: + output_parts.append('No screenshot generated') + return '\n'.join(output_parts) + + return f'Unknown action: {action} for target {target}' + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--prompt', '-p', help='Prompt en lenguaje natural que indica qué logs quieres') + ap.add_argument('--lines', type=int, default=200, help='Número de líneas a mostrar por log') + ap.add_argument('--interactive', action='store_true', help='Modo interactivo (leer prompts hasta Ctrl+C)') + ap.add_argument('--log-file', default=None, help='Ruta de archivo donde se guardará el resultado (append). Si no se proporciona, solo se imprime en stdout') + ap.add_argument('--overwrite-log', action='store_true', help='Si se usa --log-file y --overwrite-log, el archivo será sobrescrito en lugar de anexado') + ap.add_argument('--backend', default=None, help='URL base del backend (ej. http://localhost:4000). Si se especifica, será usada por run_session') + ap.add_argument('--ws', default=None, help='Playwright run-server WS endpoint (ej. ws://192.168.1.20:3003). Si se especifica, será usada por run_session') + args = ap.parse_args() + + # Normalize log-file: prefer a repo-relative ./.tmp directory to avoid /tmp space issues + # If user provided an absolute /tmp path, convert to ./.tmp/ + if args.log_file: + lf = args.log_file + try: + if os.path.isabs(lf) and lf.startswith('/tmp'): + lf = os.path.join('.tmp', os.path.basename(lf)) + except Exception: + pass + args.log_file = lf + else: + # default relative path inside repo + args.log_file = os.path.join('.tmp', 'gemini_agent_output.log') + + # Main loop: interactively process prompts or single prompt from args + try: + if args.interactive: + print("Modo interactivo. Escribe tu prompt (o 'exit' para salir):") + while True: + try: + prompt = input('> ') + if prompt.lower() in ['exit', 'quit', 'salir']: + break + mapping = interpret_prompt(prompt) + result = handle_action(mapping, args.lines) + print(result) + except Exception as e: + print(f'Error procesando prompt: {e}', file=sys.stderr) + else: + # un solo prompt desde args + mapping = interpret_prompt(args.prompt or '') + result = handle_action(mapping, args.lines) + print(result) + # write to log-file if requested + try: + if args.log_file: + log_dir = os.path.dirname(args.log_file) or '.' + os.makedirs(log_dir, exist_ok=True) + mode = 'w' if args.overwrite_log else 'a' + from datetime import datetime + header = f"[{datetime.utcnow().isoformat()}Z] PROMPT: {args.prompt}\n" + with open(args.log_file, mode, encoding='utf-8') as f: + f.write(header) + f.write(result) + f.write('\n') + except Exception as e: + print(f"[agent][log-error] Failed to write log file {args.log_file}: {e}", file=sys.stderr) + except Exception as e: + print(f'Error en el agente: {e}', file=sys.stderr) + diff --git a/packages/broadcast-panel/e2e/playwright-token-e2e.spec.ts b/packages/broadcast-panel/e2e/playwright-token-e2e.spec.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/broadcast-panel/e2e/playwright_connect.mjs b/packages/broadcast-panel/e2e/playwright_connect.mjs new file mode 100644 index 0000000..55192d1 --- /dev/null +++ b/packages/broadcast-panel/e2e/playwright_connect.mjs @@ -0,0 +1,109 @@ +// packages/broadcast-panel/e2e/playwright_connect.mjs +import { chromium } from 'playwright'; + +const DEFAULT_WS = process.env.PW_WS || 'ws://192.168.1.20:3003'; +const BROADCAST_URL = process.env.BROADCAST_URL || 'http://localhost:5175'; +const OUTSHOT = process.env.OUTSHOT || '/tmp/playwright_final.png'; + +function sleep(ms){return new Promise(r=>setTimeout(r,ms));} + +async function clickByText(page, text){ + try{ + const locator = page.locator(`text=${text}`); + const count = await locator.count(); + if(count>0){ + await locator.first().click({timeout:5000}); + return true; + } + }catch(e){ + console.error('clickByText error', text, e && e.message); + } + return false; +} + +(async ()=>{ + console.log('Playwright E2E starting'); + console.log('ENV PW_WS=', process.env.PW_WS, 'PW_WS fallback=', DEFAULT_WS); + console.log('ENV BROADCAST_URL=', process.env.BROADCAST_URL, 'BROADCAST_URL fallback=', BROADCAST_URL); + console.log('OUTSHOT=', OUTSHOT); + + console.log('Attempting to connect to Playwright WS endpoint:', DEFAULT_WS); + let browser; + try{ + // increase timeout to 30s for unstable networks + browser = await chromium.connect({ wsEndpoint: DEFAULT_WS, timeout: 30000 }); + console.log('Connected to Playwright server'); + }catch(e){ + console.error('Failed to connect to Playwright server:', e && (e.stack || e.message)); + console.error('Tip: verify that the Playwright server is running and reachable from this host.'); + process.exit(2); + } + + try{ + console.log('Creating context...'); + const context = await browser.newContext(); + console.log('Creating page...'); + const page = await context.newPage(); + page.on('console', msg => console.log('PAGE LOG:', msg.text())); + page.on('pageerror', err => console.error('PAGE ERROR:', err && err.toString())); + + console.log('Navigating to', BROADCAST_URL); + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle', timeout: 60000 }); + console.log('Navigation finished, current URL:', page.url()); + await sleep(1000); + + if(await clickByText(page, 'Entrar al Estudio')){ + console.log('Clicked "Entrar al Estudio"'); + await sleep(1500); + } else { + console.log('Entrar al Estudio not found, trying creation flow...'); + // try several create/transmit buttons + const created = await (async ()=>{ + if(await clickByText(page, 'Crear transmisión')) return true; + if(await clickByText(page, 'Crear transmisión en vivo')) return true; + if(await clickByText(page, 'Nueva transmisión')) return true; + if(await clickByText(page, 'Crear')) return true; + if(await clickByText(page, 'Transmitir')) return true; + return false; + })(); + + if(created){ + console.log('Opened creation modal; trying "Omitir por ahora"'); + await sleep(800); + await clickByText(page, 'Omitir por ahora').catch((e)=>console.error('omit click failed', e && e.message)); + await sleep(300); + // fill input + try{ + const input = await page.locator('input').first(); + await input.fill('Transmitir'); + console.log('Filled input with Transmitir'); + }catch(e){ + console.error('Input fill error', e && e.message); + } + await clickByText(page, 'Empezar ahora').catch(()=>{}); + await sleep(800); + await clickByText(page, 'Entrar al Estudio').catch(()=>{}); + await sleep(1500); + } else { + console.log('Could not open creation modal — manual check required'); + } + } + + console.log('Taking screenshot to', OUTSHOT); + try{ + await page.screenshot({ path: OUTSHOT, fullPage: true }); + console.log('Screenshot saved:', OUTSHOT); + }catch(e){ + console.error('Screenshot failed:', e && (e.stack || e.message)); + } + + await context.close(); + try{ await browser.close(); }catch(e){ console.error('browser.close error', e && e.message); } + console.log('E2E finished successfully'); + process.exit(0); + }catch(err){ + console.error('Error during flow:', err && (err.stack || err.message)); + try{ await browser.close(); }catch(e){ console.error('Error closing browser after failure', e && e.message); } + process.exit(1); + } +})(); diff --git a/packages/broadcast-panel/e2e/playwright_py_runner.py b/packages/broadcast-panel/e2e/playwright_py_runner.py new file mode 100644 index 0000000..94f46e9 --- /dev/null +++ b/packages/broadcast-panel/e2e/playwright_py_runner.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +""" +playwright_py_runner.py + +Runner E2E simple usando Playwright (Python). Funciona en modo local (lanza Chromium) y puede usarse +para ver logs en tiempo real, tomar capturas y ejecutar pasos configurables. + +Uso: + # instalar dependencias (una sola vez) + python3 -m pip install --user playwright requests + python3 -m playwright install chromium + + # ejecutar y pedir al backend crear una sesión + python3 packages/broadcast-panel/e2e/playwright_py_runner.py --create-session --backend http://localhost:4000 --room test-room --username tester --out /tmp/py-playwright-shot.png + +Opciones (resumen): + --url URL URL del frontend (por defecto http://localhost:5176) (usado si no se crea sesión) + --out PATH Ruta para guardar captura (por defecto /tmp/py-playwright-shot.png) + --headful Abrir navegador en modo no headless (útil para debugging) + --wait N Tiempo en segundos a esperar entre pasos (default 1) + --ws WS_ENDPOINT Conectar a Playwright run-server remoto (ws://host:port) + --create-session Hacer POST a BACKEND/api/session y abrir la redirectUrl devuelta + --backend URL Backend base (por defecto http://localhost:4000) + --room NAME Nombre de la sala para crear session (por defecto 'test-room') + --username NAME Username para crear session (por defecto 'e2e-py') + +El script imprime logs sencillos por stdout (progreso de pasos y errores). +""" +import sys +import argparse +import time +import json +import os +from pathlib import Path + +try: + from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError +except Exception as e: + print("ERROR: Playwright Python no está instalado. Ejecuta:\n python3 -m pip install --user playwright\n python3 -m playwright install chromium\n") + raise + +# Try to import requests, fallback to urllib +try: + import requests # type: ignore + HAS_REQUESTS = True +except Exception: + import urllib.request as _urllib_request + HAS_REQUESTS = False + + +def create_session_via_backend(backend_base: str, room: str, username: str, ttl: int | None = None, headers: dict | None = None): + url = backend_base.rstrip('/') + '/api/session' + payload = { 'room': room, 'username': username } + if ttl: + payload['ttl'] = int(ttl) + print(f"[backend] Creating session at {url} with payload {payload}") + try: + if HAS_REQUESTS: + resp = requests.post(url, json=payload, headers=headers or {}, timeout=15) + try: + data = resp.json() + except Exception: + data = { 'status_code': resp.status_code, 'text': resp.text } + return resp.status_code, data + else: + req = _urllib_request.Request(url, data=json.dumps(payload).encode('utf-8'), headers={'Content-Type':'application/json', **(headers or {})}, method='POST') + with _urllib_request.urlopen(req, timeout=15) as r: + b = r.read() + try: + data = json.loads(b.decode('utf-8')) + except Exception: + data = { 'text': b.decode('utf-8', errors='replace') } + return r.getcode(), data + except Exception as e: + print('[backend] Request failed:', e) + return None, { 'error': str(e) } + + +def run(url: str, out_path: str, headful: bool, wait_seconds: float, ws_endpoint: str | None = None, create_session: bool = False, backend: str | None = None, room: str | None = None, username: str | None = None): + print(f"[runner] Starting Playwright runner (headful={headful})") + + # If requested, create session first and override navigation URL + if create_session: + backend_base = backend or 'http://localhost:4000' + status, data = create_session_via_backend(backend_base, room or 'test-room', username or 'e2e-py') + print('[backend] create response:', status, data) + if status and status >= 200 and status < 300: + # Prefer redirectUrl, then studioUrl, else fall back to token url or provided url + nav = data.get('redirectUrl') or data.get('studioUrl') or data.get('url') or url + print(f"[runner] Will navigate to backend-provided URL: {nav}") + url = nav + else: + print('[runner] Backend session creation failed; will navigate to provided URL instead') + + with sync_playwright() as p: + browser = None + try: + # If a ws endpoint is provided, try to connect to remote Playwright run-server + if ws_endpoint: + print(f"[runner] Attempting to connect to Playwright server at {ws_endpoint}") + try: + browser = p.connect(ws_endpoint=ws_endpoint) + print('[runner] Connected to remote Playwright server') + except Exception as conn_err: + print('[runner] Remote connect failed, falling back to local launch:', conn_err) + browser = p.chromium.launch(headless=not headful) + else: + browser = p.chromium.launch(headless=not headful) + + context = browser.new_context() + page = context.new_page() + + page.on("console", lambda msg: print(f"[page console] {msg.type}: {msg.text}")) + page.on("pageerror", lambda exc: print(f"[page error] {exc}")) + + print(f"[runner] Navigating to {url}") + page.goto(url, wait_until="networkidle", timeout=30000) + print(f"[runner] Page loaded: {page.url}") + time.sleep(wait_seconds) + + # Try common flows: click 'Entrar al Estudio' if exists + try_click_text(page, 'Entrar al Estudio') + time.sleep(wait_seconds) + + # If creation modal flow (try alternative selectors) + if try_click_text(page, 'Crear transmisión'): + print('[runner] Opened create modal (Crear transmisión)') + time.sleep(wait_seconds) + try_click_text(page, 'Omitir por ahora') + time.sleep(0.5) + # try to fill first input with 'Transmitir' + try: + inp = page.query_selector('input') + if inp: + inp.fill('Transmitir') + print('[runner] Filled input with "Transmitir"') + except Exception as e: + print('[runner] Input fill error', e) + try_click_text(page, 'Empezar ahora') + time.sleep(wait_seconds) + try_click_text(page, 'Entrar al Estudio') + time.sleep(wait_seconds) + + # Final: take screenshot + outp = Path(out_path) + outp.parent.mkdir(parents=True, exist_ok=True) + print(f"[runner] Taking screenshot -> {outp}") + page.screenshot(path=str(outp), full_page=True) + print(f"[runner] Screenshot saved: {outp}") + + # Optionally log current cookies / localStorage + try: + cookies = context.cookies() + print(f"[runner] Cookies: {len(cookies)} entries") + except Exception: + pass + + context.close() + return True + except PlaywrightTimeoutError as te: + print('[runner][error] Timeout', te) + return False + except Exception as ex: + print('[runner][error] Exception', ex) + return False + finally: + try: + if browser: + browser.close() + except Exception: + pass + + +def try_click_text(page, text: str) -> bool: + try: + locator = page.locator(f"text={text}") + if locator.count() > 0: + locator.first.click(timeout=5000) + print(f"[runner] Clicked text: '{text}'") + return True + else: + print(f"[runner] Text not found: '{text}'") + return False + except Exception as e: + print(f"[runner] Click error for '{text}': {e}") + return False + + +if __name__ == '__main__': + ap = argparse.ArgumentParser() + default_out = os.path.join(os.path.dirname(__file__), 'out', 'py-playwright-shot.png') + os.makedirs(os.path.dirname(default_out), exist_ok=True) + ap.add_argument('--url', default='http://localhost:5176', help='URL del frontend (por defecto http://localhost:5176)') + ap.add_argument('--out', default=default_out, help='Ruta de captura') + ap.add_argument('--headful', action='store_true', help='Abrir navegador en modo no headless') + ap.add_argument('--wait', type=float, default=1.0, help='Segundos de espera entre pasos') + ap.add_argument('--ws', default=None, help='WebSocket endpoint del Playwright run-server (ej. ws://localhost:3003)') + ap.add_argument('--create-session', action='store_true', help='Pedir al backend que cree una session y navegar a la redirectUrl') + ap.add_argument('--backend', default='http://localhost:4000', help='Backend base URL') + ap.add_argument('--room', default='test-room', help='Room name para crear session') + ap.add_argument('--username', default='e2e-py', help='Username para crear session') + args = ap.parse_args() + + try: + ok = run(args.url, args.out, args.headful, args.wait, args.ws, args.create_session, args.backend, args.room, args.username) + if ok: + print('[runner] Finished successfully') + sys.exit(0) + else: + print('[runner] Finished with errors') + sys.exit(2) + except Exception as e: + import traceback + print('[runner][fatal] Unhandled exception:') + traceback.print_exc() + sys.exit(3) diff --git a/packages/broadcast-panel/e2e/puppeteer_browserless_e2e.cjs b/packages/broadcast-panel/e2e/puppeteer_browserless_e2e.cjs new file mode 100644 index 0000000..a6a0237 --- /dev/null +++ b/packages/broadcast-panel/e2e/puppeteer_browserless_e2e.cjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +// E2E test (CommonJS) with instrumentation: create session on backend-api, connect to browserless, +// open Broadcast Panel, automate UI (create transmission if needed), postMessage token and wait for Studio overlay. +// Instrumentation: capture page console, page errors, network requests/responses, trace, HTML snapshot, screenshots and logs. +const fs = require('fs') +const path = require('path') +const fetch = require('node-fetch') +const puppeteer = require('puppeteer-core') + +const ARTIFACT_DIR = '/tmp/avanzacast_e2e' +if (!fs.existsSync(ARTIFACT_DIR)) fs.mkdirSync(ARTIFACT_DIR, { recursive: true }) + +function now() { return new Date().toISOString().replace(/[:.]/g, '-') } + +;(async () => { + const START_TS = Date.now() + const logFile = path.join(ARTIFACT_DIR, 'e2e.log') + try { fs.writeFileSync(logFile, '') } catch(e){} + const log = (msg, ...rest) => { + const t = new Date().toISOString() + const extra = rest && rest.length ? ' ' + rest.map(r => (typeof r === 'string' ? r : JSON.stringify(r))).join(' ') : '' + const line = `[${t}] ${msg}${extra}` + console.log(line) + try { fs.appendFileSync(logFile, line + '\n') } catch(e){} + } + + try { + const BACKEND = process.env.TOKEN_SERVER || 'http://localhost:4000' + const BROADCAST_URL = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS || process.env.TOKEN || '' + if (!BROWSERLESS_TOKEN) { + log('BROWSERLESS_TOKEN not set in env (use BROWSERLESS_TOKEN=...)') + process.exit(1) + } + + // timeouts and retries + const OVERLAY_WAIT_MS = 60 * 1000 // 60s + const POLL_INTERVAL_MS = 1000 + + log('Creating session on backend:', BACKEND) + const res = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: 'e2e-room', username: 'e2e-runner' }) + }) + log('Session create status:', res.status) + const raw = await res.text().catch(() => '') + log('Session raw response:', raw) + if (!res.ok) { + const t = raw + throw new Error('Failed to create session: ' + res.status + ' ' + t) + } + const j = JSON.parse(raw) + log('Parsed session keys:', Object.keys(j)) + const livekitToken = j.token + const livekitUrl = j.url || j.studioUrl || j.redirectUrl || '' + log('Got session token (trunc):', livekitToken ? livekitToken.slice(0, 40) + '...' : '(none)') + + const wsEndpoint = `wss://browserless.bfzqqk.easypanel.host?token=${BROWSERLESS_TOKEN}` + log('Connecting to browserless WS endpoint:', wsEndpoint) + const browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1400, height: 900 } }) + const page = await browser.newPage() + + // Instrumentation containers + const networkEvents = [] + const consoleEvents = [] + const pageErrors = [] + + // Capture console + page.on('console', async msg => { + try { + const args = [] + for (let i = 0; i < msg.args().length; i++) { + try { const a = await msg.args()[i].jsonValue(); args.push(a) } catch(e) { args.push(msg.args()[i].toString()) } + } + const text = msg.text() + consoleEvents.push({ ts: Date.now(), type: msg.type(), text, args }) + log('[page console]', msg.type(), text, args.length ? JSON.stringify(args) : '') + } catch(e) { log('[page console error]', e.message) } + }) + page.on('pageerror', err => { + pageErrors.push({ ts: Date.now(), error: String(err && err.stack ? err.stack : err) }) + log('[page error]', err && err.stack ? err.stack : String(err)) + }) + + // Capture network requests/responses + page.on('request', req => { + try { networkEvents.push({ type: 'request', ts: Date.now(), url: req.url(), method: req.method(), headers: req.headers() }) } catch(e){} + }) + page.on('response', async res => { + try { + const url = res.url() + const status = res.status() + const headers = res.headers() + let body = '' + try { + const ct = headers['content-type'] || '' + if (ct.includes('application/json') || ct.includes('text/') || ct.includes('application/javascript')) { + body = await res.text() + if (body && body.length > 2000) body = body.slice(0, 2000) + '...[truncated]' + } + } catch(e) { body = '[error reading body]' } + networkEvents.push({ type: 'response', ts: Date.now(), url, status, headers, body }) + } catch(e){} + }) + page.on('requestfailed', req => { networkEvents.push({ type: 'requestfailed', ts: Date.now(), url: req.url(), err: req.failure && req.failure.errorText }) }) + + // Start tracing + const traceFile = path.join(ARTIFACT_DIR, `trace-${now()}.json`) + try { + await page.tracing.start({ path: traceFile, screenshots: true }) + log('Started page tracing ->', traceFile) + } catch (e) { log('Tracing start failed:', e.message) } + + // Navigate with token in URL (preferred) + const encodedUrl = encodeURIComponent(livekitUrl || '') + const urlWithToken = `${BROADCAST_URL.replace(/\/$/, '')}?token=${encodeURIComponent(livekitToken || '')}&url=${encodedUrl}&room=${encodeURIComponent(j.room || 'e2e-room')}` + log('Navigating to broadcast URL with token') + await page.goto(urlWithToken, { waitUntil: 'networkidle2', timeout: 120000 }) + + // Give SPA time to hydrate + await page.waitForTimeout(1500) + + // Helper to try clicking buttons by text (robust) + async function clickButtonByText(texts, timeout = 3000) { + const start = Date.now() + while (Date.now() - start < timeout) { + for (const t of texts) { + const handles = await page.$x(`//button[contains(normalize-space(.), "${t}")]`) + if (handles && handles.length > 0) { + try { await handles[0].click(); log('Clicked button', t); return true } catch(e) { log('Click error', e.message) } + } + } + await page.waitForTimeout(300) + } + return false + } + + // Wait for UI + try { await page.waitForFunction(() => !!document.querySelector('body'), { timeout: 20000 }); log('Page body present') } catch(e) { log('Page body missing?') } + + // Try to click Entrar al estudio and follow creation flow + const clickedEntrar = await clickButtonByText(['Entrar al estudio'], 8000) + if (clickedEntrar) { + log('Clicked Entrar al estudio (first attempt)') + // click 'Omitir ahora' if appears + await clickButtonByText(['Omitir ahora', 'Omitar ahora', 'Skip for now', 'Skip'], 4000) + // fill modal input + try { + const inputHandle = await page.$(".modal input, .Dialog input, dialog input, input[placeholder*='Título'], input[placeholder*='titulo'], input[placeholder*='Transmi'], input[type='text']") + if (inputHandle) { try { await inputHandle.click({ clickCount: 3 }) } catch(e){}; await inputHandle.type('Transmision en Vivo', { delay: 40 }); log('Filled transmission title input') } + } catch(e){ log('fill title failed', e.message) } + await clickButtonByText(['Empezar ahora', 'Empezar', 'Iniciar', 'Start now', 'Start'], 5000) + await page.waitForTimeout(800) + await clickButtonByText(['Entrar al estudio'], 5000) + } else { + log('Entrar al estudio not clickable initially; proceeding to postMessage fallback') + } + + // Post token via postMessage + const payload = { type: 'LIVEKIT_TOKEN', token: livekitToken, url: livekitUrl, room: j.room || 'e2e-room' } + log('Posting payload (trunc):', { type: payload.type, token: payload.token ? payload.token.slice(0,40) + '...' : null, url: payload.url, room: payload.room }) + try { await page.evaluate((p) => { window.postMessage(p, window.location.origin); return true }, payload); log('postMessage executed') } catch(e) { log('postMessage failed', e.message) } + + // Wait for overlay with polling + const overlaySelectors = ['.studio-portal', '.studioOverlay', '.studio-portal__center', '.studio-overlay'] + const overlayStart = Date.now() + let overlayFound = false + while (Date.now() - overlayStart < OVERLAY_WAIT_MS) { + for (const sel of overlaySelectors) { + const el = await page.$(sel) + if (el) { overlayFound = true; log('Overlay detected by selector', sel); break } + } + if (overlayFound) break + await page.waitForTimeout(POLL_INTERVAL_MS) + } + + if (!overlayFound) log('Overlay not detected within timeout, capturing extra artifacts for debugging') + else log('Overlay successfully detected') + + // Save artifacts + const screenshotPath = path.join(ARTIFACT_DIR, `screenshot-${now()}.png`) + await page.screenshot({ path: screenshotPath, fullPage: false }) + log('Screenshot saved to', screenshotPath) + + try { const html = await page.content(); const htmlPath = path.join(ARTIFACT_DIR, `page-${now()}.html`); fs.writeFileSync(htmlPath, html); log('Saved page HTML to', htmlPath) } catch(e){ log('Failed saving HTML', e.message) } + try { const netPath = path.join(ARTIFACT_DIR, `network-${now()}.json`); fs.writeFileSync(netPath, JSON.stringify(networkEvents, null, 2)); log('Saved network events to', netPath) } catch(e){ log('Failed saving network events', e.message) } + try { const consolePath = path.join(ARTIFACT_DIR, `console-${now()}.json`); fs.writeFileSync(consolePath, JSON.stringify(consoleEvents, null, 2)); log('Saved console events to', consolePath) } catch(e){ log('Failed saving console events', e.message) } + try { const pageErrorsPath = path.join(ARTIFACT_DIR, `page-errors-${now()}.json`); fs.writeFileSync(pageErrorsPath, JSON.stringify(pageErrors, null, 2)); log('Saved page errors to', pageErrorsPath) } catch(e){ log('Failed saving page errors', e.message) } + + try { await page.tracing.stop(); log('Stopped tracing; trace saved to', traceFile) } catch(e){ log('Tracing stop failed', e.message) } + + await browser.close() + log('Browser closed') + + const duration = (Date.now() - START_TS) / 1000 + log('E2E run finished in', duration + 's') + + const files = fs.readdirSync(ARTIFACT_DIR).map(f => path.join(ARTIFACT_DIR, f)) + log('Artifacts produced:', files) + process.exit(overlayFound ? 0 : 3) + + } catch (err) { + try { fs.appendFileSync(path.join(ARTIFACT_DIR, 'e2e.log'), String(err && err.stack ? err.stack : err) + '\n') } catch(e){} + console.error('E2E failed:', err && err.stack ? err.stack : err) + process.exit(2) + } +})() diff --git a/packages/broadcast-panel/e2e/puppeteer_browserless_e2e.js b/packages/broadcast-panel/e2e/puppeteer_browserless_e2e.js new file mode 100644 index 0000000..6029ac9 --- /dev/null +++ b/packages/broadcast-panel/e2e/puppeteer_browserless_e2e.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// E2E test: create session on backend-api, connect to browserless, open Broadcast Panel, postMessage token and wait for Studio overlay +const fs = require('fs') +const fetch = global.fetch || require('node-fetch') +const puppeteer = require('puppeteer-core') + +(async () => { + try { + const BACKEND = process.env.TOKEN_SERVER || 'http://localhost:4000' + const BROADCAST_URL = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host' + const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS || process.env.TOKEN || '' + if (!BROWSERLESS_TOKEN) { + console.error('BROWSERLESS_TOKEN not set in env (use BROWSERLESS_TOKEN=...)') + process.exit(1) + } + + console.log('Creating session on backend:', BACKEND) + const res = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: 'e2e-room', username: 'e2e-runner' }) + }) + if (!res.ok) { + const t = await res.text().catch(() => '') + throw new Error('Failed to create session: ' + res.status + ' ' + t) + } + const j = await res.json() + const livekitToken = j.token + const livekitUrl = j.url || j.studioUrl || j.redirectUrl || '' + console.log('Got session token (trunc):', livekitToken ? livekitToken.slice(0, 40) + '...' : '(none)') + + const wsEndpoint = `wss://browserless.bfzqqk.easypanel.host?token=${BROWSERLESS_TOKEN}` + console.log('Connecting to browserless WS endpoint:', wsEndpoint) + const browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1280, height: 800 } }) + const page = await browser.newPage() + console.log('Opening Broadcast Panel page:', BROADCAST_URL) + await page.goto(BROADCAST_URL, { waitUntil: 'networkidle2', timeout: 60000 }) + console.log('Page loaded, posting LIVEKIT_TOKEN via window.postMessage') + const payload = { type: 'LIVEKIT_TOKEN', token: livekitToken, url: livekitUrl, room: j.room || 'e2e-room' } + await page.evaluate((p) => window.postMessage(p, window.location.origin), payload) + + // Wait for overlay .studio-portal or validation overlay + console.log('Waiting for studio overlay or token indicator...') + try { + await page.waitForSelector('.studio-portal, .studioOverlay, .studio-portal__center, .studio-overlay', { timeout: 15000 }) + console.log('Studio overlay appeared!') + } catch (err) { + console.warn('Studio overlay not detected within timeout. Will check for StudioPortal token indicator text') + // Check for text 'Token recibido' in page + const found = await page.evaluate(() => !!document.querySelector('.studio-portal') || !![...document.querySelectorAll('*')].some(el => el.textContent && el.textContent.includes('Token recibido'))) + if (!found) { + console.error('Studio overlay/token not found') + } else { + console.log('Found token indicator text') + } + } + + const screenshotPath = '/tmp/avanzacast_studio_e2e.png' + await page.screenshot({ path: screenshotPath, fullPage: false }) + console.log('Screenshot saved to', screenshotPath) + + await browser.close() + console.log('E2E run finished') + } catch (err) { + console.error('E2E failed:', err) + process.exit(2) + } +})() + diff --git a/packages/broadcast-panel/e2e/puppeteer_connect_debug.mjs b/packages/broadcast-panel/e2e/puppeteer_connect_debug.mjs new file mode 100644 index 0000000..0285ace --- /dev/null +++ b/packages/broadcast-panel/e2e/puppeteer_connect_debug.mjs @@ -0,0 +1,116 @@ +// puppeteer_connect_debug.mjs +// Conecta a un Chrome con --remote-debugging-port=9222 y ejecuta el flujo descrito +// Uso: node --input-type=module packages/broadcast-panel/e2e/puppeteer_connect_debug.mjs + +import puppeteer from 'puppeteer-core'; + +const DEBUG_BROWSER_URL = process.env.DEBUG_BROWSER_URL || 'http://127.0.0.1:9222'; +const APP_URL = process.env.BROADCAST_URL || 'http://localhost:5175'; + +function byTextXPath(text){ + // case-insensitive contains + return `//*[contains(translate(normalize-space(string(.)), 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), '${text.toLowerCase()}')]`; +} + +async function clickByText(page, text, timeout = 5000){ + const xpath = byTextXPath(text); + const els = await page.$x(xpath); + if(els && els.length){ + await els[0].click(); + return true; + } + return false; +} + +async function run(){ + console.log('Conectando a browser en', DEBUG_BROWSER_URL); + const browser = await puppeteer.connect({ browserURL: DEBUG_BROWSER_URL, defaultViewport: null }); + const page = await browser.newPage(); + page.on('console', msg => console.log('PAGE LOG:', msg.text())); + + console.log('Navegando a', APP_URL); + await page.goto(APP_URL, { waitUntil: 'networkidle2', timeout: 60000 }); + await page.waitForTimeout(1000); + + // Intentar click en "Entrar al Estudio" si visible + const tryEntrar = await clickByText(page, 'Entrar al Estudio'); + if(tryEntrar){ + console.log('Click en Entrar al Estudio (homepage)'); + await page.waitForTimeout(1500); + } else { + console.log('No se encontró Entrar al Estudio directamente, intentando flujo de crear transmisión.'); + + // Intentar abrir modal: buscar botones que contengan "Crear" o "Nueva" o "Transmisión" + const opened = await (async ()=>{ + if(await clickByText(page, 'Crear transmisión')) return true; + if(await clickByText(page, 'Crear transmisión en vivo')) return true; + if(await clickByText(page, 'Nueva transmisión')) return true; + if(await clickByText(page, 'Crear')) return true; + if(await clickByText(page, 'Transmitir')) return true; + return false; + })(); + + if(opened) { + console.log('Modal abierto, esperando...'); + await page.waitForTimeout(1000); + // Buscar botón "Omitir por ahora" + if(await clickByText(page, 'Omitir por ahora')){ + console.log('Omitir por ahora clicado'); + await page.waitForTimeout(500); + } + + // Buscar input y poner 'Transmitir' + // Intentar seleccionar un input placeholder común + const inputs = await page.$$('input'); + if(inputs && inputs.length){ + // buscar input visible vacío + for(const inp of inputs){ + try{ + const val = await (await inp.getProperty('value')).jsonValue(); + const visible = await inp.boundingBox(); + if(visible && (val === '' || val === null)){ + await inp.focus(); + await page.keyboard.type('Transmitir', { delay: 100 }); + console.log('Rellenado input con Transmitir'); + break; + } + }catch(e){ /* ignore */ } + } + } + + // Click en "Empezar ahora" + if(await clickByText(page, 'Empezar ahora')){ + console.log('Click Empezar ahora'); + await page.waitForTimeout(1500); + } + + // Finalmente intentar Entrar al Estudio + if(await clickByText(page, 'Entrar al Estudio')){ + console.log('Click Entrar al Estudio desde modal'); + await page.waitForTimeout(2000); + } else { + console.log('No se pudo encontrar Entrar al Estudio tras modal'); + } + } else { + console.log('No pude abrir el modal de creación — inténtalo manualmente o revisa selectores.'); + } + } + + // Tomar screenshot final para revisión + const out = '/tmp/puppeteer_final.png'; + await page.screenshot({ path: out, fullPage: true }); + console.log('Screenshot guardado en', out); + + // Mantener la conexión abierta un tiempo para que puedas visualizar en la ventana real + console.log('Proceso completado. Mantendré la página abierta 30s para inspección interactiva...'); + await page.waitForTimeout(30000); + + try{ await browser.disconnect(); }catch(e){/* ignore */} + console.log('Desconectado. Script finalizado.'); +} + +run().catch(err=>{ + console.error('Error en script:', err); + process.exit(1); +}); + diff --git a/packages/broadcast-panel/e2e/puppeteer_local_debug.cjs b/packages/broadcast-panel/e2e/puppeteer_local_debug.cjs new file mode 100644 index 0000000..b6aa6e5 --- /dev/null +++ b/packages/broadcast-panel/e2e/puppeteer_local_debug.cjs @@ -0,0 +1,49 @@ +const puppeteer = require('puppeteer-core'); + +(async () => { + try { + console.log('Conectando a Chrome remoto en http://localhost:9222 ...'); + const browser = await puppeteer.connect({ browserURL: 'http://localhost:9222', defaultViewport: null }); + console.log('Conectado a Chrome remoto. Abriendo nueva pestaña...'); + const page = await browser.newPage(); + await page.goto('http://localhost:5175', { waitUntil: 'networkidle2', timeout: 30000 }); + console.log('Página cargada: http://localhost:5175'); + + // Intentar localizar botón por aria-label o por texto + const buttons = await page.$x("//button[contains(normalize-space(.), 'Entrar al estudio') or contains(@aria-label, 'Entrar al estudio')]"); + if (!buttons || buttons.length === 0) { + console.log('No se encontró botón "Entrar al estudio" en la página. Buscando botones que contengan "Entrar"...'); + const fallback = await page.$x("//button[contains(normalize-space(.), 'Entrar') or contains(@aria-label, 'Entrar')]"); + if (!fallback || fallback.length === 0) { + console.log('No se encontró botón de entrada. Termino la ejecución.'); + await browser.disconnect(); + process.exit(0); + } else { + console.log('Se encontró botón fallback; haré click en el primero.'); + await fallback[0].click(); + } + } else { + console.log('Botón encontrado. Realizando click...'); + await buttons[0].click(); + } + + // Esperar que la sessionStorage contenga la clave usada por useStudioLauncher + const storeKey = 'avanzacast_studio_session'; + try { + await page.waitForFunction((k) => !!sessionStorage.getItem(k), { timeout: 10000 }, storeKey); + const val = await page.evaluate((k) => sessionStorage.getItem(k), storeKey); + console.log('sessionStorage key found:', storeKey); + console.log('Valor (truncado):', val ? (val.length > 300 ? val.slice(0,300) + '... (truncated)' : val) : val); + } catch (err) { + console.log('No se detectó la clave en sessionStorage dentro del timeout de 10s.'); + } + + console.log('El script se desconecta pero deja Chrome abierto para inspección manual.'); + await browser.disconnect(); + process.exit(0); + } catch (err) { + console.error('Error en el script de puppeteer:', err); + process.exit(1); + } +})(); + diff --git a/packages/broadcast-panel/e2e/run_browserless_e2e.js b/packages/broadcast-panel/e2e/run_browserless_e2e.js new file mode 100644 index 0000000..62da3b3 --- /dev/null +++ b/packages/broadcast-panel/e2e/run_browserless_e2e.js @@ -0,0 +1,211 @@ +// filepath: packages/broadcast-panel/e2e/run_browserless_e2e.js +import fetch from 'node-fetch'; +import puppeteer from 'puppeteer-core'; +import fs from 'fs'; +import path from 'path'; + +async function main() { + const BROWSERLESS_WS = process.env.BROWSERLESS_WS || process.env.BROWSERLESS_URL || 'wss://browserless.bfzqqk.easypanel.host'; + const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS_KEY || ''; + const TOKEN_SERVER = process.env.TOKEN_SERVER || 'https://avanzacast-servertokens.bfzqqk.easypanel.host'; + const ROOM = process.env.ROOM || 'e2e-room'; + const USERNAME = process.env.USERNAME || 'e2e-runner'; + const OUT_DIR = process.env.OUT_DIR || null; + + function outLog(...args) { + console.log(...args); + if (OUT_DIR) { + try { + fs.appendFileSync(path.join(OUT_DIR, 'e2e.log'), args.map(String).join(' ') + '\n'); + } catch (e) { /* ignore */ } + } + } + + outLog('E2E runner starting with:', { BROWSERLESS_WS, TOKEN_SERVER, ROOM, USERNAME, OUT_DIR }); + + if (!BROWSERLESS_TOKEN) { + outLog('Missing BROWSERLESS_TOKEN env'); + process.exit(2); + } + + outLog('Creating session on token server', TOKEN_SERVER, ROOM, USERNAME); + let resp; + try { + resp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: ROOM, username: USERNAME, ttl: 300 }), + }); + } catch (err) { + outLog('Network error while calling token server:', String(err)); + process.exit(3); + } + + outLog('Token server responded with status', resp.status, resp.statusText); + let data; + try { + const text = await resp.text(); + try { data = JSON.parse(text); } catch(e) { data = null; } + outLog('Token server response body:', text); + } catch (err) { + outLog('Failed to read token server response body', String(err)); + process.exit(3); + } + + if (!resp.ok) { + outLog('Failed to create session, status', resp.status); + process.exit(4); + } + + if (!data) { + outLog('Token server returned non-JSON or empty body'); + process.exit(5); + } + + outLog('Session created:', data); + const sessionId = data.id; + const studioUrl = data.studioUrl || data.redirectUrl || data.url; + let token = data.token || null; + + if (!studioUrl) { + outLog('No studio URL returned from token server'); + process.exit(4); + } + + // If POST didn't return the token, try to GET it from the session endpoint + if (!token && sessionId) { + outLog('POST did not include token, attempting GET /api/session/:id to fetch token'); + try { + const sessResp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}`); + const sessText = await sessResp.text(); + let sessJson = null; + try { sessJson = JSON.parse(sessText); } catch(e) { sessJson = null; } + outLog('GET /api/session/:id status', sessResp.status, 'body start:', String(sessText).slice(0,400)); + if (sessJson && sessJson.token) { + token = sessJson.token; + outLog('Obtained token from GET /api/session/:id (length', (token && token.length) || 0, ')'); + } else { + outLog('GET /api/session/:id did not return token'); + } + } catch (err) { + outLog('Error while GET /api/session/:id', String(err)); + } + } + + // connect to browserless + const wsEndpoint = `${BROWSERLESS_WS}${BROWSERLESS_WS.includes('?') ? '&' : '?'}token=${encodeURIComponent(BROWSERLESS_TOKEN)}`; + outLog('Connecting to browserless WS endpoint:', wsEndpoint); + let browser; + try { + browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1366, height: 768 }, timeout: 20000 }); + } catch (err) { + outLog('Failed to connect to browserless via puppeteer.connect:', err && err.stack ? err.stack : String(err)); + process.exit(6); + } + + try { + const page = await browser.newPage(); + page.on('console', msg => { + try { outLog('[BROWSER]', msg.type(), msg.text()); } catch(e){} + }); + page.on('pageerror', err => outLog('[PAGEERROR]', err && err.stack ? err.stack : String(err))); + + // Log network failures + page.on('requestfailed', req => { + try { outLog('[REQFAILED]', req.url(), req.failure() && req.failure().errorText); } catch(e){} + }); + page.on('response', async res => { + try { + const status = res.status(); + if (status >= 400) { + outLog('[RESP_ERR]', status, res.url()); + if (OUT_DIR) { + try { + const text = await res.text(); + fs.writeFileSync(path.join(OUT_DIR, `resp_${sessionId || 'noid'}_${status}.txt`), text); + outLog('Saved failing response body to', path.join(OUT_DIR, `resp_${sessionId || 'noid'}_${status}.txt`)); + } catch (e) { outLog('Failed to save response body', String(e)); } + } + } + } catch (e) { /* ignore */ } + }); + + outLog('Navigating to studioUrl:', studioUrl); + try { + await page.goto(studioUrl, { waitUntil: 'domcontentloaded', timeout: 20000 }); + } catch (err) { + outLog('page.goto failed:', err && err.stack ? err.stack : String(err)); + throw err; + } + + // Wait for StudioPortal text indicating waiting for token + const waited = await page.waitForFunction(() => { + return document.body && document.body.innerText && (document.body.innerText.includes('Esperando token') || document.body.innerText.includes('Token recibido')); + }, { timeout: 8000 }).catch(() => false); + + if (!waited) outLog('Did not see StudioPortal waiting/received token text'); + + // If token was not included in redirect, try to postMessage token to window + if (token) { + outLog('Posting token via postMessage (token length', token.length, ')'); + try { + await page.evaluate((tk) => { + window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, url: window.location.href, room: '' }, window.location.origin); + }, token); + } catch (err) { + outLog('postMessage evaluate failed:', err && err.stack ? err.stack : String(err)); + } + } else { + outLog('No token present in session response; relying on redirect/session id flow'); + } + + // Wait for StudioPortal to report token received + const gotToken = await page.waitForFunction(() => { + return document.body && document.body.innerText && document.body.innerText.includes('Token recibido desde Broadcast Panel'); + }, { timeout: 10000 }).catch(() => false); + + if (gotToken) { + outLog('SUCCESS: StudioPortal received token via postMessage or redirect.'); + } else { + outLog('FAIL: StudioPortal did not report token received within timeout.'); + // print some page content for debugging + const snapshotText = await page.evaluate(() => document.body ? document.body.innerText.slice(0, 2000) : ''); + outLog('Page snapshot:', snapshotText); + if (OUT_DIR) { + try { + const html = await page.content(); + fs.writeFileSync(path.join(OUT_DIR, `page_${sessionId || 'noid'}.html`), html); + outLog('Saved full page HTML to', path.join(OUT_DIR, `page_${sessionId || 'noid'}.html`)); + } catch (e) { outLog('Failed to save page HTML', String(e)); } + } + if (OUT_DIR) { + try { + await page.screenshot({ path: path.join(OUT_DIR, `e2e_${sessionId || 'noid'}.png`), fullPage: true }); + outLog('Saved screenshot to', path.join(OUT_DIR, `e2e_${sessionId || 'noid'}.png`)); + } catch (e) { outLog('Failed to save screenshot', e && e.stack ? e.stack : String(e)); } + } + process.exit(5); + } + + // Optionally wait for connection attempt log + const connected = await page.waitForFunction(() => { + return document.body && document.body.innerText && document.body.innerText.includes('Conectado'); + }, { timeout: 10000 }).catch(() => false); + + outLog('Connected flag on StudioPortal:', !!connected); + outLog('E2E finished'); + + if (OUT_DIR) { + try { + await page.screenshot({ path: path.join(OUT_DIR, `e2e_${sessionId || 'noid'}.png`), fullPage: true }); + outLog('Saved screenshot to', path.join(OUT_DIR, `e2e_${sessionId || 'noid'}.png`)); + } catch (e) { outLog('Failed to save screenshot', e && e.stack ? e.stack : String(e)); } + } + + await page.close(); + } finally { + try { await browser.disconnect(); } catch(e){} + } +} + +main().catch(err => { console.error('Unhandled error in main:', err && err.stack ? err.stack : String(err)); process.exit(99); }); diff --git a/packages/broadcast-panel/e2e/run_local_e2e.js b/packages/broadcast-panel/e2e/run_local_e2e.js new file mode 100644 index 0000000..fffc45d --- /dev/null +++ b/packages/broadcast-panel/e2e/run_local_e2e.js @@ -0,0 +1,363 @@ +#!/usr/bin/env node +// Local E2E runner for Broadcast Panel using Puppeteer +// Usage examples: +// node e2e/run_local_e2e.js +// TOKEN_SERVER=http://localhost:4000 BROADCAST_URL=http://localhost:5175 node e2e/run_local_e2e.js --show --ws http://localhost:9222 + +import puppeteer from 'puppeteer'; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// --- CLI / ENV parsing --- +const argv = process.argv.slice(2); +function hasFlag(name) { return argv.includes(name); } +function getArgValue(name) { + const idx = argv.indexOf(name); + if (idx >= 0 && argv[idx+1]) return argv[idx+1]; + return null; +} + +const BACKEND = process.env.TOKEN_SERVER || process.env.BACKEND || 'http://localhost:4000'; +const BROADCAST = process.env.BROADCAST_URL || 'http://localhost:5175'; +const OUT_DIR = process.env.OUT_DIR || path.resolve(__dirname, 'out'); +let REMOTE_WS = process.env.REMOTE_WS || getArgValue('--ws') || process.env.BROWSERLESS_WS || process.env.REMOTE_WSE; +const SHOW = process.env.SHOW === '1' || hasFlag('--show') || hasFlag('-s') || !!process.env.VISUAL; +const CHROME_PATH = process.env.CHROME_PATH || null; + +fs.mkdirSync(OUT_DIR, { recursive: true }); + +function log(...args) { + console.log(...args); + try { fs.appendFileSync(path.join(OUT_DIR, 'run_local_e2e.log'), args.join(' ') + '\n'); } catch (e) {} +} + +log('Local E2E: BACKEND=', BACKEND, 'BROADCAST=', BROADCAST, 'REMOTE_WS=', REMOTE_WS, 'SHOW=', SHOW); + +// Use global fetch available on Node 18+ +const fetchFn = global.fetch ? global.fetch.bind(global) : (...a) => import('node-fetch').then(m=>m.default(...a)); + +async function checkBackend(url) { + try { const root = await fetchFn(url, { method: 'GET' }); log('[check] GET', url, 'status', root.status, 'content-type', root.headers.get('content-type')); } catch (e) { log('[check] GET root failed for', url, String(e)); } + try { const health = await fetchFn(url.replace(/\/$/, '') + '/health', { method: 'GET' }); log('[check] GET /health', url, 'status', health.status); } catch (e) { } +} + +async function resolveRemoteWSEndpoint(raw) { + if (!raw) return null; + raw = String(raw).trim(); + // if starts with ws or wss, return as-is + if (raw.startsWith('ws://') || raw.startsWith('wss://')) return raw; + // if it's numeric (port) assume localhost:port + if (/^\d+$/.test(raw)) raw = `http://localhost:${raw}`; + // if starts with http, try to fetch /json/version and /json/list + if (!raw.startsWith('http://') && !raw.startsWith('https://')) raw = `http://${raw}`; + try { + const ver = await fetchFn(raw.replace(/\/$/, '') + '/json/version'); + if (ver && ver.ok) { + const j = await ver.json(); + if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; + } + } catch (e) { /* ignore */ } + try { + const list = await fetchFn(raw.replace(/\/$/, '') + '/json/list'); + if (list && list.ok) { + const arr = await list.json(); + if (Array.isArray(arr) && arr.length) { + // prefer first page's webSocketDebuggerUrl + if (arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; + } + } + } catch (e) { /* ignore */ } + // try /json + try { + const j = await fetchFn(raw.replace(/\/$/, '') + '/json'); + if (j && j.ok) { + const arr = await j.json(); + if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; + } + } catch (e) { } + return null; +} + +function isExecutable(p) { try { fs.accessSync(p, fs.constants.X_OK); return true; } catch (e) { return false; } } + +function resolveChromeExecutable() { + const candidates = []; + if (CHROME_PATH) candidates.push(CHROME_PATH); + // Prefer system-installed chrome first + candidates.push('/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium', '/snap/bin/chromium'); + // packaged chromium in project (previously detected) + candidates.push(path.resolve(__dirname, 'chrome', 'linux-144.0.7531.0', 'chrome-linux64')); + + for (const c of candidates) { + if (!c) continue; + try { + if (fs.existsSync(c)) { + if (isExecutable(c)) return c; + // try to chmod + try { fs.chmodSync(c, 0o755); if (isExecutable(c)) return c; } catch (e) { log('Could not chmod candidate', c, String(e)); } + log('Candidate exists but not executable (skipping):', c); + } + } catch (e) { } + } + + // try puppeteer.executablePath() as last resort if it's valid and executable + try { + const ep = typeof puppeteer.executablePath === 'function' ? puppeteer.executablePath() : puppeteer.executablePath; + if (ep && fs.existsSync(ep)) { + if (isExecutable(ep)) return ep; + try { fs.chmodSync(ep, 0o755); if (isExecutable(ep)) return ep; } catch (e) { log('puppeteer.executablePath exists but not executable and chmod failed', ep, String(e)); } + } + } catch (e) { } + + return null; +} + +(async () => { + await checkBackend(BACKEND); + if (BACKEND.includes('localhost')) await checkBackend(BACKEND.replace('localhost', '127.0.0.1')); + + // 1) create session on backend + let session = null; + try { + const res = await fetchFn(`${BACKEND.replace(/\/$/, '')}/api/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room: 'local-e2e-room', username: 'local-runner', ttl: 300 }) }); + const text = await res.text(); try { session = JSON.parse(text); } catch (e) { session = null; } + log('POST /api/session status', res.status); + log('body start', String(text).slice(0, 400)); + if (!res.ok) { log('[error] backend POST returned non-OK. Full body:\n', text); throw new Error('Failed create session (non-OK)'); } + if (!session) { log('[error] backend POST returned non-JSON body, aborting.'); throw new Error('Failed create session (no JSON)'); } + } catch (err) { + console.error('Failed to create session on backend:', err && err.message ? err.message : String(err)); + console.error('Hint: asegúrate de que el backend API esté corriendo en', BACKEND, 'y responde /api/session'); + process.exit(1); + } + + const token = session && session.token ? session.token : null; + log('Session id', session && session.id, 'token?', !!token); + + if (!token && session && session.id) { + try { + log('POST did not include token, attempting GET /api/session/:id'); + const getResp = await fetchFn(`${BACKEND.replace(/\/$/, '')}/api/session/${encodeURIComponent(session.id)}`); + const getText = await getResp.text(); let getJson = null; try { getJson = JSON.parse(getText); } catch (e) { getJson = null; } + log('GET /api/session/:id status', getResp.status, 'body start', String(getText).slice(0, 400)); + if (getJson && getJson.token) { session.token = getJson.token; log('Obtained token from GET /api/session/:id (length', session.token.length, ')'); } else { log('GET /api/session/:id did not return token'); } + } catch (e) { log('Error while GET /api/session/:id', String(e)); } + } + + // 2) connect or launch puppeteer + let browser; + try { + let connectWSEndpoint = null; + if (REMOTE_WS) { + log('Raw REMOTE_WS provided:', REMOTE_WS); + // try to resolve to webSocketDebuggerUrl when necessary + connectWSEndpoint = await resolveRemoteWSEndpoint(REMOTE_WS).catch(e => null); + if (!connectWSEndpoint) { + // maybe REMOTE_WS was like 'localhost:9222' without http + connectWSEndpoint = await resolveRemoteWSEndpoint(REMOTE_WS).catch(e => null); + } + } + + if (connectWSEndpoint) { + log('Connecting to remote browser WS endpoint:', connectWSEndpoint); + browser = await puppeteer.connect({ browserWSEndpoint: connectWSEndpoint }); + } else if (REMOTE_WS && REMOTE_WS.startsWith('ws://')) { + log('Connecting to remote browser WS (as-is):', REMOTE_WS); + browser = await puppeteer.connect({ browserWSEndpoint: REMOTE_WS }); + } else { + const launchOptions = { headless: !SHOW, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] }; + let resolved = resolveChromeExecutable(); + if (resolved) { launchOptions.executablePath = resolved; log('Resolved executablePath for Chrome/Chromium:', resolved); } else { log('No explicit chrome executable resolved; using puppeteer default (may download bundled browser)'); } + + log('Launching local browser, headless=', launchOptions.headless, 'exe=', launchOptions.executablePath || '(puppeteer default)'); + try { + browser = await puppeteer.launch(launchOptions); + } catch (launchErr) { + log('Initial launch failed:', launchErr && launchErr.message ? launchErr.message : String(launchErr)); + // If EACCES on a resolved executable, try to chmod and retry + if (launchOptions.executablePath && launchErr && launchErr.message && launchErr.message.includes('EACCES')) { + try { fs.chmodSync(launchOptions.executablePath, 0o755); log('Chmod applied to', launchOptions.executablePath); browser = await puppeteer.launch(launchOptions); } + catch (e) { log('Retry after chmod failed:', String(e)); } + } + // final fallback: try system chrome paths explicitly + if (!browser) { + const fallbacks = ['/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium']; + for (const f of fallbacks) { + try { + if (fs.existsSync(f)) { + try { fs.chmodSync(f, 0o755); } catch (e) {} + launchOptions.executablePath = f; log('Retrying launch with fallback exe', f); + browser = await puppeteer.launch(launchOptions); + if (browser) break; + } + } catch (e) { log('Fallback launch attempt failed for', f, String(e)); } + } + } + if (!browser) throw launchErr; + } + } + } catch (err) { + console.error('Failed to launch/connect puppeteer:', err && err.message ? err.message : String(err)); + console.error('Resolved CHROME_PATH:', CHROME_PATH || '(not set)'); + process.exit(2); + } + + try { + const page = await browser.newPage(); + page.setDefaultTimeout(20000); + page.on('console', msg => log('[BROWSER]', msg.type(), msg.text())); + page.on('pageerror', err => log('[PAGEERROR]', err && err.stack ? err.stack : String(err))); + + async function sleep(ms) { return new Promise(res => setTimeout(res, ms)); } + + // click element by fuzzy text + async function clickByText(words, tag = '*') { + for (const w of words) { + const clicked = await page.evaluate((w, tag) => { + try { + const els = Array.from(document.querySelectorAll(tag)); + for (const el of els) { + const txt = (el.innerText || '').trim(); + if (txt && txt.indexOf(w) !== -1) { el.scrollIntoView({ block: 'center', inline: 'center' }); el.click(); return true; } + } + } catch (e) { } + return false; + }, w, tag); + if (clicked) { log('Clicked', w); return true; } + } + return false; + } + + async function setInputValue(selector, value) { + try { + if (!selector) { + const anyHandle = await page.$('input[type="text"], input, textarea'); + if (!anyHandle) return false; + await anyHandle.focus(); + await page.keyboard.down('Control'); await page.keyboard.press('A'); await page.keyboard.up('Control'); + await page.keyboard.type(value, { delay: 80 }); + return true; + } + await page.focus(selector); + await page.evaluate((s) => { const el = document.querySelector(s); if (el) el.value = ''; }, selector); + await page.type(selector, value, { delay: 80 }); + return true; + } catch (e) { log('setInputValue failed', String(e)); return false; } + } + + // 3) Navigate to broadcast panel or studioUrl + // If backend returned a studioUrl, open it directly so the StudioPortal can receive the token + const targetUrl = (session && session.studioUrl) ? session.studioUrl : BROADCAST; + log('Navigating to target URL (studio or broadcast):', targetUrl); + await page.goto(targetUrl, { waitUntil: 'networkidle2' }); + await sleep(800); + + // 4) Click create transmission button + const createCandidates = ['Nueva transmisión', 'Crear transmisión', 'Crear transmisión en vivo', 'Nueva transmisión en vivo', 'Nueva emisión', 'Crear', 'Transmitir', 'Nueva']; + let opened = await clickByText(createCandidates, 'button'); + if (!opened) opened = await clickByText(createCandidates, 'a'); + if (!opened) opened = await clickByText(createCandidates, 'div'); + if (!opened) log('Warning: create button not found automatically'); + await sleep(600); + + // 5) If modal shows, try to click 'Omitir' or 'Skip' or close it + const skipCandidates = ['Omitir', 'Saltar', 'Skip', 'Cerrar', 'Cerrar modal', 'No, gracias']; + const skipped = await clickByText(skipCandidates, 'button') || await clickByText(skipCandidates, 'a'); + if (skipped) { log('Skipped modal'); await sleep(400); } + + // 6) Find text input for title and set to 'Transmitir' + await setInputValue(null, 'Transmitir'); + await sleep(400); + + // 7) Click 'Empezar ahora' / 'Comenzar' / 'Empezar' + const startCandidates = ['Empezar ahora', 'Comenzar ahora', 'Empezar', 'Iniciar ahora', 'Comenzar', 'Empezar transmisión']; + let started = await clickByText(startCandidates, 'button'); + if (!started) started = await clickByText(startCandidates, 'a'); + if (!started) log('Warning: start button not found automatically'); + await sleep(1200); + + // 8) Click 'Entrar al estudio' or similar + const enterCandidates = ['Entrar al estudio', 'Entrar', 'Conectar', 'Ir al estudio', 'Abrir estudio', 'Entrar al estudio ahora']; + let entered = await clickByText(enterCandidates, 'button'); + if (!entered) entered = await clickByText(enterCandidates, 'a'); + if (!entered) log('Warning: enter to studio button not found'); + await sleep(1500); + + // 9) If token exists, postMessage it to the page (StudioPortal listens for LIVEKIT_TOKEN) + try { + if (session && session.token) { + log('Posting token to page via postMessage (token length', session.token.length, ')'); + await page.evaluate((tk, srv) => { + try { window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, room: 'local-e2e-room', serverUrl: srv }, window.location.origin); } catch (e) { /* ignore */ } + }, session.token, session.url || session.serverUrl || session.livekitUrl || ''); + await sleep(800); + + // Wait for StudioPortal to indicate token received or connected + let connected = false; + try { + const start = Date.now(); + while (Date.now() - start < 10000) { + const text = await page.evaluate(() => (document.body && document.body.innerText) || ''); + if (text.indexOf('Token recibido') !== -1 || text.indexOf('Conectado') !== -1 || text.indexOf('Conectando') !== -1) { connected = true; break; } + await new Promise(r => setTimeout(r, 400)); + } + } catch (e) { } + + if (!connected) { + log('Auto-connect not detected, attempting to click Connect button'); + // find buttons with class .btn-small and innerText includes 'Conectar' + try { + const clicked = await page.evaluate(() => { + const els = Array.from(document.querySelectorAll('button.btn-small')); + for (const el of els) { + const t = (el.innerText || '').trim(); + if (t && t.indexOf('Conectar') !== -1) { (el as any).click(); return true; } + } + // fallback: look for any button with text 'Conectar' + const any = Array.from(document.querySelectorAll('button')); + for (const b of any) { + const t = (b.innerText || '').trim(); + if (t && t.indexOf('Conectar') !== -1) { (b as any).click(); return true; } + } + return false; + }); + log('Clicked Connect button?', !!clicked); + if (clicked) await sleep(1200); + } catch (e) { log('Click Connect failed', String(e)); } + } + } + } catch (e) { log('postMessage/open studio failed', String(e)); } + + // 10) Wait a bit and try to detect indicators of token/connection + const indicators = ['Token recibido', 'Conectado', 'Conectando', 'LiveKit', 'livekit-js', 'Connected', 'Token']; + let saw = false; + try { + const start = Date.now(); + while (Date.now() - start < 15000) { + const found = await page.evaluate((indicators) => { const text = document.body && document.body.innerText || ''; return indicators.some(i => text.indexOf(i) !== -1); }, indicators); + if (found) { saw = true; break; } + await sleep(500); + } + } catch (e) { } + + log('Studio token/connection indicator found?', !!saw); + + const screenshotFile = path.join(OUT_DIR, `local_e2e_${Date.now()}.png`); + await page.screenshot({ path: screenshotFile, fullPage: false }); + log('Saved screenshot to', screenshotFile); + + if (!REMOTE_WS && SHOW) { log('Leaving browser open for manual inspection (SHOW=true)'); process.exit(0); } + + await browser.close(); + log('Local E2E finished OK'); + process.exit(0); + } catch (err) { + console.error('E2E error', err && err.stack ? err.stack : err); + try { await browser && browser.close(); } catch (e) { } + process.exit(3); + } +})(); diff --git a/packages/broadcast-panel/e2e/run_studio_integration.sh b/packages/broadcast-panel/e2e/run_studio_integration.sh new file mode 100644 index 0000000..537ec00 --- /dev/null +++ b/packages/broadcast-panel/e2e/run_studio_integration.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Quick E2E helper: create session on backend-api and print a URL you can open in the Broadcast Panel (with token in query) or use to postMessage. +# Set these env vars before running if needed: +# TOKEN_SERVER (default: http://localhost:4000) +# BROADCAST_URL (default: http://localhost:5175) + +TOKEN_SERVER=${TOKEN_SERVER:-http://localhost:4000} +BROADCAST_URL=${BROADCAST_URL:-http://localhost:5175} +ROOM=${1:-e2e-room} +USERNAME=${2:-e2e-runner} + +set -e + +echo "Creating session on ${TOKEN_SERVER} for room=${ROOM} username=${USERNAME}" +RESP=$(curl -sS -X POST "${TOKEN_SERVER%/}/api/session" -H 'Content-Type: application/json' -d '{"room":"'"${ROOM}"'","username":"'"${USERNAME}"'"}') || true +if [ -z "$RESP" ]; then + echo "No response from token server" + exit 1 +fi + +echo "Session response: $RESP" + +ID=$(echo "$RESP" | jq -r '.id // empty') +TOKEN=$(echo "$RESP" | jq -r '.token // empty') +URL=$(echo "$RESP" | jq -r '.redirectUrl // .studioUrl // .url // empty') + +if [ -n "$TOKEN" ]; then + echo "\nToken created (truncated): ${TOKEN:0:40}..." +fi +if [ -n "$URL" ]; then + echo "Returned URL: $URL" +fi + +# Print a Broadcast Panel friendly URL that includes token in query (useful if INCLUDE_TOKEN_IN_REDIRECT flow is enabled) +if [ -n "$TOKEN" ]; then + echo "\nOpen this in Broadcast Panel to auto-open the StudioPortal overlay (if it's integrated):" + echo "${BROADCAST_URL}?token=${TOKEN}&url=${URL}&room=${ROOM}" +fi + +# Also print a JS postMessage snippet you can paste in browser console to simulate the Broadcast Panel sending token to an open Broadcast/Studio page +if [ -n "$TOKEN" ]; then + cat < WS env var -> DEFAULT_WS +const url = process.env.WSS || process.env.WS || DEFAULT_WS; +console.log('Attempting WebSocket URL:', url); + +let opened = false; +try{ + const ws = new WebSocket(url, { handshakeTimeout: 15000 }); + + ws.on('open', () => { + opened = true; + console.log('EVENT: open'); + // send a simple ping-like frame + try{ + ws.send(JSON.stringify({ type: 'ping', ts: Date.now() })); + console.log('Sent ping'); + }catch(e){ console.error('SEND ERROR', e && e.message ? e.message : e); } + }); + + ws.on('message', (data) => { + try{ + console.log('EVENT: message', typeof data === 'string' ? data : data.toString()); + }catch(e){ console.log('EVENT: message (binary)'); } + }); + + ws.on('close', (code, reason) => { + console.log('EVENT: close', code, reason && reason.toString()); + process.exit(0); + }); + + ws.on('error', (err) => { + console.error('EVENT: error', err && err.message ? err.message : err); + setTimeout(()=>process.exit(1), 500); + }); + + // safety timeout + setTimeout(()=>{ + if(!opened){ + console.error('TIMEOUT: did not open connection within 15s'); + try{ ws.terminate(); }catch(e){} + process.exit(2); + } else { + console.log('INFO: connection opened, waiting 8s for messages then close'); + setTimeout(()=>{ try{ ws.close(); }catch(e){} }, 8000); + } + }, 15000); +}catch(e){ + console.error('FATAL:', e && e.message ? e.message : e); + process.exit(3); +} diff --git a/packages/broadcast-panel/nginx.conf b/packages/broadcast-panel/nginx.conf index 470ca05..ce4f79e 100644 --- a/packages/broadcast-panel/nginx.conf +++ b/packages/broadcast-panel/nginx.conf @@ -4,6 +4,20 @@ server { root /usr/share/nginx/html; index index.html; + # Proxy API requests for session/token to token server/backend-api + # This allows the broadcast-panel SPA to call /api/session/:id without CORS issues + location ~ ^/api/session(/.*)?$ { + # Change the upstream to the token server host as needed + # In production this should point to the backend-api service or token server + proxy_pass http://backend-api:4000$request_uri; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 5s; + proxy_read_timeout 20s; + } + # Configuración para SPA (Single Page Application) location / { try_files $uri $uri/ /index.html; diff --git a/packages/broadcast-panel/package.json b/packages/broadcast-panel/package.json index 857d6bb..ae39277 100644 --- a/packages/broadcast-panel/package.json +++ b/packages/broadcast-panel/package.json @@ -6,12 +6,17 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "e2e:dify": "node e2e/dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png" }, "dependencies": { "@livekit/components-react": "^2.9.15", "@livekit/components-styles": "^1.1.6", + "avanza-ui": "file:../avanza-ui", "livekit-client": "^2.15.14", + "node-fetch": "^2.7.0", + "puppeteer": "^24.30.0", + "puppeteer-core": "^20.9.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-icons": "^5.5.0" @@ -22,8 +27,16 @@ "@vitejs/plugin-react": "^4.0.0", "autoprefixer": "^10.4.14", "postcss": "^8.4.24", - "tailwindcss": "^4.1.0", + "tailwindcss": "^4.1.0", "typescript": "^5.0.2", - "vite": "^7.2.0" + "vite": "^7.2.0", + "playwright": "^1.51.0" + }, + "vitest": { + "test": { + "environment": "jsdom", + "globals": true, + "setupFiles": "./vitest.setup.ts" + } } } diff --git a/packages/broadcast-panel/packages/broadcast-panel/package-lock.json b/packages/broadcast-panel/packages/broadcast-panel/package-lock.json index d0ce826..ec408fe 100644 --- a/packages/broadcast-panel/packages/broadcast-panel/package-lock.json +++ b/packages/broadcast-panel/packages/broadcast-panel/package-lock.json @@ -8,7 +8,8 @@ "@vitejs/plugin-react": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "vite": "^7.2.0" + "vite": "^7.2.0", + "ws": "^8.18.3" } }, "node_modules/@babel/code-frame": { @@ -1495,6 +1496,27 @@ } } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/packages/broadcast-panel/packages/broadcast-panel/package.json b/packages/broadcast-panel/packages/broadcast-panel/package.json index a8370c4..4ffe182 100644 --- a/packages/broadcast-panel/packages/broadcast-panel/package.json +++ b/packages/broadcast-panel/packages/broadcast-panel/package.json @@ -3,6 +3,7 @@ "@vitejs/plugin-react": "^5.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "vite": "^7.2.0" + "vite": "^7.2.0", + "ws": "^8.18.3" } } diff --git a/packages/broadcast-panel/scripts/browser_e2e_local.cjs b/packages/broadcast-panel/scripts/browser_e2e_local.cjs new file mode 100644 index 0000000..1ba46a3 --- /dev/null +++ b/packages/broadcast-panel/scripts/browser_e2e_local.cjs @@ -0,0 +1,88 @@ +// Local E2E script that connects to a local Chrome remote debugging endpoint (http://127.0.0.1:9222) +// Usage: +// PUPPETEER_BROWSER_URL=http://127.0.0.1:9222 BROADCAST_URL=http://localhost:5175 node packages/broadcast-panel/scripts/browser_e2e_local.cjs + +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-core'); + +const LOG_DIR = path.resolve(process.cwd(), 'packages', 'broadcast-panel', 'tmp'); +const LOG_FILE = path.join(LOG_DIR, 'browser_e2e_local.log'); +if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + +function log(...args) { + const s = `[${new Date().toISOString()}] ${args.join(' ')}\n`; + fs.appendFileSync(LOG_FILE, s); + console.log(...args); +} + +(async () => { + try { + const browserUrl = process.env.PUPPETEER_BROWSER_URL || process.env.BROWSERLESS_BROWSER_URL || 'http://127.0.0.1:9222'; + const broadcastUrl = process.env.BROADCAST_URL || 'http://localhost:5175'; + + log('Connecting to Chrome at', browserUrl); + const browser = await puppeteer.connect({ browserURL: browserUrl, defaultViewport: { width: 1280, height: 800 } }); + log('Connected to Chrome. Opening new page...'); + const page = await browser.newPage(); + + page.on('console', msg => log('PAGE_CONSOLE:', msg.type(), msg.text())); + page.on('pageerror', err => log('PAGE_ERROR:', err && err.stack ? err.stack : err)); + + log('Navigating to', broadcastUrl); + await page.goto(broadcastUrl, { waitUntil: 'networkidle2', timeout: 60000 }); + log('Page loaded:', page.url()); + + const xpaths = [ + "//button[contains(normalize-space(.), 'Entrar al Estudio')]", + "//button[contains(normalize-space(.), 'Entrar al estudio')]", + "//button[contains(normalize-space(.), 'Entrar al Studio')]", + "//button[contains(normalize-space(.), 'Entrar')]", + "//a[contains(normalize-space(.), 'Entrar al Estudio')]", + "//a[contains(normalize-space(.), 'Entrar')]", + ]; + + let handle = null; + for (const xp of xpaths) { + try { + log('Trying XPath:', xp); + handle = await page.waitForXPath(xp, { timeout: 3000 }); + if (handle) { log('Found element for XPath:', xp); break; } + } catch (e) { } + } + + if (!handle) { + log('ERROR: No "Entrar" button found. Dumping candidates...'); + const candidates = await page.$$eval('button, a', els => els.slice(0,50).map(e => ({ text: e.innerText.trim().slice(0,80), html: e.outerHTML.slice(0,200) }))); + log('CANDIDATES:', JSON.stringify(candidates, null, 2)); + await browser.disconnect(); + process.exit(2); + } + + try { await handle.click(); log('Clicked Entrar'); } catch (e) { try { await page.evaluate(el => el.click(), handle); log('Clicked via evaluate'); } catch (e2) { log('Click failed', e2 && e2.message); } } + + try { await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 8000 }); log('Navigation:', page.url()); } catch (e) { log('No navigation, checking popups...'); } + + const pages = await browser.pages(); + log('Open pages count:', pages.length); + for (const p of pages) log('PAGE:', p.target()._targetId || p.url(), p.url()); + + const studioPage = pages.find(p => /studio|session|\/\w{6}/i.test(p.url())); + if (studioPage) { + log('Detected studio candidate:', studioPage.url()); + const u = new URL(studioPage.url()); + const sessionParam = u.searchParams.get('session') || u.searchParams.get('token'); + log('Session param (if any):', sessionParam); + } else { + log('No studio page detected. Current page URL:', page.url()); + } + + await browser.disconnect(); + log('Done. Logs:', LOG_FILE); + process.exit(0); + } catch (err) { + log('FATAL', err && err.stack ? err.stack : err); + process.exit(10); + } +})(); + diff --git a/packages/broadcast-panel/scripts/browserless_e2e.cjs b/packages/broadcast-panel/scripts/browserless_e2e.cjs new file mode 100644 index 0000000..e65f8bd --- /dev/null +++ b/packages/broadcast-panel/scripts/browserless_e2e.cjs @@ -0,0 +1,126 @@ +// Script: browserless_e2e.cjs +// Conecta a Browserless via WebSocket y navega al broadcast panel para hacer click en "Entrar al Estudio". +// Uso: +// BROWSERLESS_TOKEN=e2e098863b912f6a178b68e71ec3c58d BROADCAST_URL=http://localhost:5175 node packages/broadcast-panel/scripts/browserless_e2e.cjs + +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-core'); + +const LOG_DIR = path.resolve(process.cwd(), 'packages', 'broadcast-panel', 'tmp'); +const LOG_FILE = path.join(LOG_DIR, 'browserless_e2e.log'); +if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + +function log(...args) { + const s = `[${new Date().toISOString()}] ${args.join(' ')}\n`; + fs.appendFileSync(LOG_FILE, s); + console.log(...args); +} + +(async () => { + try { + const token = process.env.BROWSERLESS_TOKEN; + if (!token) { + log('ERROR: No BROWSERLESS_TOKEN provided in env.'); + process.exit(1); + } + + const broadcastUrl = process.env.BROADCAST_URL || process.env.VITE_BROADCASTPANEL_URL || 'http://localhost:5175'; + const wsEndpoint = process.env.BROWSERLESS_WS || `wss://browserless.bfzqqk.easypanel.host?token=${token}`; + + log('Connecting to Browserless at', wsEndpoint); + const browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1280, height: 800 } }); + log('Connected to browserless. Opening new page...'); + const page = await browser.newPage(); + + // capture console messages from page into log + page.on('console', msg => log('PAGE_CONSOLE:', msg.type(), msg.text())); + page.on('pageerror', err => log('PAGE_ERROR:', err && err.stack ? err.stack : err)); + + log('Navigating to', broadcastUrl); + await page.goto(broadcastUrl, { waitUntil: 'networkidle2', timeout: 60000 }); + log('Page loaded:', page.url()); + + // Try several XPaths / selectors matching localized button text + const xpaths = [ + "//button[contains(normalize-space(.), 'Entrar al Estudio')]", + "//button[contains(normalize-space(.), 'Entrar al estudio')]", + "//button[contains(normalize-space(.), 'Entrar al Studio')]", + "//button[contains(normalize-space(.), 'Entrar')]", + "//a[contains(normalize-space(.), 'Entrar al Estudio')]", + "//a[contains(normalize-space(.), 'Entrar')]", + "//button[contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'entrar')]", + ]; + + let handle = null; + for (const xp of xpaths) { + try { + log('Trying XPath:', xp); + handle = await page.waitForXPath(xp, { timeout: 3000 }); + if (handle) { + log('Found element for XPath:', xp); + break; + } + } catch (e) { + // continue + } + } + + if (!handle) { + log('ERROR: No "Entrar" button found on page. Dumping top-level buttons for debugging...'); + const buttons = await page.$$eval('button, a', els => els.slice(0,50).map(e => ({ text: e.innerText.trim().slice(0,80), html: e.outerHTML.slice(0,200) }))); + log('BUTTONS_SNAPSHOT:', JSON.stringify(buttons, null, 2)); + await browser.disconnect(); + process.exit(2); + } + + // Click the control + try { + await handle.click(); + log('Clicked the Entrar control. Waiting for navigation or popup...'); + } catch (e) { + log('Click failed, trying evaluate click()...', e && e.message); + try { + await page.evaluate(el => el.click(), handle); + log('Clicked via evaluate.'); + } catch (e2) { + log('Failed to click element:', e2 && e2.message); + } + } + + // Wait for possible navigation or popup + try { + await page.waitForNavigation({ waitUntil: 'networkidle2', timeout: 8000 }); + log('Navigation occurred. New URL:', page.url()); + } catch (e) { + log('No navigation after click (timed out). Checking if a new popup opened...'); + } + + const allPages = await browser.pages(); + log('Open pages count:', allPages.length); + for (const p of allPages) { + log('PAGE:', p.target()._targetId || p.url(), p.url()); + } + + // If a popup or new tab includes /studio or a session id, log it + const studioPage = allPages.find(p => /studio|session|y5n0wsr|\/\w{6}/i.test(p.url())); + if (studioPage) { + log('Detected candidate studio page:', studioPage.url()); + // Could attempt to read token from URL param or DOM + const url = studioPage.url(); + const search = new URL(url).searchParams; + const sessionParam = search.get('session') || search.get('token') || null; + log('Session param found in URL (if any):', sessionParam); + } else { + log('No obvious studio page detected by URL pattern. Current page URL:', page.url()); + } + + log('Done. Disconnecting from browserless. Logs saved to', LOG_FILE); + await browser.disconnect(); + process.exit(0); + } catch (err) { + log('FATAL ERROR:', err && err.stack ? err.stack : err); + process.exit(10); + } +})(); + diff --git a/packages/broadcast-panel/scripts/browserless_test.cjs b/packages/broadcast-panel/scripts/browserless_test.cjs new file mode 100644 index 0000000..533d835 --- /dev/null +++ b/packages/broadcast-panel/scripts/browserless_test.cjs @@ -0,0 +1,18 @@ +const puppeteer = require('puppeteer-core'); +(async () => { + const ws = process.env.BROWSERLESS_WS || `wss://browserless.bfzqqk.easypanel.host?token=${process.env.BROWSERLESS_TOKEN}`; + console.log('Trying connect to', ws); + try { + const browser = await puppeteer.connect({ browserWSEndpoint: ws, defaultViewport: { width: 800, height: 600 } }); + console.log('Connected OK to browserless'); + const page = await browser.newPage(); + await page.goto(process.env.BROADCAST_URL || 'http://localhost:5175', { waitUntil: 'networkidle2', timeout: 20000 }); + console.log('Page loaded:', await page.title(), page.url()); + await browser.disconnect(); + process.exit(0); + } catch (err) { + console.error('Connect failed:', err && err.stack ? err.stack : err); + process.exit(2); + } +})(); + diff --git a/packages/broadcast-panel/scripts/e2e_mock_ui.cjs b/packages/broadcast-panel/scripts/e2e_mock_ui.cjs new file mode 100644 index 0000000..92340aa --- /dev/null +++ b/packages/broadcast-panel/scripts/e2e_mock_ui.cjs @@ -0,0 +1,120 @@ +// Simple E2E that uses backend-api to create a session, then opens a data: HTML page +// with a button "Entrar al Estudio" that opens the studioUrl. It clicks the button and +// verifies a new page/tab opened with that URL. + +const fetch = require('node-fetch'); +const puppeteer = require('puppeteer-core'); +const fs = require('fs'); +const path = require('path'); + +const LOG_DIR = path.resolve(process.cwd(), 'packages', 'broadcast-panel', 'tmp'); +if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); +const LOG_FILE = path.join(LOG_DIR, 'e2e_mock_ui.log'); +function log(...args) { + const s = `[${new Date().toISOString()}] ${args.join(' ')}\n`; + fs.appendFileSync(LOG_FILE, s); + console.log(...args); +} + +(async () => { + try { + const backend = process.env.BACKEND_URL || 'http://127.0.0.1:4000'; + log('Using backend:', backend); + + // create session + const createResp = await fetch(`${backend}/api/session`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: 'mock-room', username: 'e2e-mock' }) + }); + if (!createResp.ok) { + const t = await createResp.text(); + throw new Error('Create session failed: ' + createResp.status + ' ' + t); + } + const createJson = await createResp.json(); + log('Create response:', JSON.stringify(createJson)); + const studioUrl = createJson.studioUrl || createJson.redirectUrl; + if (!studioUrl) throw new Error('studioUrl not found in create response'); + + // Prepare mock HTML with override hook placeholder (we will set override from puppeteer) + const html = `Mock Broadcast +

Mock Broadcast Panel

+ + + `; + + // connect to browser + const browserUrl = process.env.PUPPETEER_BROWSER_URL; // e.g. http://127.0.0.1:9222 + const browserWSEndpoint = process.env.BROWSERLESS_WSE || (process.env.BROWSERLESS_TOKEN ? `wss://browserless.bfzqqk.easypanel.host?token=${process.env.BROWSERLESS_TOKEN}` : undefined); + let browser; + if (browserUrl) { + log('Connecting to browser via browserURL:', browserUrl); + browser = await puppeteer.connect({ browserURL: browserUrl, defaultViewport: { width: 1280, height: 800 } }); + } else if (browserWSEndpoint) { + log('Connecting to browserless via WSEndpoint:', browserWSEndpoint); + browser = await puppeteer.connect({ browserWSEndpoint, defaultViewport: { width: 1280, height: 800 } }); + } else { + // fallback: try to launch local chrome (may fail in CI) + log('No remote browser configured, attempting local launch'); + browser = await puppeteer.launch({ headless: false }); + } + + const page = await browser.newPage(); + page.on('console', msg => log('PAGE_CONSOLE', msg.type(), msg.text())); + page.on('pageerror', err => log('PAGE_ERROR', err && err.stack ? err.stack : err)); + + // Ensure override is present before any script runs on the page + await page.evaluateOnNewDocument(() => { + (window as any).__TEST_OPEN = (u: string) => { (window as any).__LAST_OPENED = u; }; + }); + + // set page content (more reliable than data URL for popup handling) + log('Setting page content (html)'); + await page.setContent(html, { waitUntil: 'networkidle0' }); + log('Mock page loaded.'); + + const btn = await page.waitForSelector('#enter', { timeout: 5000 }); + await btn.click(); + log('Clicked enter button. Waiting for __LAST_OPENED...'); + + // give a small delay for the page script to call __TEST_OPEN + await page.waitForTimeout(500); + + const openedUrl = await page.evaluate(() => (window as any).__LAST_OPENED || ''); + log('Captured openedUrl from page context:', openedUrl); + + if (!openedUrl) { + log('WARNING: No opened URL captured from page context. Trying to detect popup...'); + const pages = await browser.pages(); + log('Open pages count after click:', pages.length); + if (pages.length > 1) { + const popup = pages[pages.length - 1]; + const popupUrl = popup.url(); + log('Popup URL:', popupUrl); + } + } else { + if (openedUrl.indexOf(studioUrl) === -1 && openedUrl.indexOf('/' + createJson.id) === -1) { + log('WARNING: opened URL does not match expected studioUrl', studioUrl); + } else { + log('SUCCESS: openedUrl matches expected studioUrl/session'); + } + } + + // cleanup + try { await browser.disconnect(); } catch(e) { try { await browser.close(); } catch(e2) {} } + process.exit(0); + } catch (err) { + log('FATAL', err && err.stack ? err.stack : err); + process.exit(1); + } +})(); diff --git a/packages/broadcast-panel/scripts/fetch_public_page_browserless.cjs b/packages/broadcast-panel/scripts/fetch_public_page_browserless.cjs new file mode 100644 index 0000000..e66d84c --- /dev/null +++ b/packages/broadcast-panel/scripts/fetch_public_page_browserless.cjs @@ -0,0 +1,36 @@ +const fs = require('fs'); +const path = require('path'); +const puppeteer = require('puppeteer-core'); + +(async () => { + try { + const token = process.env.BROWSERLESS_TOKEN; + if (!token) { + console.error('No BROWSERLESS_TOKEN'); + process.exit(1); + } + const broadcastUrl = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host'; + const wsEndpoint = process.env.BROWSERLESS_WS || `wss://browserless.bfzqqk.easypanel.host?token=${token}`; + const outDir = path.resolve(process.cwd(), 'packages', 'broadcast-panel', 'tmp'); + if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); + const outFile = path.join(outDir, 'public_page.html'); + + console.log('Connecting to Browserless at', wsEndpoint); + const browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1280, height: 900 } }); + const page = await browser.newPage(); + page.on('console', m => console.log('PAGE_CONSOLE:', m.type(), m.text())); + page.on('pageerror', e => console.error('PAGE_ERROR:', e && e.stack ? e.stack : e)); + + console.log('Navigating to', broadcastUrl); + await page.goto(broadcastUrl, { waitUntil: 'networkidle2', timeout: 60000 }); + const html = await page.content(); + fs.writeFileSync(outFile, html, 'utf8'); + console.log('Saved HTML to', outFile); + await browser.disconnect(); + process.exit(0); + } catch (err) { + console.error('FATAL', err && err.stack ? err.stack : err); + process.exit(2); + } +})(); + diff --git a/packages/broadcast-panel/src/components/NewTransmissionModal.tsx b/packages/broadcast-panel/src/components/NewTransmissionModal.tsx index c2b44ba..202697e 100644 --- a/packages/broadcast-panel/src/components/NewTransmissionModal.tsx +++ b/packages/broadcast-panel/src/components/NewTransmissionModal.tsx @@ -40,7 +40,8 @@ interface DestinationData { badge?: React.ReactNode } -const NewTransmissionModal: React.FC = ({ open, onClose, onCreate, onUpdate, transmission }) => { +const NewTransmissionModal: React.FC = (props) => { + const { open, onClose, onCreate, onUpdate, transmission, onlyAddDestination, onAddDestination } = props const [view, setView] = useState<'main' | 'add-destination'>('main') const [source, setSource] = useState('studio') @@ -142,8 +143,8 @@ const NewTransmissionModal: React.FC = ({ open, onClose, onCreate, onUpda badge: platform === 'YouTube' ? : undefined } // If this modal is used only to add a destination, call the callback and close - if (onlyAddDestination && onAddDestination) { - onAddDestination(newDest) + if ((props as any).onlyAddDestination && (props as any).onAddDestination) { + ;(props as any).onAddDestination(newDest) onClose() return } @@ -161,13 +162,13 @@ const NewTransmissionModal: React.FC = ({ open, onClose, onCreate, onUpda } } - const handleCreate = () => { + const handleCreate = async () => { if (!selectedDestination) { alert('Por favor selecciona un destino de transmisión') return } - // Si es transmisión en blanco (genérica) + // If blank transmission (generic) if (selectedDestination === 'blank') { const blankTransmission: Transmission = { id: isEditMode && transmission ? transmission.id : generateId(), @@ -181,7 +182,7 @@ const NewTransmissionModal: React.FC = ({ open, onClose, onCreate, onUpda }) } - // Si estamos editando, llamar a onUpdate, sino onCreate + // If editing, call onUpdate, else onCreate if (isEditMode && onUpdate) { onUpdate(blankTransmission) } else { @@ -193,8 +194,8 @@ const NewTransmissionModal: React.FC = ({ open, onClose, onCreate, onUpda return } - // Transmisión con destino específico - const t: Transmission = { + // Transmission with a specific destination + const t: Transmission = { id: isEditMode && transmission ? transmission.id : generateId(), title: title || 'Nueva transmisión', platform: destinations.find(d => d.id === selectedDestination)?.platform || 'YouTube', @@ -205,8 +206,30 @@ const NewTransmissionModal: React.FC = ({ open, onClose, onCreate, onUpda year: 'numeric' }) } - - // Si estamos editando, llamar a onUpdate, sino onCreate + + // Try to create a session for this broadcast on the backend and attach sessionId to transmission + try { + const BACKEND_ABS = (import.meta.env.VITE_BACKEND_API_URL as string) || (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || '' + const sessionUrl = BACKEND_ABS ? `${BACKEND_ABS.replace(/\/$/, '')}/api/broadcasts/${encodeURIComponent(t.id)}/session` : `/api/broadcasts/${encodeURIComponent(t.id)}/session` + // Call backend to create session associated with this broadcast. + const resp = await fetch(sessionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: (localStorage.getItem('avanzacast_user') || 'Guest') }) }) + if (resp.ok) { + try { + const json = await resp.json() + // attach session id returned by backend to transmission object (optional field) + ;(t as any).sessionId = json.id || undefined + } catch (e) { + // ignore JSON parse + } + } else { + // if session creation failed, continue; the studio flow will create one on demand + console.warn('[NewTransmissionModal] failed to create session for broadcast:', resp.status) + } + } catch (e) { + console.warn('[NewTransmissionModal] error creating session for broadcast', e) + } + + // If editing, call onUpdate, else onCreate if (isEditMode && onUpdate) { onUpdate(t) } else { diff --git a/packages/broadcast-panel/src/components/PageContainer.tsx b/packages/broadcast-panel/src/components/PageContainer.tsx index 36eae1b..dae17d5 100644 --- a/packages/broadcast-panel/src/components/PageContainer.tsx +++ b/packages/broadcast-panel/src/components/PageContainer.tsx @@ -9,6 +9,7 @@ import Header from './Header' import TransmissionsTable from './TransmissionsTable' import { NewTransmissionModal } from '@shared/components' import Studio from './Studio' +import StudioConnector from './StudioConnector' import type { Transmission } from '@shared/types' const STORAGE_KEY = 'broadcast_transmissions' @@ -61,7 +62,8 @@ const PageContainer: React.FC = () => { // Renderizar página según navegación if (currentPage === 'studio') { - return + // Dev: render StudioConnector for quick testing of the session flow + return } return ( diff --git a/packages/broadcast-panel/src/components/Studio.tsx b/packages/broadcast-panel/src/components/Studio.tsx index 1ae53d0..a196c38 100644 --- a/packages/broadcast-panel/src/components/Studio.tsx +++ b/packages/broadcast-panel/src/components/Studio.tsx @@ -1,22 +1,53 @@ -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { ThemeProvider } from './ThemeProvider' import Sidebar from './Sidebar' import Header from './Header' import styles from './Studio.module.css' +import { StudioPortal } from '../features/studio' const Studio: React.FC = () => { + const [tokenData, setTokenData] = useState<{ token?: string; url?: string } | null>(null) + useEffect(() => { - // Obtener información del usuario desde localStorage o crear temporal const userName = localStorage.getItem('avanzacast_user') || 'Usuario' - const roomName = 'avanzacast-studio' - - // Guardar información para el studio-panel + const roomName = localStorage.getItem('avanzacast_room') || 'avanzacast-studio' localStorage.setItem('avanzacast_user', userName) localStorage.setItem('avanzacast_room', roomName) - - // Redirigir al studio-panel (puerto 3001) - const studioUrl = `https://avanzacast-studio.bfzqqk.easypanel.host?user=${encodeURIComponent(userName)}&room=${encodeURIComponent(roomName)}` - window.location.href = studioUrl + + // Request session token via backend API: POST /api/session then GET /api/session/:id + ;(async () => { + try { + // Create session (POST) + const createResp = await fetch('/api/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room: roomName, username: userName }), + credentials: 'include', + }) + if (!createResp.ok) { + console.warn('studio session create failed', createResp.status) + return + } + const created = await createResp.json() + const sessionId = created && created.id + if (!sessionId) { + console.warn('studio session create returned no id', created) + return + } + // Fetch full session data (token and url) + const fetchResp = await fetch(`/api/session/${encodeURIComponent(sessionId)}`, { method: 'GET', credentials: 'include' }) + if (!fetchResp.ok) { + console.warn('failed fetching session data', fetchResp.status) + return + } + const data = await fetchResp.json() + // expected { token, url } + setTokenData({ token: data.token, url: data.url }) + return + } catch (e) { + console.warn('failed requesting studio session', e) + } + })() }, []) return ( @@ -26,15 +57,17 @@ const Studio: React.FC = () => {
-
+ {tokenData ? ( + + ) : (
-

Redirigiendo al Studio...

-

Preparando tu estudio de transmisión

+

Preparando tu estudio...

+

Solicitando credenciales seguras

-
+ )}
diff --git a/packages/broadcast-panel/src/components/StudioConnector.tsx b/packages/broadcast-panel/src/components/StudioConnector.tsx new file mode 100644 index 0000000..2702458 --- /dev/null +++ b/packages/broadcast-panel/src/components/StudioConnector.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useRef, useState } from 'react' +import { useStudioSession } from '../hooks/useStudioSession' +import { connect, createLocalTracks, Room, LocalTrack } from 'livekit-client' + +export const StudioConnector: React.FC = () => { + const { state, session, error, connect, disconnect } = useStudioSession() + + const [room, setRoom] = useState(null) + const [localTracks, setLocalTracks] = useState(null) + const videoRef = useRef(null) + const [connectingError, setConnectingError] = useState(null) + + // Determine LiveKit server URL (from Vite env or fallback) + const LIVEKIT_URL = (import.meta.env.VITE_LIVEKIT_URL as string) || (window as any).VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host' + + useEffect(() => { + // When the hook reports connected state and we have a token, establish the livekit Room + const doConnect = async () => { + setConnectingError(null) + if (!session?.token) return + // If we already have a room, skip + if (room) return + + try { + // Request local media permissions and create tracks + const tracks = await createLocalTracks({ audio: true, video: true }) + setLocalTracks(tracks) + + // Connect to LiveKit room using token (session.token) and LIVEKIT_URL + const r = await connect(LIVEKIT_URL, session.token, { reconnect: true }) + + // Publish local tracks + for (const t of tracks) { + try { + await r.localParticipant.publishTrack(t) + } catch (err) { + console.warn('publishTrack failed', err) + } + } + + // Attach the first video track to our preview element + const videoTrack = tracks.find((t) => t.kind === 'video') as LocalTrack | undefined + if (videoTrack && videoRef.current) { + try { + const el = videoTrack.attach() + // attach returns HTMLMediaElement, ensure it's a video element + // Replace container's children with this element + if (videoRef.current.parentElement) { + const parent = videoRef.current.parentElement + parent.replaceChild(el, videoRef.current) + videoRef.current = el as HTMLVideoElement + } + } catch (err) { + console.warn('attach track failed', err) + } + } + + // Listen for room events (optional) + r.on('disconnected', () => { + setRoom(null) + }) + r.on('reconnecting', () => { + // Could set state to reconnecting + }) + + setRoom(r) + } catch (e: any) { + console.error('LiveKit connect error', e) + setConnectingError(String(e?.message ?? e)) + } + } + + if (state === 'connected' && session?.token) { + void doConnect() + } + + return () => { + // nothing here: cleanup handled separately + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [state, session?.token]) + + // Cleanup on unmount or disconnect + useEffect(() => { + return () => { + // Stop local tracks and disconnect room + try { + if (localTracks) { + for (const t of localTracks) { + try { t.stop(); t.detach(); } catch (e) { } + } + } + if (room) { + try { room.disconnect(); } catch (e) { } + } + } catch (e) {} + } + }, [localTracks, room]) + + const onCreateAndEnter = async () => { + try { + await connect({ createIfMissing: true, createPayload: { title: 'E2E Transmisión' } }) + } catch (e) { + console.error('connect failed', e) + } + } + + const onEnterExisting = async () => { + if (!session?.id) { + await onCreateAndEnter() + return + } + try { + await connect({ sessionId: session.id }) + } catch (e) { + console.error('connect failed', e) + } + } + + const onDisconnect = async () => { + try { + if (room) { + try { room.disconnect() } catch (e) { } + setRoom(null) + } + if (localTracks) { + for (const t of localTracks) { + try { t.stop(); t.detach(); } catch (e) { } + } + setLocalTracks(null) + } + await disconnect() + } catch (e) { + console.warn('disconnect error', e) + } + } + + return ( +
+
Estado: {state}
+ {error &&
Error: {error}
} + {connectingError &&
Conexión LiveKit: {connectingError}
} + +
+ + + +
+ +
+

Session: {session?.id ?? 'n/a'}

+

Token: {session?.token ? `${session.token.substring(0,20)}...` : 'n/a'}

+
+ {/* placeholder video element - will be replaced by attach() result when track attaches */} +
+
+
+ ) +} + +export default StudioConnector diff --git a/packages/broadcast-panel/src/components/TransmissionsTable.tsx b/packages/broadcast-panel/src/components/TransmissionsTable.tsx index ff3472a..8632567 100644 --- a/packages/broadcast-panel/src/components/TransmissionsTable.tsx +++ b/packages/broadcast-panel/src/components/TransmissionsTable.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react' +import React, { useState, useEffect } from 'react' import { MdMoreVert, MdVideocam, MdPersonAdd, MdEdit, MdOpenInNew, MdDelete } from 'react-icons/md' import { Dropdown } from './Dropdown' import { FaYoutube, FaFacebook, FaTwitch, FaLinkedin } from 'react-icons/fa' @@ -8,6 +8,8 @@ import InviteGuestsModal from './InviteGuestsModal' import { NewTransmissionModal } from '@shared/components' import type { Transmission } from '@shared/types' import useStudioLauncher from '../hooks/useStudioLauncher' +import useStudioMessageListener from '../hooks/useStudioMessageListener' +import StudioPortal from '../features/studio/StudioPortal' interface Props { transmissions: Transmission[] @@ -33,6 +35,39 @@ const TransmissionsTable: React.FC = (props) => { const [editTransmission, setEditTransmission] = useState(undefined) const { openStudio, loadingId: launcherLoadingId, error: launcherError } = useStudioLauncher() const [loadingId, setLoadingId] = useState(null) + const [studioSession, setStudioSession] = useState<{ serverUrl?: string; token?: string; room?: string } | null>(null) + const [validating, setValidating] = useState(false) + const [connectError, setConnectError] = useState(null) + const [currentAttempt, setCurrentAttempt] = useState(null) + + // Listen for external postMessage events carrying a LIVEKIT_TOKEN payload. + useStudioMessageListener((msg) => { + try { + if (msg && msg.token) { + const serverUrl = msg.url || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || '' + // start validating token and open StudioPortal overlay + setValidating(true) + setConnectError(null) + setStudioSession({ serverUrl, token: msg.token, room: msg.room || 'external' }) + } + } catch (e) { /* ignore */ } + }) + + // Auto-open studio if token is present in URL (INCLUDE_TOKEN_IN_REDIRECT flow) + useEffect(() => { + try { + if (typeof window === 'undefined') return + const params = new URLSearchParams(window.location.search) + const tokenParam = params.get('token') + if (tokenParam) { + const serverParam = params.get('serverUrl') || params.get('url') || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || '' + const roomParam = params.get('room') || 'external' + setConnectError(null) + setValidating(true) + setStudioSession({ serverUrl: serverParam, token: tokenParam, room: roomParam }) + } + } catch (e) { /* ignore */ } + }, []) const handleEdit = (t: Transmission) => { setEditTransmission(t) @@ -57,22 +92,66 @@ const TransmissionsTable: React.FC = (props) => { const openStudioForTransmission = async (t: Transmission) => { if (loadingId || launcherLoadingId) return setLoadingId(t.id) + setCurrentAttempt(t) + setValidating(true) try { const userRaw = localStorage.getItem('avanzacast_user') || 'Demo User' const user = (userRaw) const room = (t.id || 'avanzacast-studio') + const result = await openStudio({ room, username: user }) if (!result) { throw new Error('No se pudo abrir el estudio') } + + const resAny: any = result as any + + // If backend returned a session id, persist it and navigate to broadcastPanel/:id so the Studio route picks it + if (resAny && resAny.id) { + try { + const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' + sessionStorage.setItem(storeKey, JSON.stringify(resAny)) + try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: resAny })) } catch (e) { /* ignore */ } + } catch (e) { /* ignore storage errors */ } + + const BROADCAST_BASE = (import.meta.env.VITE_BROADCASTPANEL_URL as string) || (typeof window !== 'undefined' ? window.location.origin : '') + const target = `${BROADCAST_BASE.replace(/\/$/, '')}/${encodeURIComponent(resAny.id)}` + try { + window.location.href = target + return + } catch (e) { + try { window.location.assign(target) } catch (e2) { /* ignore */ } + } + } + + // If app is configured as integrated, ensure we open StudioPortal overlay immediately + const INTEGRATED = (import.meta.env.VITE_STUDIO_INTEGRATED === 'true' || import.meta.env.VITE_STUDIO_INTEGRATED === '1') || false + if (INTEGRATED && resAny && resAny.token) { + const serverUrl = resAny.url || resAny.studioUrl || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || '' + setStudioSession({ serverUrl, token: resAny.token, room: resAny.room || room }) + setLoadingId(null) + return + } + + const serverUrl = resAny.url || resAny.studioUrl || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || '' + if (resAny.token) { + setStudioSession({ serverUrl, token: resAny.token, room: resAny.room || room }) + } else { + setValidating(false) + } setLoadingId(null) } catch (err: any) { console.error('[BroadcastPanel] Error entrando al estudio:', err) - alert(err?.message || 'No fue posible entrar al estudio. Revisa el servidor de tokens.') + setConnectError(err?.message || 'No fue posible entrar al estudio. Revisa el servidor de tokens.') + setValidating(false) setLoadingId(null) } } + const closeStudio = () => { + try { setStudioSession(null) } catch(e){} + } + if (isLoading) { return (
@@ -172,7 +251,16 @@ const TransmissionsTable: React.FC = (props) => { )} {launcherError && ( -
{launcherError}
+ // Mostrar modal claro si el hook de launcher reporta un error +
+
+

Error al iniciar el estudio

+

{launcherError}

+
+ +
+
+
)} = (props) => { onUpdate={onUpdate} transmission={editTransmission} /> + + {studioSession && ( +
+
+ + { setValidating(false); /* keep portal open */ }} + onRoomDisconnected={() => { closeStudio(); }} + onRoomConnectError={(err) => { setValidating(false); setConnectError(String(err?.message || err || 'Error al conectar')); }} + /> +
+
+ )} + + {validating && ( +
+
+ Validando token, por favor espera... +
+
+ )} + {connectError && ( +
+
+

Error al conectar al estudio

+

{connectError}

+
+ + +
+
+
+ )}
) } diff --git a/packages/broadcast-panel/src/features/studio/BottomControls.tsx b/packages/broadcast-panel/src/features/studio/BottomControls.tsx new file mode 100644 index 0000000..98746b5 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/BottomControls.tsx @@ -0,0 +1,171 @@ +import React, { useState, useContext } from 'react' +import { RoomContext } from '@livekit/components-react' +import { Room } from 'livekit-client' +import { ControlButton, ControlGroup, IconButton } from 'avanza-ui' +import IconCameraOn from './icons/IconCameraOn' +import IconMicOff from './icons/IconMicOff' + +interface BottomControlsProps { + onToggleMute?: (muted: boolean) => void; + onToggleCamera?: (cameraOn: boolean) => void; + onToggleRecording?: (recording: boolean) => void; +} + +let idCounter = 0 +function uniqueId(prefix = 'id'){ + idCounter += 1 + return `${prefix}-${idCounter}` +} + +export default function BottomControls({ onToggleMute, onToggleCamera, onToggleRecording }: BottomControlsProps){ + const [muted, setMuted] = useState(false) + const [cameraOn, setCameraOn] = useState(true) + const [recording, setRecording] = useState(false) + + const ctxRoom = useContext(RoomContext) as Room | null + + React.useEffect(() => { + function onGoLive(e: any) { + try { + const d = e?.detail || {}; + if (d.action === 'start') setRecording(true); + else if (d.action === 'stop') setRecording(false); + } catch (err) { console.warn('go-live handler error', err) } + } + window.addEventListener('avz:request:go-live', onGoLive as EventListener); + return () => window.removeEventListener('avz:request:go-live', onGoLive as EventListener); + }, []); + + const muteTipId = React.useMemo(() => uniqueId('tip-mute'), []) + const camTipId = React.useMemo(() => uniqueId('tip-cam'), []) + const recTipId = React.useMemo(() => uniqueId('tip-rec'), []) + + const safeSetMic = async (enabled: boolean) => { + try { + const r = ctxRoom as any + if (!r) return + const lp = r.localParticipant + if (!lp) return + if (typeof lp.setMicrophoneEnabled === 'function') { + await lp.setMicrophoneEnabled(enabled) + return + } + if (lp.audioTracks && Array.isArray(lp.audioTracks)) { + for (const tpub of lp.audioTracks) { + try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){} + } + } + } catch (e) { + console.warn('safeSetMic failed', e) + } + } + + const safeSetCamera = async (enabled: boolean) => { + try { + const r = ctxRoom as any + if (!r) return + const lp = r.localParticipant + if (!lp) return + if (typeof lp.setCameraEnabled === 'function') { + await lp.setCameraEnabled(enabled) + return + } + if (lp.videoTracks && Array.isArray(lp.videoTracks)) { + for (const tpub of lp.videoTracks) { + try { tpub.track && typeof tpub.track.enable === 'function' && tpub.track.enable(enabled) } catch(e){} + } + } + } catch (e) { + console.warn('safeSetCamera failed', e) + } + } + + const safeToggleRecording = async (start: boolean) => { + try { + const r = ctxRoom as any + if (!r) return + const lp = r.localParticipant + if (!lp) return + if (typeof lp.publishData === 'function') { + const payload = JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop', ts: Date.now() }) + const enc = new TextEncoder().encode(payload) + try { await lp.publishData(enc, { reliable: true }) } catch(e) { console.warn('publishData failed', e) } + return + } + if (typeof r.sendData === 'function') { + try { r.sendData(JSON.stringify({ type: 'RECORDING', action: start ? 'start' : 'stop' })) } catch(e) {} + } + } catch (e) { + console.warn('safeToggleRecording failed', e) + } + } + + const handleToggleMute = async () => { + const next = !muted + setMuted(next) + onToggleMute?.(next) + await safeSetMic(!next ? true : false) + } + + const handleToggleCamera = async () => { + const next = !cameraOn + setCameraOn(next) + onToggleCamera?.(next) + await safeSetCamera(next) + } + + const handleToggleRecording = async () => { + const next = !recording + setRecording(next) + onToggleRecording?.(next) + } + + return ( +
+ + +
+ } + active={!muted} + title={muted ? 'Activar micrófono' : 'Silenciar'} + onClick={handleToggleMute} + size="sm" + /> + {muted ? 'Activar micrófono' : 'Silenciar'} +
+ +
+ } + active={cameraOn} + title={cameraOn ? 'Apagar cámara' : 'Encender cámara'} + onClick={handleToggleCamera} + size="sm" + /> + {cameraOn ? 'Apagar cámara' : 'Encender cámara'} +
+ +
+ : undefined} + label={recording ? 'Stop' : 'Start'} + active={recording} + danger={true} + title={recording ? 'Detener grabación' : 'Iniciar grabación'} + onClick={handleToggleRecording} + size="md" + /> + {recording ? 'Detener grabación' : 'Iniciar grabación'} +
+ + {recording ? 'Grabación iniciada' : 'Grabación detenida'} + +
+
+ ) +} + diff --git a/packages/broadcast-panel/src/features/studio/StudioPortal.css b/packages/broadcast-panel/src/features/studio/StudioPortal.css new file mode 100644 index 0000000..2201b37 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/StudioPortal.css @@ -0,0 +1,17 @@ +// moved from studio-panel +.studio-portal{display:flex;height:100vh;gap:12px;background:var(--studio-bg-primary)} +.studio-portal__left{width:280px;background:var(--studio-bg-secondary);padding:12px;border-right:1px solid var(--studio-border)} +.studio-portal__right{width:320px;background:var(--studio-bg-secondary);padding:12px;border-left:1px solid var(--studio-border)} +.studio-portal__center{flex:1;display:flex;flex-direction:column;align-items:stretch;padding:12px} +.preview-wrapper{flex:1;background:#0f0f0f;border-radius:8px;display:flex;align-items:center;justify-content:center} +.controls-bar{display:flex;justify-content:space-between;align-items:center;padding:12px 0} +.scenes-header{font-weight:700;margin-bottom:8px} +.scenes-list{display:flex;flex-direction:column;gap:8px} +.scene-item{padding:10px;border-radius:6px;background:var(--studio-bg-primary);border:1px solid var(--studio-border)} +.scene-item.active{background:var(--studio-accent);color:#fff} +.layout-presets{display:flex;gap:8px} +.layout-btn{padding:8px 10px;border-radius:6px;background:var(--studio-bg-primary);border:1px solid var(--studio-border)} +.layout-btn.active{background:var(--studio-accent);color:#fff} +.actions .btn-record{background:#2563eb;color:#fff;padding:10px 16px;border-radius:8px;border:none} +.actions .btn-stop{background:#dc2626;color:#fff;padding:10px 16px;border-radius:8px;border:none} + diff --git a/packages/broadcast-panel/src/features/studio/StudioPortal.tsx b/packages/broadcast-panel/src/features/studio/StudioPortal.tsx new file mode 100644 index 0000000..f5b3d50 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/StudioPortal.tsx @@ -0,0 +1,219 @@ +import React, { useState, useEffect, useRef } from 'react'; +import StudioRoom from './StudioRoom'; +import './StudioPortal.css'; +import { Room } from 'livekit-client'; + +export interface StudioPortalProps { + serverUrl: string; + token: string; + roomName?: string; + onRoomConnected?: () => void; + onRoomDisconnected?: () => void; + onRoomConnectError?: (err: any) => void; + /** optional external LiveKit Room instance */ + room?: any; +} + +const LAYOUTS = [ + { id: 'layout-1', label: 'Individual' }, + { id: 'layout-2', label: 'Gallery' }, + { id: 'layout-3', label: 'Speaker' }, + { id: 'layout-4', label: 'Wide' }, +]; + +export default function StudioPortal({ serverUrl, token, roomName, onRoomConnected, onRoomDisconnected, onRoomConnectError, room }: StudioPortalProps) { + const [activeLayout, setActiveLayout] = useState(LAYOUTS[0].id); + const [live, setLive] = useState(false); + // allow override of serverUrl via postMessage (useful for e2e) + const [serverUrlOverride, setServerUrlOverride] = useState(null); + + // Local room management when App does not provide a room prop + const localRoomRef = useRef(null); + const [isConnecting, setIsConnecting] = useState(false); + const [isConnected, setIsConnected] = useState(false); + const isExternalRoom = Boolean(room); + + // New: tokenFromMessage state and connectError + const [tokenFromMessage, setTokenFromMessage] = useState(null); + const [connectError, setConnectError] = useState(null); + + // Connect function used by UI or auto when token arrives + const connectWithToken = async (useToken?: string, useServer?: string) => { + const tk = useToken || tokenFromMessage || token; + const sUrl = useServer || serverUrlOverride || serverUrl; + if (!tk || !sUrl) return; + try { + setConnectError(null); + setIsConnecting(true); + // cleanup previous + if (localRoomRef.current) { + try { localRoomRef.current.disconnect(); } catch(e) {} + localRoomRef.current = null; + } + const r = new Room(); + localRoomRef.current = r; + await r.connect(sUrl, tk); + setIsConnected(true); + onRoomConnected && onRoomConnected(); + } catch (err) { + console.error('StudioPortal: failed to connect local room', err); + setIsConnected(false); + const msg = (err as any)?.message ?? String(err); + setConnectError(msg); + try { onRoomConnectError && onRoomConnectError(err) } catch(e){} + } finally { + setIsConnecting(false); + } + }; + + const disconnectLocalRoom = () => { + try { + if (localRoomRef.current) { + localRoomRef.current.disconnect(); + localRoomRef.current = null; + } + } catch (e) { /* ignore */ } + setIsConnected(false); + onRoomDisconnected && onRoomDisconnected(); + }; + + // Auto-connect when token becomes available and there is no external room + useEffect(() => { + if (!isExternalRoom && (tokenFromMessage || token) && (tokenFromMessage || token).trim() && !isConnected && !isConnecting) { + connectWithToken(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token, tokenFromMessage, serverUrl]); + + // Listen for postMessage tokens from Broadcast Panel (or parent) + useEffect(() => { + function onMessage(e: MessageEvent) { + try { + const data = e.data || {}; + // respond to ping from launcher so it knows we are ready + if (data && data.type === 'LIVEKIT_PING') { + try { (e.source as Window)?.postMessage?.({ type: 'LIVEKIT_READY' }, e.origin || '*') } catch(e) { /* ignore */ } + return + } + // accept object messages with type LIVEKIT_TOKEN and token + if (data && data.type === 'LIVEKIT_TOKEN' && data.token) { + console.info('StudioPortal received token via postMessage', { origin: e.origin }) + setTokenFromMessage(String(data.token)) + // optionally accept serverUrl override for e2e flows + if (data.serverUrl) setServerUrlOverride(String(data.serverUrl)); + // reply ack to sender + try { (e.source as Window)?.postMessage?.({ type: 'LIVEKIT_ACK', room: data.room || '' }, e.origin || '*') } catch(e) { /* ignore */ } + } + } catch (err) { console.warn('postMessage handler error', err) } + } + window.addEventListener('message', onMessage); + return () => { window.removeEventListener('message', onMessage) } + }, []); + + // Cleanup on unmount + useEffect(() => { + return () => { + try { if (localRoomRef.current) { localRoomRef.current.disconnect(); localRoomRef.current = null; } } catch (e) {} + }; + }, []); + + const handleStartLive = () => { + window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'start' } })); + setLive(true); + }; + const handleStopLive = () => { + window.dispatchEvent(new CustomEvent('avz:request:go-live', { detail: { action: 'stop' } })); + setLive(false); + }; + + const changeLayout = (id: string) => { + setActiveLayout(id); + try { + window.dispatchEvent(new CustomEvent('avz:layout:change', { detail: { layoutId: id } })); + } catch (e) { console.warn('layout dispatch failed', e); } + }; + + // Determine which room to pass into StudioRoom: external first, fallback to local + const effectiveRoom = room || localRoomRef.current || undefined; + + return ( +
+ + +
+
+
+ LiveKit: {serverUrlOverride || serverUrl} +
+
+ {!isExternalRoom && ( + <> + + + + )} + {isExternalRoom && ( +
Usando Room externo
+ )} +
+
+ + {/* show token status / errors for E2E debugging */} +
+ {tokenFromMessage ? ( +
Token recibido desde Broadcast Panel (length {tokenFromMessage.length})
+ ) : ( +
Esperando token...
+ )} + {connectError && ( +
Error de conexión: {connectError}
+ )} +
+ +
+ +
+ +
+
+ {LAYOUTS.map(l => ( + + ))} +
+ +
+ {!live ? ( + + ) : ( + + )} +
+
+
+ + +
+ ); +} diff --git a/packages/broadcast-panel/src/features/studio/StudioRoom.css b/packages/broadcast-panel/src/features/studio/StudioRoom.css new file mode 100644 index 0000000..13d80cd --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/StudioRoom.css @@ -0,0 +1,7 @@ +/* Minimal placeholder styles for StudioRoom used in tests */ +.studio-room { font-family: sans-serif; } +.studio-room__header { display:flex; justify-content:space-between; } +.studio-room__content { height: 400px; } +.controls-inner { display:flex; gap:8px; } +.tooltip { display:none; } + diff --git a/packages/broadcast-panel/src/features/studio/StudioRoom.tsx b/packages/broadcast-panel/src/features/studio/StudioRoom.tsx new file mode 100644 index 0000000..153d050 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/StudioRoom.tsx @@ -0,0 +1,373 @@ +import React, { useEffect, useState, useRef } from 'react'; +import type { Room } from 'livekit-client' +import { + GridLayout, + ParticipantTile, + ControlBar, + RoomAudioRenderer, + useTracks, + RoomContext, +} from '@livekit/components-react'; +import '@livekit/components-styles'; +import { Button } from 'avanza-ui'; +import './StudioRoom.css'; +import BottomControls from './BottomControls'; + +export interface StudioRoomProps { + /** LiveKit server URL */ + serverUrl: string; + /** Authentication token */ + token: string; + /** Room name */ + roomName?: string; + /** Callback when room is connected */ + onConnected?: () => void; + /** Callback when room is disconnected */ + onDisconnected?: () => void; + /** Callback when connection fails */ + onConnectError?: (err: any) => void; + /** Optional externally-created LiveKit Room instance */ + room?: Room; +} + +export const StudioRoom: React.FC = ({ + serverUrl, + token, + roomName, + onConnected, + onDisconnected, + room: externalRoom, + onConnectError, +}) => { + // If an external Room is provided, use it; otherwise create an internal Room lazily + const internalRoomRef = useRef(null); + const getRoom = () => externalRoom || internalRoomRef.current; + // Local alias used throughout the component to reference current Room (external or internal) + const room = externalRoom || internalRoomRef.current; + // effectiveRoom used to decide whether to render LiveKit components (must be a valid Room instance) + const effectiveRoom = getRoom(); + const hasValidRoom = !!effectiveRoom && (typeof (effectiveRoom as any).connect === 'function' || !!(effectiveRoom as any).localParticipant) + const isExternalRoom = !!externalRoom; + const [connectError, setConnectError] = useState(null); + const [participantsList, _setParticipantsList] = useState>([]); + const connectedRef = React.useRef(false); + const connectingRef = React.useRef(false); + const previewRef = React.useRef(null); + const [lines, setLines] = useState>([]); + + // connectRoom: reusable connect logic for initial attempt and retries + const connectRoom = React.useCallback( + async (attemptToken?: string, attemptServer?: string) => { + if (connectingRef.current) return; + connectingRef.current = true; + setConnectError(null); + try { + const sUrl = attemptServer || serverUrl; + const tk = attemptToken || token; + if (!sUrl || !tk) { + // Avoid throwing inside this catch-all to keep analyzer happy; set error and return + setConnectError('Missing serverUrl or token'); + return; + } + // Only create/connect internal Room if no external one provided + if (!isExternalRoom) { + if (!internalRoomRef.current) { + // dynamic import to avoid executing livekit-client at module load + const lk = await import('livekit-client'); + const LiveKitRoom = lk.Room; + internalRoomRef.current = new LiveKitRoom({ adaptiveStream: true, dynacast: true }) as any; + } + if (internalRoomRef.current && typeof internalRoomRef.current.connect === 'function') { + await internalRoomRef.current.connect(sUrl, tk); + } + } + connectedRef.current = true; + setConnectError(null); + onConnected?.(); + } catch (err: any) { + console.error('StudioRoom connect failed', err); + setConnectError(String(err?.message || err || 'Connection failed')); + try { onConnectError && onConnectError(err) } catch(e){} + } finally { + connectingRef.current = false; + } + }, + [serverUrl, token, onConnected, isExternalRoom] + ); + + useEffect(() => { + let mounted = true; + + // Attempt initial connect once when mounted + (async () => { + if (!mounted) return; + // If we're using an internal room, attempt initial connect + if (!isExternalRoom) await connectRoom(); + else { + // If external room is already connected, notify parent + try { if ((getRoom() as any)?.state === 'connected' || (getRoom() as any)?.isConnected) { connectedRef.current = true; onConnected?.(); } } catch(e){} + } + })(); + + // If token or serverUrl changes after mount, attempt to connect (useful when token is injected later) + // No polling here: connection will be attempted on mount, and further + // attempts are triggered by an effect that watches `token`/`serverUrl`. + return () => { + mounted = false; + // cleanup listeners if present + try { const r = effectiveRoom; (r as any).off && (r as any).off('dataReceived'); } catch(e){} + try { + // Only disconnect if we actually connected + if (!isExternalRoom && connectedRef.current && internalRoomRef.current && typeof internalRoomRef.current.disconnect === 'function') { + internalRoomRef.current.disconnect(); + internalRoomRef.current = null; + } + } catch (e) { /* ignore */ } + onDisconnected?.(); + // poll removed + }; + }, [effectiveRoom, connectRoom, onDisconnected]); + + // Reactively attempt to connect whenever token or serverUrl changes + useEffect(() => { + try { + if (connectedRef.current) return; // already connected + if (!connectingRef.current && (token && token.trim()) && (serverUrl && serverUrl.trim())) { + // attempt connection with the latest props + connectRoom(token, serverUrl); + } + } catch (e) { console.warn('reactive connect attempt failed', e); } + }, [token, serverUrl, connectRoom]); + + // Notify parent when the room actually becomes connected + useEffect(() => { + // Poll connectedRef to know when it's set by connectRoom + const t = setInterval(() => { + if (connectedRef.current) { + onConnected?.(); + clearInterval(t); + } + }, 250); + return () => clearInterval(t); + }, [onConnected]); + + // If using external room, notify parent when it becomes connected + useEffect(() => { + if (!isExternalRoom) return; + const checkInterval = setInterval(() => { + try { + if ((effectiveRoom as any)?.state === 'connected' || (effectiveRoom as any)?.isConnected) { + connectedRef.current = true; + onConnected?.(); + clearInterval(checkInterval); + } + } catch(e){} + }, 250); + return () => clearInterval(checkInterval); + }, [isExternalRoom, effectiveRoom, onConnected]); + + // Auto-start camera, mic, and "recording" when connected + useEffect(() => { + if (!connectedRef.current) return; + + const autoStart = async () => { + try { + const lp = getRoom()?.localParticipant; + if (!lp) return; + + // Auto-enable camera + try { + await lp.setCameraEnabled(true); + console.log('Auto-enabled camera'); + } catch (e) { + console.warn('Failed to auto-enable camera:', e); + } + + // Auto-enable microphone + try { + await lp.setMicrophoneEnabled(true); + console.log('Auto-enabled microphone'); + } catch (e) { + console.warn('Failed to auto-enable microphone:', e); + } + + // NOTE: removed automatic recording/start signal per request (focus on transmission only) + + } catch (e) { + console.warn('Auto-start failed:', e); + } + }; + + // Small delay to ensure room is fully ready + const timer = setTimeout(autoStart, 1000); + return () => clearTimeout(timer); + }, [room]); + + useEffect(() => { + // layout change listener: apply data-layout attribute to root element + function onLayoutChange(e: any) { + try { + const layoutId = e?.detail?.layoutId; + const root = document.querySelector('.studio-room'); + if (root && layoutId) { + (root as HTMLElement).setAttribute('data-layout', String(layoutId)); + console.log('Applied layout', layoutId); + } + } catch (err) { console.warn('layout change handler error', err) } + } + window.addEventListener('avz:layout:change', onLayoutChange as EventListener); + + return () => { + window.removeEventListener('avz:layout:change', onLayoutChange as EventListener); + }; + }, []); + + // Recalculate overlay lines between moderator (local) and guests + React.useEffect(() => { + function computeLines(){ + try{ + const container = previewRef.current; + if (!container) return setLines([]); + const rootRect = container.getBoundingClientRect(); + // find moderator tile by identity + const localIdentity = effectiveRoom?.localParticipant?.identity; + let moderatorEl: Element | null = null; + if (localIdentity) { + moderatorEl = Array.from(container.querySelectorAll('.lk-participant-name')).find(el => (el.textContent || '').trim() === localIdentity) as Element || null; + } + // fallback: try first participant tile inside container + if (!moderatorEl) moderatorEl = container.querySelector('.lk-participant-tile'); + + if (!moderatorEl) return setLines([]); + const mRect = (moderatorEl as HTMLElement).getBoundingClientRect(); + const mx = mRect.left + mRect.width/2 - rootRect.left; + const my = mRect.top + mRect.height/2 - rootRect.top; + + const newLines: Array<{x1:number,y1:number,x2:number,y2:number, accepted?: boolean}> = []; + // for each participant (excluding local), find tile by name and create line + participantsList.forEach(p => { + if (p.isLocal) return; + const el = Array.from(container.querySelectorAll('.lk-participant-name')).find(el => (el.textContent||'').trim() === p.identity) as Element | undefined; + if (!el) return; + const tRect = (el as HTMLElement).closest('.lk-participant-tile')?.getBoundingClientRect(); + if (!tRect) return; + const tx = tRect.left + tRect.width/2 - rootRect.left; + const ty = tRect.top + tRect.height/2 - rootRect.top; + newLines.push({ x1: mx, y1: my, x2: tx, y2: ty, accepted: !!p.accepted }); + }); + setLines(newLines); + }catch(e){ console.warn('computeLines error', e); setLines([]) } + } + computeLines(); + if (typeof (globalThis as any).ResizeObserver !== 'undefined') { + const ro = new (globalThis as any).ResizeObserver(()=> computeLines()); + if (previewRef.current) ro.observe(previewRef.current); + window.addEventListener('resize', computeLines); + const interval = setInterval(computeLines, 1200); + return ()=>{ ro.disconnect(); window.removeEventListener('resize', computeLines); clearInterval(interval); }; + } + // If ResizeObserver not present (e.g., jsdom), just return cleanup-less or simple interval + const interval = setInterval(computeLines, 1200); + window.addEventListener('resize', computeLines); + return () => { window.removeEventListener('resize', computeLines); clearInterval(interval); }; + }, [participantsList, effectiveRoom]); + + + return ( +
+ {connectError && ( +
+
Error al conectar a LiveKit
+
{connectError}
+
Server: {serverUrl}
+
+ + +
+
+ )} + {hasValidRoom ? ( + +
+
+

Estudio - {roomName || 'Sin nombre'}

+
+
+ En vivo +
+
+
+ + +
+
+ +
+
+ + {/* SVG overlay for connection lines */} + + {lines.map((ln,i)=>( + + ))} + +
+
+ +
+ +
+ + {/* Our BottomControls will consume RoomContext and control mic/cam/recording */} + + + + + ) : ( +
+
Conectando al estudio...
+
Esperando a que la sesión se establezca. Si esto tarda demasiado, comprueba el token y conexión a LiveKit.
+
+ +
+
+ )} +
+ ); +}; + +function VideoConferenceView() { + // Defensive: ensure a Room exists in context before calling useTracks (livekit components throw otherwise) + const ctxRoom = React.useContext(RoomContext as any) + if (!ctxRoom) { + // Render a lightweight placeholder while the room is not ready + return ( +
+ Conectando streams... +
+ ) + } + + // Avoid direct dependency on livekit-client Track constants; cast to any to satisfy TS types in this integration layer + const tracks = useTracks(([ + { source: 'camera', withPlaceholder: true }, + { source: 'screen', withPlaceholder: false }, + ] as any), { onlySubscribed: false } as any) + + return ( + + + + ); +} + +// Note: when rendering in tests, `livekit-client` is mocked in vitest.setup.ts; +// dynamic imports above will resolve to the mock in the test environment. +export default StudioRoom; diff --git a/packages/broadcast-panel/src/features/studio/__tests__/StudioPortal.test.tsx b/packages/broadcast-panel/src/features/studio/__tests__/StudioPortal.test.tsx new file mode 100644 index 0000000..d6cc801 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/__tests__/StudioPortal.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom + +import React from 'react' +import { render, screen, waitFor, fireEvent } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +// Ensure livekit UI lib is mocked before any module imports that may load it +vi.mock('@livekit/components-react', () => { + const React = require('react'); + const noop = () => React.createElement('div', null); + return { + GridLayout: noop, + ParticipantTile: noop, + ControlBar: noop, + RoomAudioRenderer: noop, + useTracks: () => [], + RoomContext: { Provider: ({ children }: any) => children }, + useRoom: () => ({ room: null }), + useParticipant: () => ({ participant: null }), + useLocalParticipant: () => ({ localParticipant: null }), + usePrefetchRoom: () => ({}), + }; +}); +vi.mock('@livekit/components-styles', () => ({})); + +// Stub the actual StudioRoom implementation in the studio-panel package to avoid heavy hooks +vi.mock('../../../../studio-panel/src/components/StudioRoom/StudioRoom', () => ({ + __esModule: true, + default: (props: any) => React.createElement('div', { id: 'studio-room-mock' }) +})) + +// Also stub the local re-export module to be safe +vi.mock('../StudioRoom/StudioRoom', () => ({ + __esModule: true, + default: (props: any) => React.createElement('div', { id: 'studio-room-mock' }) +})) + +import StudioPortal from '../StudioPortal' +const livekitMock: any = require('livekit-client'); + +describe('StudioPortal', () => { + beforeEach(() => { + vi.clearAllMocks() + // reset instances array + if (livekitMock && livekitMock.__mocks && Array.isArray(livekitMock.__mocks.instances)) { + livekitMock.__mocks.instances.length = 0; + } + }) + + it('creates a local Room and connects when token is provided and no external room', async () => { + render() + + // wait for the connect to be called + await waitFor(() => { + expect(livekitMock.__mocks.instances[0]).toBeDefined() + expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith('wss://example', 'FAKE_TOKEN') + }) + }) + + it('does not create a local Room when external room is provided', async () => { + const fakeRoom = { connect: vi.fn(), disconnect: vi.fn() } + render() + + // local constructor should not be called + await new Promise((r) => setTimeout(r, 50)) + expect(livekitMock.__mocks.instances.length).toBe(0) + }) + + it('connect/disconnect buttons call connectWithToken and disconnect', async () => { + // render without auto token to test manual connect: pass empty token first + const { rerender } = render() + + // Click connect button -> nothing happens since token empty, ensure no constructor called + const connectBtn = screen.getByText(/Conectar|Conectando...|Conectado/, { exact: false }) + fireEvent.click(connectBtn) + expect(livekitMock.__mocks.instances.length).toBe(0) + + // Rerender with token to enable connect via button + rerender() + + // Wait for auto connect (effect) or click button to trigger connect + await waitFor(() => expect(livekitMock.__mocks.instances[1]).toBeDefined()) + + // Now test disconnect button triggers disconnect + const disconnectBtn = screen.getByText('Desconectar') + fireEvent.click(disconnectBtn) + await waitFor(() => expect(livekitMock.__mocks.instances[1].disconnect).toHaveBeenCalled()) + }) +}) diff --git a/packages/broadcast-panel/src/features/studio/__tests__/e2e_simulated_flow.test.tsx b/packages/broadcast-panel/src/features/studio/__tests__/e2e_simulated_flow.test.tsx new file mode 100644 index 0000000..cd2f866 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/__tests__/e2e_simulated_flow.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +import React, { useEffect, useState } from 'react' +import { render, fireEvent, screen, waitFor } from '@testing-library/react' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +import useStudioLauncher from '../../../hooks/useStudioLauncher' + +// Ensure we don't import the heavy StudioRoom implementation that requires ResizeObserver/WebRTC +vi.mock('../../../../studio-panel/src/components/StudioRoom/StudioRoom', () => ({ + __esModule: true, + default: (_props: any) => React.createElement('div', { id: 'studio-room-mock' }) +})) +vi.mock('../StudioRoom/StudioRoom', () => ({ + __esModule: true, + default: (_props: any) => React.createElement('div', { id: 'studio-room-mock' }) +})) + +import StudioPortal from '../StudioPortal' + +// Simple component that exposes openStudio via a button using the hook +function LauncherButton({ room = 'sim-room', username = 'tester' }: { room?: string; username?: string }) { + const { openStudio } = useStudioLauncher() + return ( + + ) +} + +// App side that listens for LIVEKIT_TOKEN and renders StudioPortal when token arrives +function TestApp() { + const [token, setToken] = useState('') + const [serverUrl, setServerUrl] = useState('') + useEffect(() => { + function onMessage(e: MessageEvent) { + try { + const d = e.data || {} + if (d?.type === 'LIVEKIT_TOKEN' && d.token) { + // set token and optional url + setToken(String(d.token)) + if (d.url) setServerUrl(String(d.url)) + // Reply ACK via the message source if available + try { + const ack = { type: 'LIVEKIT_ACK', status: 'connected', room: d.room } + if (e.source && typeof (e.source as any).postMessage === 'function') { + try { (e.source as any).postMessage(ack, e.origin || '*') } catch (err) {} + } + // also post to opener/parent just in case + try { window.postMessage(ack, e.origin || '*') } catch (err) {} + } catch (err) {} + } + } catch (err) {} + } + window.addEventListener('message', onMessage) + return () => window.removeEventListener('message', onMessage) + }, []) + + return ( +
+
{token ? 'token:' + token : 'no-token'}
+ {token ? : null} +
+ ) +} + +describe('E2E simulated flow: Broadcast -> Studio', () => { + let originalOpen: any + let popupMock: any + beforeEach(() => { + vi.clearAllMocks() + originalOpen = (window as any).open + + // popup mock that simply dispatches message events to window when postMessage is called + // make popupMock a callable function object so TS doesn't complain if it's invoked + const pm: any = function() { /* noop callable */ }; + pm.location = { href: '' }; + pm.closed = false; + pm.postMessage = (message: any, targetOrigin: string) => { + // simulate asynchronous arrival in the popup (studio) + setTimeout(() => { + // Message arrives to the studio (which in our test is the same window) + const ev = new MessageEvent('message', { data: message, origin: targetOrigin, source: pm }) + window.dispatchEvent(ev) + }, 20) + }; + popupMock = pm + + // Replace window.open to return our popup mock + (window as any).open = vi.fn(() => popupMock) + + // mock fetch to token server to return a session with token and studioUrl + globalThis.fetch = vi.fn(() => Promise.resolve({ + ok: true, + json: () => Promise.resolve({ token: 'SIM_TOKEN', room: 'sim-room', studioUrl: window.location.origin + '/studio', url: 'wss://livekit-server.example' }) + })) as any + }) + + afterEach(() => { + (window as any).open = originalOpen + // @ts-ignore + globalThis.fetch = undefined + }) + + it('opens popup, sends token, studio receives it and StudioPortal connects (mock Room)', async () => { + // render both launcher and app + render( +
+ + +
+ ) + + // click launcher which calls openStudio -> will call fetch and popup.postMessage repeatedly + const btn = screen.getByTestId('open-studio') + fireEvent.click(btn) + + // Wait for TestApp to receive token and render StudioPortal status + await waitFor(() => expect(screen.getByTestId('status').textContent).toContain('SIM_TOKEN'), { timeout: 2000 }) + + // Now assert that the mocked livekit Room was instantiated and connect called. + // The project-level vitest setup provides a mock for 'livekit-client' with __mocks.instances + const livekitMock: any = require('livekit-client') + await waitFor(() => expect(livekitMock.__mocks.instances.length).toBeGreaterThan(0), { timeout: 2000 }) + expect(livekitMock.__mocks.instances[0].connect).toHaveBeenCalledWith('wss://livekit-server.example', 'SIM_TOKEN') + }) +}) diff --git a/packages/broadcast-panel/src/features/studio/__tests__/postMessage.test.ts b/packages/broadcast-panel/src/features/studio/__tests__/postMessage.test.ts new file mode 100644 index 0000000..78a6d25 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/__tests__/postMessage.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; + +// load the postMessage util from broadcast-panel +import * as pm from '../../../utils/postMessage'; + +describe('postMessage utils', () => { + it('exposes environment parsing helpers', () => { + // basic existence + expect(typeof pm.getAllowedOriginsFromEnv === 'function' || typeof pm.isAllowedOrigin === 'function').toBeTruthy(); + }) +}) diff --git a/packages/broadcast-panel/src/features/studio/__tests__/smoke.test.tsx b/packages/broadcast-panel/src/features/studio/__tests__/smoke.test.tsx new file mode 100644 index 0000000..0d31f72 --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/__tests__/smoke.test.tsx @@ -0,0 +1,32 @@ +// @vitest-environment jsdom + +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { vi, describe, it, expect, beforeEach } from 'vitest' + +// Stub Studio implementation to a minimal status element to avoid heavy dependencies +vi.mock('../../../components/Studio', () => ({ + __esModule: true, + default: () => React.createElement('div', { id: 'status' }, 'Conectado') +})) + +import Studio from '../../../components/Studio' + +describe('smoke test - Broadcast Studio integration', () => { + beforeEach(() => { + vi.clearAllMocks() + document.body.innerHTML = '' + }) + + it('renders Studio and auto connects via proxy token', async () => { + // Mock fetch to return token + (globalThis as any).fetch = vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ token: 'SMOKE', url: 'wss://example' }) })) + + render() + + await waitFor(() => { + const el = document.getElementById('status') + expect(el).toBeTruthy() + }, { timeout: 3000 }) + }) +}) diff --git a/packages/broadcast-panel/src/features/studio/icons/IconCameraOn.tsx b/packages/broadcast-panel/src/features/studio/icons/IconCameraOn.tsx new file mode 100644 index 0000000..579f8af --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/icons/IconCameraOn.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +export default function IconCameraOn(){ + return ( + + + + + ) +} + diff --git a/packages/broadcast-panel/src/features/studio/icons/IconMicOff.tsx b/packages/broadcast-panel/src/features/studio/icons/IconMicOff.tsx new file mode 100644 index 0000000..603c3ab --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/icons/IconMicOff.tsx @@ -0,0 +1,12 @@ +import React from 'react' + +export default function IconMicOff(){ + return ( + + + + + + ) +} + diff --git a/packages/broadcast-panel/src/features/studio/index.ts b/packages/broadcast-panel/src/features/studio/index.ts new file mode 100644 index 0000000..e217cbc --- /dev/null +++ b/packages/broadcast-panel/src/features/studio/index.ts @@ -0,0 +1,3 @@ +export { default as StudioPortal } from './StudioPortal'; +export { default as StudioRoom } from './StudioRoom'; + diff --git a/packages/broadcast-panel/src/hooks/useLayouts.ts b/packages/broadcast-panel/src/hooks/useLayouts.ts new file mode 100644 index 0000000..e69de29 diff --git a/packages/broadcast-panel/src/hooks/useStudioLauncher.ts b/packages/broadcast-panel/src/hooks/useStudioLauncher.ts index 3b23eb9..a07abb2 100644 --- a/packages/broadcast-panel/src/hooks/useStudioLauncher.ts +++ b/packages/broadcast-panel/src/hooks/useStudioLauncher.ts @@ -7,8 +7,10 @@ export type OpenStudioOptions = { } type SessionData = { + id?: string studioUrl?: string redirectUrl?: string + url?: string token?: string room?: string ttl?: number @@ -32,8 +34,60 @@ export default function useStudioLauncher() { const POST_MESSAGE_INTERVAL = 300 // ms try { - const TOKEN_SERVER = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' - const sessionUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session` + // Prefer explicit backend API URL (VITE_BACKEND_API_URL) then legacy VITE_TOKEN_SERVER_URL, fallback to known host + const TOKEN_SERVER = (import.meta.env.VITE_BACKEND_API_URL as string) || (import.meta.env.VITE_TOKEN_SERVER_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' + const absoluteSessionUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session` + const relativeSessionUrl = '/api/session' + + // Check if the app is running in integrated mode (Studio is a feature inside Broadcast Panel) + const INTEGRATED = (import.meta.env.VITE_STUDIO_INTEGRATED === 'true' || import.meta.env.VITE_STUDIO_INTEGRATED === '1') || false + + // Helper to POST to a URL and return parsed JSON or null + async function postSession(url: string) { + try { + const r = await fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ room, username, ttl }) + }) + const txt = await r.text().catch(() => '') + // If not ok, return object with status info + if (!r.ok) return { ok: false, status: r.status, body: txt } + // parse JSON if possible + try { return { ok: true, json: JSON.parse(txt) } } catch (e) { return { ok: false, status: r.status, body: txt } } + } catch (err) { return { ok: false, error: String(err) } } + } + + // If integrated mode is enabled, fetch session data to be used inside the SPA + if (INTEGRATED) { + // In integrated mode (Studio inside the same SPA) prefer backend API (absolute) first + let sr = await postSession(absoluteSessionUrl) + if (!sr.ok) sr = await postSession(relativeSessionUrl) + + if (!sr.ok) { + const attempted = sr.error ? sr.error : (sr.body || 'no body') + const hint = `Intentado: ${absoluteSessionUrl} (luego ${relativeSessionUrl}). Posibles causas: backend no accesible, CORS, mixed-content (https página -> http backend). Prueba: curl -v "${absoluteSessionUrl}" -H 'Content-Type: application/json' -d '{"room":"${room}","username":"${username}"}'` + const msg = `No se pudo crear la sesión (${sr.status || 'err'}) ${attempted} — ${hint}` + console.error('[useStudioLauncher]', msg, { sr, sessionUrl: absoluteSessionUrl }) + setError(msg) + setLoadingId(null) + return null + } + const sessionData: SessionData = sr.json + try { console.debug('[useStudioLauncher] sessionData (integrated)', sessionData) } catch (e) { /* ignore */ } + // Store session data in sessionStorage so the integrated StudioPortal component can pick it up + // and dispatch an event so the StudioPortal can react immediately. + try { + const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' + const payload = JSON.stringify(sessionData) + sessionStorage.setItem(storeKey, payload) + try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: sessionData })) } catch (e) { /* ignore */ } + console.debug('[useStudioLauncher] sessionData stored in sessionStorage key=', storeKey) + } catch (e) { + console.warn('[useStudioLauncher] failed to write sessionStorage', e) + } + setLoadingId(null) + return sessionData + } // Try to open a blank popup immediately (in direct response to user action) to reduce popup-blocker issues let popup: Window | null = null @@ -45,76 +99,91 @@ export default function useStudioLauncher() { // If popup failed to open, we will fallback to redirect later - const sessionRes = await fetch(sessionUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ room, username, ttl }) - }) - if (!sessionRes.ok) { - const txt = await sessionRes.text().catch(() => '') - const msg = `No se pudo crear la sesión (${sessionRes.status}) ${txt}` - console.error('[useStudioLauncher]', msg) - setError(msg) - setLoadingId(null) - // Close popup if we opened it but will not navigate it - try { popup?.close() } catch (e) { /* ignore */ } - return null - } - const sessionData: SessionData = await sessionRes.json() + // For popup flow prefer backend absolute API (may be on different host) then fallback to same-origin + let res = await postSession(absoluteSessionUrl) + if (!res.ok) res = await postSession(relativeSessionUrl) - const studioUrl = sessionData.studioUrl || sessionData.redirectUrl || null - if (!studioUrl) { - const msg = 'No studio URL returned from token server' - console.error('[useStudioLauncher]', msg) + if (!res.ok) { + const attempted = res.error ? res.error : (res.body || 'no body') + const hint = `Intentado: ${absoluteSessionUrl} (luego ${relativeSessionUrl}). Posibles causas: backend no accesible, CORS, mixed-content (https página -> http backend). Prueba: curl -v "${absoluteSessionUrl}" -H 'Content-Type: application/json' -d '{"room":"${room}","username":"${username}"}'` + const msg = `No se pudo crear la sesión (${res.status || 'err'}) ${attempted} — ${hint}` + console.error('[useStudioLauncher]', msg, { res, sessionUrl: absoluteSessionUrl }) setError(msg) setLoadingId(null) try { popup?.close() } catch (e) { /* ignore */ } return null } - const targetUrl = sessionData.studioUrl || sessionData.redirectUrl || studioUrl + const sessionData: SessionData = res.json + try { console.debug('[useStudioLauncher] sessionData', sessionData) } catch (e) { /* ignore */ } + // If the popup failed to open but we still have sessionData, store it locally so the user can continue + try { + const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' + const payload = JSON.stringify(sessionData) + sessionStorage.setItem(storeKey, payload) + try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: sessionData })) } catch (e) { /* ignore */ } + console.debug('[useStudioLauncher] sessionData cached in sessionStorage key=', storeKey) + } catch (e) { + /* ignore storage errors */ + } + + // Build targetUrl: prefer broadcast-panel route /:id so the Broadcast Panel path contains the session id + const BROADCAST_BASE = (import.meta.env.VITE_BROADCASTPANEL_URL as string) || (typeof window !== 'undefined' ? window.location.origin : '') + const broadcastPanelUrl = sessionData.id ? `${BROADCAST_BASE.replace(/\/$/, '')}/${encodeURIComponent(sessionData.id)}` : '' + + // Keep original fallback order but prefer broadcastPanelUrl when available + const targetUrl = broadcastPanelUrl || sessionData.studioUrl || sessionData.redirectUrl || (sessionData as any).url || '' - // If popup couldn't be opened, fallback to redirecting current window to redirectUrl (may contain token) if (!popup) { + setLoadingId(null) + return sessionData + } + + if (targetUrl) { try { - const fallback = sessionData.redirectUrl || targetUrl - window.location.href = fallback - setLoadingId(null) - return sessionData + popup.location.href = targetUrl } catch (e) { - // can't redirect, return error - const msg = 'No se pudo abrir popup ni redirigir' + (String(e) || '') - console.error('[useStudioLauncher]', msg) - setError(msg) - setLoadingId(null) - return null + try { popup.location.assign(targetUrl) } catch (e2) { /* ignore */ } } } - // We have a popup window. Navigate it to the studio (without token in URL if possible) - try { - popup.location.href = targetUrl - } catch (e) { - // Some browsers may block setting location for cross-origin until navigation happens - try { popup.location.assign(targetUrl) } catch (e2) { /* ignore */ } - } - - // Prepare message to send the token - const msgPayload = { type: 'LIVEKIT_TOKEN', token: sessionData.token, room: sessionData.room } const targetOrigin = (() => { try { return new URL(targetUrl).origin } catch (e) { return '*' } })() - let posted = false + async function waitForPopupReady(timeout = 1500) { + return new Promise((resolve) => { + let resolved = false + const onMsg = (e: MessageEvent) => { + try { + const d = e.data || {} + if (d?.type === 'LIVEKIT_READY') { + resolved = true + window.removeEventListener('message', onMsg) + resolve(true) + } + } catch (err) { /* ignore */ } + } + window.addEventListener('message', onMsg) + try { popup?.postMessage({ type: 'LIVEKIT_PING' }, targetOrigin) } catch (e) {} + setTimeout(() => { + if (!resolved) { + window.removeEventListener('message', onMsg) + resolve(false) + } + }, timeout) + }) + } + + const msgPayload = { type: 'LIVEKIT_TOKEN', token: sessionData.token, room: sessionData.room, url: (sessionData as any).url } + let ackReceived = false - // Listen for ACK from the studio window function onMessage(e: MessageEvent) { try { const d = e.data || {} if (d?.type === 'LIVEKIT_ACK' && d?.room === sessionData.room) { ackReceived = true - // optional: we can close the popup opener listener window.removeEventListener('message', onMessage) } } catch (err) { @@ -123,47 +192,33 @@ export default function useStudioLauncher() { } window.addEventListener('message', onMessage) + const popupReady = await waitForPopupReady(1200) + if (popupReady) { + try { popup?.postMessage(msgPayload, targetOrigin) } catch (e) { /* ignore */ } + const ackWaitStart = Date.now() + while (Date.now() - ackWaitStart < 1200 && !ackReceived) { + await new Promise((r) => setTimeout(r, 100)) + } + } + const start = Date.now() - // Try posting repeatedly until timeout or ACK - while (!posted && Date.now() - start < POST_MESSAGE_TIMEOUT && !ackReceived) { + while (Date.now() - start < POST_MESSAGE_TIMEOUT && !ackReceived) { try { - // postMessage itself doesn't throw for cross-origin; we still wrap it - popup.postMessage(msgPayload, targetOrigin) - posted = true // assume success; ack will confirm + try { popup?.postMessage(msgPayload, targetOrigin) } catch (e) { /* ignore cross-origin errors */ } } catch (e) { - // ignore and retry + // ignore } - if (!posted) await new Promise((r) => setTimeout(r, POST_MESSAGE_INTERVAL)) + await new Promise((r) => setTimeout(r, POST_MESSAGE_INTERVAL)) } - // If we posted but didn't receive ACK, try a short wait for ack - const waitForAck = () => new Promise((resolve) => { - const maxWait = 2000 - const t0 = Date.now() - const int = setInterval(() => { - if (ackReceived || Date.now() - t0 > maxWait) { - clearInterval(int) - resolve() - } - }, 100) - }) - - if (posted) { - await waitForAck() - } - - // If we couldn't post at all or no ACK received, fallback to redirect to redirectUrl (may include token) - if (!posted || (!ackReceived && sessionData.redirectUrl)) { + if (!ackReceived && sessionData.redirectUrl) { try { - // navigate popup to redirectUrl which typically contains token - const fallback = sessionData.redirectUrl || targetUrl - popup.location.href = fallback + popup.location.href = sessionData.redirectUrl } catch (e) { - // If navigation fails, try to navigate the current window - try { window.location.href = sessionData.redirectUrl || targetUrl } catch (e2) { /* ignore */ } + try { window.location.href = sessionData.redirectUrl } catch (e2) { /* ignore */ } } } - + // finished setLoadingId(null) return sessionData } catch (err: any) { diff --git a/packages/broadcast-panel/src/hooks/useStudioMessageListener.ts b/packages/broadcast-panel/src/hooks/useStudioMessageListener.ts new file mode 100644 index 0000000..be4a407 --- /dev/null +++ b/packages/broadcast-panel/src/hooks/useStudioMessageListener.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react' + +export type LivekitMessage = { + type: 'LIVEKIT_TOKEN' + token?: string + room?: string + url?: string +} + +export default function useStudioMessageListener(onReceive: (msg: LivekitMessage) => void) { + useEffect(() => { + // Build allowed origins list from env or default to current origin + const envAllowed = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS || import.meta.env.VITE_BROADCASTPANEL_URL || '') as string + const allowedOrigins = envAllowed ? envAllowed.split(',').map(s => s.trim()) : [window.location.origin] + + function handler(e: MessageEvent) { + try { + // Validate origin: allow same-window messages (origin may equal window.location.origin) + const origin = e.origin || window.location.origin + if (!allowedOrigins.includes(origin) && origin !== window.location.origin) return + + const data = e.data || {} + if (data && data.type === 'LIVEKIT_TOKEN') { + onReceive({ type: 'LIVEKIT_TOKEN', token: data.token, room: data.room, url: data.url }) + } + } catch (err) { + // ignore malformed messages + } + } + window.addEventListener('message', handler) + return () => window.removeEventListener('message', handler) + }, [onReceive]) +} diff --git a/packages/broadcast-panel/src/hooks/useStudioSession.ts b/packages/broadcast-panel/src/hooks/useStudioSession.ts new file mode 100644 index 0000000..5266686 --- /dev/null +++ b/packages/broadcast-panel/src/hooks/useStudioSession.ts @@ -0,0 +1,63 @@ +// Hook: useStudioSession +// - GET /api/session/:id/token +// - Returns { token, url, room, username } or error + +import { useEffect, useState } from 'react'; + +export type StudioSession = { + token: string; + ttlSeconds?: number; + room?: string; + username?: string; + url?: string; +}; + +export function useStudioSession(sessionId?: string | null) { + const [loading, setLoading] = useState(false); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!sessionId) return; + let aborted = false; + async function fetchToken() { + setLoading(true); + setError(null); + try { + const base = import.meta.env.VITE_BACKEND_TOKENS_URL || import.meta.env.VITE_BROADCASTPANEL_URL || ''; + // Prefer absolute backend URL env var; else assume same origin + port 4000 + const backend = import.meta.env.VITE_BACKEND_TOKENS_URL || `http://localhost:4000`; + const url = `${backend.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}/token`; + + // Helpful debug information for E2E and development + console.debug('[useStudioSession] fetching token from', url, { sessionId, backend, envBase: base }); + + const resp = await fetch(url, { method: 'GET', credentials: 'include' }); + if (!resp.ok) { + const txt = await resp.text().catch(() => '()'); + const msg = `fetch failed status=${resp.status} body=${txt}`; + throw new Error(msg); + } + const json = await resp.json(); + if (!aborted) setData(json as StudioSession); + } catch (err: any) { + if (!aborted) { + // Enriquecer el mensaje para ayudar a diagnosticar 'Failed to fetch' + let enriched = String(err?.message || err); + // If it's a network error (TypeError: Failed to fetch) give common causes hints + if (enriched.includes('Failed to fetch') || enriched.includes('NetworkError') || enriched.includes('TypeError')) { + enriched = `${enriched} — posible causa: backend no accesible, CORS bloqueando la petición, mixed-content (https página -> http backend), o error de red. Comprueba que VITE_BACKEND_TOKENS_URL apunta al backend correcto y que el backend está levantado en esa URL.`; + } + console.error('[useStudioSession] error fetching token', { sessionId, error: enriched }); + setError(enriched); + } + } finally { + if (!aborted) setLoading(false); + } + } + fetchToken(); + return () => { aborted = true }; + }, [sessionId]); + + return { loading, data, error }; +} diff --git a/packages/broadcast-panel/src/main.tsx b/packages/broadcast-panel/src/main.tsx index 3528ba2..c6d3a42 100644 --- a/packages/broadcast-panel/src/main.tsx +++ b/packages/broadcast-panel/src/main.tsx @@ -3,10 +3,195 @@ import { createRoot } from 'react-dom/client' import PageContainer from './components/PageContainer' import './styles.css' import { ToastProvider } from './hooks/useToast' +import StudioPortal from './features/studio/StudioPortal' + +function SessionLoader({ sessionId }: { sessionId: string }) { + const [state, setState] = React.useState<{ status: 'loading' | 'ready' | 'missing' | 'error'; token?: string; url?: string; err?: string }>({ status: 'loading' }) + + React.useEffect(() => { + let cancelled = false + ;(async () => { + try { + // First try relative endpoint (same-origin) + const relUrl = `/api/session/${encodeURIComponent(sessionId)}` + let resp = null + try { + resp = await fetch(relUrl) + } catch (err) { + console.warn('[SessionLoader] relative fetch failed, will try token server absolute URL', err) + resp = null + } + + // If relative response exists and is ok, try to parse + let json: any = null + if (resp && resp.ok) { + const text = await resp.text() + try { json = JSON.parse(text) } catch (e) { json = null } + if (json) { + if (!cancelled) { + // store session in sessionStorage so StudioPortal or embedded viewers can pick it up + try { + const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' + const payload = JSON.stringify(json) + sessionStorage.setItem(storeKey, payload) + try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: json })) } catch(e){} + console.debug('[SessionLoader] stored session in sessionStorage key=', storeKey) + } catch(e) { console.warn('[SessionLoader] failed to write sessionStorage', e) } + setState({ status: 'ready', token: json.token, url: json.url }) + } + return + } + // If parsing failed but resp.ok, fallthrough to absolute URL + console.warn('[SessionLoader] relative response not JSON, will try token server absolute URL') + } + + // Fallback: try token server absolute URL from env + const TOKEN_SERVER = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' + const absUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}` + try { + const resp2 = await fetch(absUrl, { mode: 'cors' }) + if (!resp2.ok) { + // Distinguish 404 from network/CORS + if (resp2.status === 404) { + console.warn('[SessionLoader] absolute session endpoint returned 404', absUrl) + // Try to auto-create a new session on the token server using the missing id as room name fallback + try { + const createUrl = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session` + const username = (typeof window !== 'undefined' ? (localStorage.getItem('avanzacast_user') || 'Guest') : 'Guest') + const createResp = await fetch(createUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room: sessionId, username }) }) + if (createResp.ok) { + const created = await createResp.json().catch(() => null) + if (created && created.id) { + // Persist and redirect to new id so the loader will pick it up + try { + const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' + sessionStorage.setItem(storeKey, JSON.stringify(created)) + try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: created })) } catch(e){} + } catch (e) { /* ignore */ } + const BROADCAST_BASE = (import.meta.env.VITE_BROADCASTPANEL_URL as string) || (typeof window !== 'undefined' ? window.location.origin : '') + const target = created.studioUrl || `${BROADCAST_BASE.replace(/\/$/, '')}/${encodeURIComponent(created.id)}` + if (!cancelled) { + try { window.location.href = target; return } catch(e) { try { window.location.assign(target); return } catch(e2){} } + } + } + } + } catch (createErr) { + console.warn('[SessionLoader] auto-create session failed', createErr) + } + + if (!cancelled) setState({ status: 'missing' }) + return + } + const body = await resp2.text().catch(() => '') + const msg = `[SessionLoader] token-server returned ${resp2.status} for ${absUrl} - ${body}` + console.warn(msg) + if (!cancelled) setState({ status: 'error', err: msg }) + return + } + const text2 = await resp2.text() + let json2 = null + try { json2 = JSON.parse(text2) } catch (e) { json2 = null } + if (!json2) { + const msg = `[SessionLoader] token-server at ${absUrl} returned non-JSON response`; + console.warn(msg) + if (!cancelled) setState({ status: 'error', err: msg }) + return + } + if (!cancelled) { + try { + const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' + const payload = JSON.stringify(json2) + sessionStorage.setItem(storeKey, payload) + try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: json2 })) } catch(e){} + console.debug('[SessionLoader] stored session in sessionStorage key=', storeKey, 'from', absUrl) + } catch(e) { console.warn('[SessionLoader] failed to write sessionStorage', e) } + setState({ status: 'ready', token: json2?.token, url: json2?.url }) + } + return + } catch (err2) { + // network-level error (DNS, CORS preflight blocked, TLS, etc) + const errMsg = `[SessionLoader] failed to fetch from token-server ${absUrl}: ${String(err2)}` + console.error(errMsg) + if (!cancelled) setState({ status: 'error', err: errMsg }) + return + } + } catch (err: any) { + if (!cancelled) setState({ status: 'error', err: String(err) }) + } + })() + return () => { cancelled = true } + }, [sessionId]) + + if (state.status === 'loading') { + return
Cargando sesión del estudio...
+ } + if (state.status === 'missing') { + // redirect to home if no session + if (typeof window !== 'undefined') window.location.replace('/') + return null + } + if (state.status === 'error') { + // Show a helpful error with diagnostics commands + const curlRel = `curl -v "${window.location.origin}/api/session/${encodeURIComponent(sessionId)}"` + const tokenServer = (import.meta.env.VITE_TOKEN_SERVER_URL as string) || (import.meta.env.VITE_BACKEND_TOKENS_URL as string) || 'https://avanzacast-servertokens.bfzqqk.easypanel.host' + const curlAbs = `curl -v "${tokenServer.replace(/\/$/, '')}/api/session/${encodeURIComponent(sessionId)}"` + return ( +
+

Error cargando sesión: {state.err ? state.err : 'Error desconocido'}

+

Posibles causas: sesión no existe, backend inaccesible (CORS / mixed-content), o token-server rechaza la petición.

+
+
Prueba estos comandos desde tu máquina/servidor para diagnosticar:
+
{curlRel}
+
+
{curlAbs}
+
+
+ +
+
+ ) + } + return ( + // render StudioPortal directly (embedded) + + ) +} const root = createRoot(document.getElementById('root')!) -root.render( - - - -) + +// detect session id in the path: if path is like / then try to load session +const pathname = typeof window !== 'undefined' ? window.location.pathname.replace(/\/$/, '') : '' +const maybeId = pathname && pathname.length > 1 ? pathname.slice(1) : '' + +// NEW: if the URL contains token as query param, persist it to sessionStorage and dispatch event +if (typeof window !== 'undefined') { + try { + const qs = new URLSearchParams(window.location.search) + const qtoken = qs.get('token') + const qroom = qs.get('room') + const qserver = qs.get('serverUrl') || qs.get('server') + if (qtoken) { + try { + const storeKey = (import.meta.env.VITE_STUDIO_SESSION_KEY as string) || 'avanzacast_studio_session' + const payload = { token: qtoken, room: qroom || '', url: qserver || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || '' } + sessionStorage.setItem(storeKey, JSON.stringify(payload)) + try { window.dispatchEvent(new CustomEvent('AVZ_STUDIO_SESSION', { detail: payload })) } catch(e) {} + console.debug('[main] token found in querystring, stored session under', storeKey) + } catch (e) { console.warn('[main] failed to persist token from querystring', e) } + } + } catch(e) { /* ignore URL parsing errors */ } +} + +if (maybeId) { + root.render( + + + + ) +} else { + root.render( + + + + ) +} diff --git a/packages/broadcast-panel/src/utils/postMessage.ts b/packages/broadcast-panel/src/utils/postMessage.ts new file mode 100644 index 0000000..c10cd24 --- /dev/null +++ b/packages/broadcast-panel/src/utils/postMessage.ts @@ -0,0 +1,39 @@ +// Utilities for postMessage origin validation and ACK helpers +export function getAllowedOriginsFromEnv(): string[] { + const allowed = new Set(); + try { + const raw = (import.meta.env.VITE_STUDIO_ALLOWED_ORIGINS as string) || ''; + if (raw) { + raw.split(',').map(s => s.trim()).filter(Boolean).forEach(o => allowed.add(o)); + } + } catch (e) { /* ignore */ } + try { + const studioUrl = (import.meta.env.VITE_STUDIO_URL as string) || ''; + if (studioUrl) { + try { + const u = new URL(studioUrl); + allowed.add(u.origin); + } catch (e) { /* ignore */ } + } + } catch (e) { /* ignore */ } + try { allowed.add(window.location.origin); } catch (e) {} + return Array.from(allowed); +} + +export function isAllowedOrigin(origin: string | null | undefined): boolean { + if (!origin) return false; + const list = getAllowedOriginsFromEnv(); + return list.includes(origin); +} + +export function safePostMessage(target: Window | null | undefined, message: any, targetOrigin: string) { + if (!target) return false; + try { + target.postMessage(message, targetOrigin); + return true; + } catch (e) { + // some window proxies can throw when cross-origin; ignore + return false; + } +} + diff --git a/packages/broadcast-panel/tsconfig.json b/packages/broadcast-panel/tsconfig.json index 446549f..5813ae7 100644 --- a/packages/broadcast-panel/tsconfig.json +++ b/packages/broadcast-panel/tsconfig.json @@ -9,7 +9,11 @@ "paths": { "@/*": ["packages/broadcast-panel/src/*"], "@shared/*": ["shared/*"], - "@shared": ["shared"] + "@shared": ["shared"], + "@avanzacast/shared-hooks": ["shared/hooks"], + "@avanzacast/shared-components": ["shared/components"], + "@avanzacast/shared-utils": ["shared/utils"], + "@avanzacast/shared-types": ["shared/types"] } }, "include": ["src", "../../shared"], diff --git a/packages/broadcast-panel/vite.config.ts b/packages/broadcast-panel/vite.config.ts index 89c8aff..96c9e82 100644 --- a/packages/broadcast-panel/vite.config.ts +++ b/packages/broadcast-panel/vite.config.ts @@ -2,58 +2,49 @@ import { defineConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'path' -// https://vitejs.dev/config/ -export default defineConfig({ +export default defineConfig(({ mode }) => ({ plugins: [react()], - optimizeDeps: { - include: [ - 'react-icons', - 'react-icons/si', - 'react-icons/md', - 'react-icons/fa', - 'react-icons/fa6', - 'react-icons/bs' - ] - }, resolve: { alias: { - '@': path.resolve(__dirname, './src'), - '@shared': process.env.DOCKER_ENV ? '/shared' : path.resolve(__dirname, '../../shared'), - '@avanzacast/shared-hooks': process.env.DOCKER_ENV ? '/shared/hooks' : path.resolve(__dirname, '../../shared/hooks'), - '@avanzacast/shared-utils': process.env.DOCKER_ENV ? '/shared/utils' : path.resolve(__dirname, '../../shared/utils'), - '@avanzacast/shared-types': process.env.DOCKER_ENV ? '/shared/types' : path.resolve(__dirname, '../../shared/types'), - '@avanzacast/shared-config': process.env.DOCKER_ENV ? '/shared/config' : path.resolve(__dirname, '../../shared/config') - , - // Ensure react-icons subpackages imported from /shared resolve to the - // node_modules installed in the image. - 'react-icons': process.env.DOCKER_ENV ? '/app/node_modules/react-icons' : 'react-icons', - 'react-icons/si': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/si' : 'react-icons/si', - 'react-icons/md': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/md' : 'react-icons/md', - 'react-icons/fa': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/fa' : 'react-icons/fa', - 'react-icons/fa6': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/fa6' : 'react-icons/fa6', - 'react-icons/bs': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/bs' : 'react-icons/bs' - } + '@': path.resolve(__dirname, 'src'), + '@shared': path.resolve(__dirname, '../../shared'), + '@avanza-ui': path.resolve(__dirname, '../avanza-ui/src'), + // Monorepo package aliases + '@avanzacast/shared-hooks': path.resolve(__dirname, '../../shared/hooks'), + '@avanzacast/shared-components': path.resolve(__dirname, '../../shared/components'), + '@avanzacast/shared-utils': path.resolve(__dirname, '../../shared/utils'), + '@avanzacast/shared-types': path.resolve(__dirname, '../../shared/types'), + }, + }, + optimizeDeps: { + // Pre-bundle shared packages so Vite can resolve them during dev + include: [ + '@avanzacast/shared-hooks', + '@avanzacast/shared-components', + '@avanzacast/shared-utils', + '@avanzacast/shared-types', + ], }, server: { port: 5175, host: true, - allowedHosts: [ - 'localhost', - '.easypanel.host', - 'avanzacast-broadcastpanel.bfzqqk.easypanel.host' - ], fs: { - // Allow serving files from the shared folder when mounted in Docker - allow: [ - path.resolve(__dirname), - process.env.DOCKER_ENV ? '/shared' : path.resolve(__dirname, '../../shared') - ] - , - // Disable strict fs checking so imports from outside project root work - strict: false + // allow serving files from the monorepo root and shared folder + allow: [path.resolve(__dirname, '../../')], }, - watch: { - usePolling: true - } - } -}) + proxy: { + // Proxy API calls to local backend during development + '/api': { + target: process.env.VITE_API_URL || 'http://localhost:4000', + changeOrigin: true, + secure: false, + rewrite: (p) => p.replace(/^\/api/, '/api'), + }, + }, + // Allowlist hosts for preview/remote access + allowedHosts: [ + 'avanzacast-broadcastpanel.bfzqqk.easypanel.host', + 'localhost', + ], + }, +})) diff --git a/packages/broadcast-panel/vitest.setup.ts b/packages/broadcast-panel/vitest.setup.ts new file mode 100644 index 0000000..2ca70e6 --- /dev/null +++ b/packages/broadcast-panel/vitest.setup.ts @@ -0,0 +1,87 @@ +// Vitest setup for broadcast-panel tests +import { vi } from 'vitest' + +// Ensure global window exists (jsdom may provide it, but be defensive) +if (typeof (globalThis as any).window === 'undefined') { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + (globalThis as any).window = globalThis +} + +// polyfill matchMedia for jsdom +function makeMatchMedia() { + return (query: string) => { + return { + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + } + } +} + +;(globalThis as any).matchMedia = makeMatchMedia(); +if (typeof (globalThis as any).window !== 'undefined') { + (globalThis as any).window.matchMedia = makeMatchMedia(); +} + +// Minimal ResizeObserver polyfill for jsdom +if (typeof (globalThis as any).ResizeObserver === 'undefined') { + (globalThis as any).ResizeObserver = class { + callback: Function; + constructor(cb: Function) { this.callback = cb } + observe(_target?: Element) { /* no-op */ } + unobserve(_target?: Element) { /* no-op */ } + disconnect() { /* no-op */ } + } as any; +} + +// Partially mock livekit-client to provide a constructible Room while keeping other exports +vi.mock('livekit-client', () => { + const instances: any[] = []; + function Room(this: any, ..._args: any[]) { + const connect = vi.fn(async (serverUrl?: string, token?: string) => { + // emulate async connect + return Promise.resolve(); + }); + const disconnect = vi.fn(() => {}); + const localParticipant = { + setCameraEnabled: vi.fn(() => Promise.resolve()), + setMicrophoneEnabled: vi.fn(() => Promise.resolve()), + }; + const inst = { connect, disconnect, localParticipant, state: 'disconnected', isConnected: false }; + instances.push(inst); + return inst; + } + const Track = { Source: { Camera: 'camera', ScreenShare: 'screen' } }; + return { Room: Room as any, Track, __mocks: { instances } }; +}); + +// Minimal mocks for @livekit/components-react to avoid DOM APIs and hooks during tests +vi.mock('@livekit/components-react', () => { + const React = require('react'); + const noop = () => React.createElement('div', null); + return { + GridLayout: noop, + ParticipantTile: noop, + ControlBar: noop, + RoomAudioRenderer: noop, + useTracks: () => [], + RoomContext: { Provider: ({ children }: any) => children }, + useRoom: () => ({ room: null }), + useParticipant: () => ({ participant: null }), + useLocalParticipant: () => ({ localParticipant: null }), + usePrefetchRoom: () => ({}), + // export anything else as noop so imports succeed + }; +}); + +// Mock styles import (no-op) +vi.mock('@livekit/components-styles', () => ({})); + +// optional: silence console during tests +// const orig = console.error; console.error = (...args) => { if (String(args[0]).includes('Warning')) return; orig(...args) } diff --git a/packages/meet/.env.example b/packages/meet/.env.example new file mode 100644 index 0000000..f961fa0 --- /dev/null +++ b/packages/meet/.env.example @@ -0,0 +1,30 @@ +# 1. Copy this file and rename it to .env.local +# 2. Update the enviroment variables below. + +# REQUIRED SETTINGS +# ################# +# If you are using LiveKit Cloud, the API key and secret can be generated from the Cloud Dashboard. +LIVEKIT_API_KEY= +LIVEKIT_API_SECRET= +# URL pointing to the LiveKit server. (example: `wss://my-livekit-project.livekit.cloud`) +LIVEKIT_URL= + + +# OPTIONAL SETTINGS +# ################# +# Recording +# S3_KEY_ID= +# S3_KEY_SECRET= +# S3_ENDPOINT= +# S3_BUCKET= +# S3_REGION= + +# PUBLIC +# Uncomment settings menu when using a LiveKit Cloud, it'll enable Krisp noise filters. +# NEXT_PUBLIC_SHOW_SETTINGS_MENU=true +# NEXT_PUBLIC_LK_RECORD_ENDPOINT=/api/record + +# Optional, to pipe logs to datadog +# NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=client-token +# NEXT_PUBLIC_DATADOG_SITE=datadog-site + diff --git a/packages/meet/.eslintrc.json b/packages/meet/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/packages/meet/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/packages/meet/.gitattributes b/packages/meet/.gitattributes new file mode 100644 index 0000000..0a65c24 --- /dev/null +++ b/packages/meet/.gitattributes @@ -0,0 +1 @@ +public/background-images/*.jpg filter=lfs diff=lfs merge=lfs -text diff --git a/packages/meet/.github/assets/livekit-mark.png b/packages/meet/.github/assets/livekit-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..e984d810379048e388ea3e2f0f6e3f9f7a0336ca GIT binary patch literal 832 zcmeAS@N?(olHy`uVBq!ia0y~yU;;9k7&w@L)Zt|+CxDbqfKP}kJ3IUT|Nj>Ofuq^~ zeio4(6_MQYl(0ldTO8`q(dWFcatHIq2gs$=c|_g9pu(6$cL<%s+M{ z9cbu&PZ!6K3dXm0TLYN`C0Z};?sEPg`e+^R0{aP1a?fcde4V}Xu|Ci~a)3$4x|z~t z`kBWsEPbK5`s#8<4hBXA29^c}0R|=q26WDa3=ARMiqT~b%-}aH)qM4e-968bp{HMS zR=y85JJ7Y^a0&rs5}Rw-W3zU-)`T&ZlwU2%3oglKx-kvAuLu~0H6&2T+`8%MX zJV8T4MMHavfsT&x6c-ZprDTna z0ucWH7V!HAfQyEBiCm3@Kn)<`A|T-+{Qe1$!!aTw{ek))0Ra&S`3VXt8v5@A0PSzi zSvX=YMi62UB13RE23m1(E_E;fd6yi0A4l#;7I_08V;T`4m|nKn)niGfHZ)IgM$M61*-TzGvSy4fGRhr_zyLg z0pkyk0TBs7LJnTc|2aIV{Ut%dfb0?UmlzUf>iopR=!K6`RVKjG!{=7e0#_9}7?u3|>!t^3!d|a^m&U(Qa zXp8DUStNjLn=uVk?GcfElwEf9Hq;T_woTs6S~%q{v*331M%|sH@z%-M<{;&n7t2}5 zcypi*H#=LV_}RQcRQuRIp6CKqo&{Fg45o<=kA8dQQ&w*kCxLHx)p4fZ^hDP@EdOW} zVsW2|NP7$d0F>G%YNC;iI(@bakC~rhzuUC3%@Tm6Agp_LtA%W%P*5M#^5)`v6D**U zS(|DfrnKTl9(*Y}`J~`vuuk6qA*t99=k}DsmWeKldZGE4!lhOrX-rO?itsJag^>aMu0oPV-E*qHh7 zar`KXU5U`p_OQa zd*~On$`yct5kibuOfe)lN>3bI_`zDHQH-wE&+{^7WSpNlWh(&VCItF%QmDS3#@ljn=-$u!f5Peo;^$9Z+q$9V@|3act!Y(7TkCco^zJ30~sC$;Ud^D5RJYPqQ6#{MrET@1=V9B)sVRXQ)V=Yy*JL4Q{ zu&gwL?@c2+YW|HWvf?v!H9UM=Vk50*isRo+ITubc#1#D&KBErtJin+|ZM?XzZ$Z$evY=a*$9q(EvED>R65scB?D-N;hnxF_VZKFG7;KaD5ZmX? zQUZbNo#;n~(w{cj4|+*m<_>SVEYtg_4@tiohvgJrv@5_A+Zrcyh~H28mm|`iVdKDY zl)u+{W&tLf!Vm8i7a#Xrs&8;1;ir!1S^DO7PJXA%KqqknJvWUc9<0${C9L~!(J@Sh zZOt9$*=e&|%QVx_N!hyFy1lkXUkrgA$;XWSYODw*Sf0$Na1>-DtHLe+)!8t(&XI;@ zPR<(!!!BvE;tkOL2eKPdY#Tmi{Sn_LZY(?B&0=WqF%D|muzY8dhA@xZY;P}-REsl=+Q4mbx5nM%q)ypqy0Y3jzLfS_{Jco zy6ntvN$Ja)+8s&N&f?G#I|k7hn5bnDi^-44BOn|Nvy+}4BYlFS4eiIw(>WHxJ1Vp3 zshr2H{>K-cf%e8iL+MT_mxsRrjajZ67CJxXV#kMBUh4``ugGJPl&1KjBlmw&a*`^50kmJ$KB!N zgEM}WeF0HdtQ?a8^Xpl!eUPU>3D_{wbH-!jYc`9>o}x?ox&13R=Xv@+n%`8VOJ;;J18#1gl zQ^(1pXOq8WoK<;9$VF0A=#0;a?`|S@7DaFve5Qt$LLmSEQh)l@i}4Fb(E*RFjEQDn z(rk9gFLk#5I(Z&Om8){Im(_iG)&Bc~=6u;zpZXJX2 zS{ox9Og$<>CZD%Y8I16DsLWPOMiFDAxTecyxSR-f%QO)lV$L|&aFR-=_!^5^KO`nf z?!1eHdBT$4HO&c*l6v&{P{H4EkQRWC!UYHPFwVQe_!S234Bhr4x7qcmjo%Q040!L3 zWpXw`#oX;~mN%S2sIPer{Q|!4kEdZ?vF(wHA#soLI?j3-jBGSigiMV2B`QLjv0YaU zCuhmAlqyHceG1P?8;bc!56Xv0j;#42S*~*^C!%NBFCI@fD83bsm$j|eWt^|bB0T>Y zTnb>&SioQMfa9^*`c;t~(OS`1n@IHuQ^+0N6wl~BTQ$*89i`Ca1OMAOn*QbQt`&-T zbMu9|=gQnO{I(YLmE4UNLasj&ZduPn7py}yllND?&Kgv&QL z*IE&MK^tpq5^AM_yCX8Sx5YF$;`Yu1@e2|)*zWdm7dE0v8f{p)xZqT1 zEI&F=dL=d$*#DhcFa9;cdNChKX|7+H@F2H9XM6h8~8*+3|49chPim8T{MkF@64M_^SBlXY49nlK%M zeTt8h?)pU8KDqAgpJid&MT_eY!6QY{YVdXQrPvJ5l;S97yq)g5j~!4?v-GNL4noi2 za*`A=GJkugGB-MImWB)S^T?9jx#3>7z-BH`(RQZPotH!ZpO&i;Ol6sUL$=vtjxVt{ zS9(c|-;ozezK4ZGjEBx_$$k+N>bj{HIq!scK=KyU=3Ne?Cwr6rdkzPB^B6kA& zjNP)Ffvpy&cqzx2SzLl>Rt?hyu#R$Nio^+fr;_466JC*sGEch*WR5kFHM-TMBOh91 z#X4SGYX!o!c_L?;_^G?b{YHQR?Q=ONQLyLDfo{CTw#b$i7L0469E>70?wr^0i$-^Q zcEY#kW5VDKt=}qx2nmG8oEK;6R8%kNGxge$ksD4K?RT_kOMB~Gn~&g$h{}}BSd@mE zTr`%qM*TRyB&7QFEQlsVIn95b;htej!P_4nq_>wdyq{Xyw=BiBfN1WQzm~}UUWN~5 zrQ%ELo=5_E4?~rzgwO@r?!Xis$)U&pY*uhIxJ{5^S6E(pZA_#?P@eyOzGD zo?EQC${Tm#r`XrLN76ByBZw73ctP*TF!xh5K;*1*BuCu2M@^KlPII7!n1ltl#!zrH z!B#!IpEik%JUn4jv&yx%Re_jO<@h-BxEgY4Q8Zr(e+ORzwu*#sicgkwE zE2fD5>8Pj88`m)Yb4G`$RnJmNZr?!vo$duJi|(rK`&DWzvzd>we_`44W8o{C)vnxj z)enALoAz192|j``>CiFP5qEnBnLZQr9i`Y0EtMQTNONL-pHF9UUPWaMi!r7gwmA;s z`Fux9E06Z3{#0@0cCbmRNeqo4D@855(w*y-Mt0JD={tA|$>Dj+^}KE&z}Oz&rPF(DFV`3B5gJd+A#Ee< zrUgdP&tcZ| zKw6(R_dS6n>C3LdwcF3Nd{)w$AFiP`7b$!5ph?}&8M|%@cQ*_hF-)g@ZUwGlIfQhM!WY0`Vqbc z=z!31Chyld)wO{QSL0_r(r*G)4XgAke0R3JKhB|L1j^Mnu;d6{h^@A>;3y)Vko^Wa zJ>&3xJ@U+VxHK0;?Ai-w*?Q8;9H5iA=v3*4k^~H~uPeVmnQaligqFe{nCh2px$dm< zN2q>qHNH^_)Yi7R8Ar>|?$L-A8kaq1jIsyStoZzSEN&iPix@QDPTaPyq{Fw>H~AUW zmzGs8m{=OxpYE>-sjS)A``7tjO?6O)_E}%zL0o~iAAa}N?#O4DSu|S5ms9tjwz77 z^MQ>yf0xzajs3Z8>v8-hJj%{FkksC|ci}n6zpgFQc5L$M?);)+xjZ6ENZ|G_EH9p7 zFusb4w)eE7R6PGEOcRLv>$ZI8HN66u5srnQyt0r4ueAFdD?A?5=d=Ik2}RG#NMp-Z zy0%wItoe>k$0?Fp?=>;?eeCOncxTK$IqHjR;`IlOaqR?%t4&>Et!hoQra)I6^GW75 z*2G@Vb$qT)=$IOA-%v05Ftll_wnAsgQ!?2*eKeng`&P|jpL%{M>UDPJfv>|s%zWiF zKPiEBr{X(vb*L$|V_See(Je3Y-9v?3nMM0W_l(4O%1Is#_XA<9!!l}=IUU&eQgzb4 zPZm!z>^Be^VBf~C_RUq7SH2mgZX9gRvc(7{)lputh+)VJW8>dGoPxA)=}eBOWbSMB zzAioXzjIowD)i;uwP3tbf_^*)qf`<5@yH}woa-1&G8|{9uzx>kqBjw5;jF)bH^tsv z?J#jlg{E&L(-DorV}|e-N!sx!P10$;MFm=Y8U<+xAF$@+^J3W^&?p2IV)wwhA&xtw zGj|EEOH>EzjY6ASYPIX{q2_b*cP|X3)=|J9UuAX#OdqtjU-o;i~&+BR}s^k*L(N@ClEfS>toTA z3pL@(eY>eFu~VCDhdXiXep8Q!5YY-N&*e7nydOPwi(Z4Vdg2uaIu6R3I<@UZDusO2 zX@fV9`x((SJ(y~zoq>CTx~eP?2YR6?0ae|u^?ir?=5wRlB-`;X%WjXPll!W&sHuZ; zF=fgv?{*usR!%8Qp*wZ_o+I@oV-ixUO$%^5gLoaA<(8n+N8fe$2Qu=hSB^ex?kqAj zb>}NKMH?>jJH!^|hd1A)!A$p`FOo-R;%giYJ-?ZcM)#Xn@j8@}K1^EI#CPF|*kt*J zX|sMR${vJu8vW$YN}q8+Rmq3X@in3`15~zZyumUlEZXA!`@6kgenPMO#b)b{nQd}z zj*VU^pIvK1zf-$Yyf=;!CXUHJ5}xS8@(;|u4lX7y@)=yJt(tb94=lX&{UZNC^Z<0g{qa@Xme*rI-meDG;PgUms{e5wmqMs= z;8sz2(_^CbbVavXn$H+hjT>IZq4q^|+Rofs#5cUH?2LM$C7ABV_dB}n)*X{&OVC-z zugjD4W!1JFvF0b=M5@Fd)W){&vMnoQwL7i3_N{g!f02b-$|VtT*KKh$Wy(tmy|etsW_AIVI>&1)9h6f<;qM`7bV(o^c+a#3WsSiYD- zhW%-z1yVRoobtt{z>Up}Z(x#tdCD|`>0Wp&yK0QNLO1O(a06!HoVqfSFwN^<^J$*T zh|3{X{UC!f)FJOQ}eikWP9-%&gFAl2!eb$Cm*ui>9zgkvy{3q9dVp zG2$!#Sf;!dc3Xp0Bk7I*=qc35M4{LE47PbR*P@cJb*yUf64I)jbq1Ri?Phm{R1BQ6 z1x7|^5weE#3A=2!UX-N?&$fDbebWg&tvsz=G|+yvv4q%BN-|RsvsSgqK}rE?F-~Z% z+^zH0e7%38lys|oJaeXEiwx!!ET6MiNfsPSBWHFG!WGg{iOCiwj_SR8qAg!$A#(FG zSz@tLagk>6BHOKSrAU=Gss292x4~1K#&vqi$}dH7E2mCad9MAc*V2LH#p|3Mts1O8 zyK&;DtSxa%9h!0Qk)z>=Y@@s_;UvO)_)y0DQqJS~k-^&>la7`{ftcfZZ{0Ws3?UYf&={;(e_pAo1BAy$Fa2YFuqYX`Q4Ek%TshoR7aN3h%~yTy-xTyqL-iN zZoHHWTzq*<2S>n^#$WZlxJ~=^=UeW?DC-@J31s)1m#WnF1j!;Pdh9LAJ8Juw*_*}; zBeeGbYWJ1mWCn|xB>20j6;_pV9Z^iGDm5%Q=B#jC$$42cCb3(gUE^!OHD(Txv^f3z zm3!WVU&q7NWuACXp&|ZkyuWt8%!*{|(lL20`~BoCGD#ClSPTtm>yQ_IP(#g1Lv(s2 zDL=(n%cXK#w-%ostg_$focrRSBB8@4!xyzvKMe#GvrcY}L(38`!!O>bxp-jMax?q# z-MhA!cqCsX!n&V#Qr@m}KOxLPLnJC;gt@TB6PgpiZW6B9xMSv#Rx;-vtR8RGq}Q}* z;oCX3Iqw%bY7rce+^4lbY8<8!Gq|Inpv%%zze6tk_ROu;yV*Ibk2Q9r_YT_((FDi; zdJ%F;@z!}F~*oL(G%o-xRU86u;BFFTI3qMT(kPdRLigygJu@P{?gHaEY@x2$Ta~Qf7CYyLED}%9+27# z@YI$hPviG$+0?pbCjcR}!x-{*gvU}BB6b6ezKsmDk55xp-}%~Q6ZAAZH*u1hd7{%| z3>DR~qcMz^!~~BvI1VY#M;+%ml)C5+=IkB}$Za$DMR zZ2_53t$|9ne)td}tCY-sm76(OTDgj!{uj^=g^_eL;U-lr&g2e}PA2VdvKhGAapk=Hkwj|Pm6JtZPjKRk^;*<=`8 zY|Aj@MF`}@uu~r1VaCP}R{EA@Z;>bG9w+wzI{3$Ttd}^8-JuY z%5Rn02|lJSxwC4J3PGMeY4yWUcI$CQO9((um`Phf`bd{~Sj!UXTP@YkV7lphwyapM z?rCE-w8^?$*LPnO*tBZsL~v;IP$NyI-h=l}bde98Hh#S<6WTIZ6@P^PE5G`kG{TEA z&!^aO=GKrKit@4_*Mr66W6m|^Z?BEe$$8rP1iPn4`xkv4#+ur6V9%MOnRk>f59Xb} zyNUGlyfUnEJP*MICD*4j9qE;NX1b@fKbp|ITffeb+*?MGnSz{;+0v@Z;;GA0#x!Py zPRdg;XF}zw+zR3cs{qeu{kha0j(t|?f%IXo5vl9fMw^+}F$^`q|4PCppB(?0UO81{T1b_|6hj?S=V-oqZhb@ow zCjE^P_5_(hTPn;)%sA3WFQenHSFPqKZj39*D&2Bua%fAp{DL^LrQr8|Da;y-B3mma z`^g~OWlK%ian;&qJ6*mo*}v=K!JX$jLlcypfSB=Knf@K`rX3^rf)=-uClJq$doG47 zMRAfMI!=GCf;DCf3hthRcF8XgFx@g`}m&fR(bq}>l`W&QvKnjb+(UHimit@x1%WKt46a! zmk#lnJ0UKCdGco@0a)Mcrc0-NHpJwo`0U^1l9ZAW$M8Oj*5<|cfG zJiSj$s_b^)Gd0(Nfo2v#AWa$NHV^H;3MtSk>8nZ28fsne%c5DrHl&Y}klH7Y9_JEB z%XCj*(pg`M*1{fX(o6T*PSATbBQiVLD77PUC0-26QE@0Ss;R@Ryvf=b}B-UijLAU6V}@u z&Ru6pjFD3*u^iHuNFiN?Gs}CoM}lAVXWTP&n0vfh3HX%D(2GqOd`hwR9;yh^u(1km zS0|ibBdf_`8Z!<<$*zh^Yr6Zn=WkfvW^GR=h>p5t-&4j&z`H@C4H^qqHn(J|O~VQ; z?3%{8I`htF?(r570d22*Fz126an{?bjvUU^>YKy5E=t*=CZ^)rnNv*P9+6eM4cLY3 ztyS&lPKhZmwoc@+JF9&LK}A2~x}XZp+hg`R*^fK>yT1X|PLh*ZOHS_`-VC=DpgdA8 z_`ZeMYmxg;5AIj}jtAjI(S@E2XGMFZ_nZ$sI|%8EQ{qCk*`;Qd8>aB9Bfc*S=VHRI zVvd*V%7c-cV6dHw3@4C0Wr>eWFUnF@hF;$}nNIEmgd&N_ZACfw2*m1Wz}i94STW#Z zA-vLOkmYjs!sA`tR1*>N_kQzJIAGZ2%vcxj~T_at3jEVd@N4`nsF8|tQQlBvYUdN+V0%gu`K<+@-HDpz@*Jn7J z25X8dGWmxWr0{cRXaIM(t1_ffbMgk9S(fE-C`tf4^=m}&BC^2pGemzigA@z*8%*!_5C`F+i+;?PK?R)LC&9 z*u8>_&a&13H{WD;u_KbeIz?77v*%=Hc7z-S|m+tzVzI zZg!N#x-ZLI5S`lhAFMR2SVTO-wuHsF=378~PNJ*_;<`a$#wFvdO9{3nd{2ME7jec} z!eI^gKk}JjDxRe%hpE}pppE*8Nc1Ynb~t9o zSM^%xm*;s}DdJ&O1>Usd@^9+(470oTMpB;PXpQ!KmZ}=zbazyy4N2eZ+iv{|vz}?w zF6{D7LRJZ#EEm+DF3-!Sg8GvDPi|2F(l%H>D6f@k#sDWMoT>HaK&&i41NYBD)@jB= z{(!6!GHDN2E-s~^8s~BTuN-}l-t9ohD(p1y)sxS${uka||C9a>Iki``%p?LaN=U&! zEW-sYxEC35ECz&60sy)}7?E$`945yOEdY}p_aG4fGBf`2-hz9teLMfO!ds$GCHHIm zJnnCO%&$|Q_x<^qZBPhz&{EUUZoNF&Y9gU+D&@cu8QOwl4MeXDQWgeiks5r3ufH0F|KQNpgD7?@h=j7su(J@#r6#tr?p6YnAJ8GSNmK^CxhtO>a2>N=_6zr4pnYMHU2-AqN1=?wy%>Ustoj?cR*U+I56I&Sc zH&vW27Xa{oq0Q356LU1}Kb9jNApi=u_QF>Hp|xD%Z#mN6fK*yHZrdDoS&?lzj|7mL zn^zi9T3VW?ZVI5mJ?9uIKK*fTg!?#30BTS0&pJTxprx*b2iP-&Q`NaEokv8=I5D6_ zf47~xgzL5pLIscgW8u@L1U*{Mx{^FJYD6@|;w_L)SJZoCoYy!KI1+dub!mJp*H#0) zs1K8hvlAq=hiFBhc)?Uu;Z%sFr7cFZy1oq5RKZke zKxrw%7QSJnj=TkDMvF+DGD{sCuiTa3U>;EvulEL69Y-Cv9PF8_E(Ml0#KsSfD1{0h z!I9F|4)IJWv!t##sl7(VMaacqP9B8HOFjj{gV7MFgW}OVxUN8*q{Bc%#9dygC@vJhX)sKN#Sv26(;JDzem$!2g}n)#c`^!7Ecr z4NnKoNhNcoMgw@B;DZeFlR<+}(aB9dmaMexbRX*!TiSSIXi%yl9!^bo__Q5wIaoIb z?h{Ie0KlCh;osAv|Iqdj{X%dD=vn0O9Ws` zz-e?rrtlaSo+ZZuFk-yn7ym7zKDm{9uarNxNWYBZX?0XSC zr<7zx%_iDS>tNku789Py5gjUDW!_bW@3@S{h01%NNASigbM1Z|9{_2wRzt|Ks8HTH zJ6n`l=3CoeFXFGHhwqo-@u92f4HkOvym=*$*!tEYNg8cMR?VF!L?Oo6jl59@zcEvQ zt@E&hyj1mUDT2d%FnF!RKb#oQoJoNlw^dDO6*ln31yl89COE$%fgD z5sr1DaZO~-21Wif*=n%FrZz9WCbohv+5EVPlEL(h3@h+kn1I`1p6Y=rN-rM}-$Fq4 zwW}(X#=4qQyFzs;-|Wu*by%L~XA-zHnDz)8XX@W-|M0D0 zxzvfaIJBM0pxr;KDqEcDP7o5N7Jz$H zbxfZcZlc5Na~-O)X{|I~$%;h*^XyqMfwW++8>LzKNRS-A-do|@9)+DYq38PCe7Gn= zuIm+A1PuzFH{vM_U`Z=rG)Gr!#J{zhF+0--@A!S+?oyE061JgTL$x64qKe_Cs(oV* zA`DkcT2lMf@MPiebNAc)@dq-&=@^IFp>o?s4pu zPS-$)_9FV26xv`kM}3)??%;?oa`s#K#P0Ob=0Rj*+>r?P%k-!!>?a+Lv0cg8u{&ME z#WCGtpDW7{nfUO#G1;?jaZ=tWuWCPS!`HHTJ6@#FrIH;i*)B45;2}nmlYcAV_Es@Z zhxB-|Sf#OFmd~K!Z31f|loTGB2JW{yM|ExT7SOh3m+q+D|a; zc^%Z3Wk^B!B;&-*JTfEwlvIQ02#iIpSmY@{C8EyAMjZ15V)`wa_b;iNJ_Y-aa1^= z!nyz=&L#zAMy;{BB3yz;1)qAd)~hs%Ckh7%*TX*P^>=w@caFCR=mZ}_w+x*>?ubQ@ zyut`}pK`eBMPbsV0&ESm7G)fr6xJ|?<`w{X=>>`x(f-YYM!$i)Cz~1!Az^oVY(`2_ z+xwAtY^54ViCS%pudHGu5egDmo;~TG5EBYG{t`NhE>x%Mm{}+$t)v1nTn~K;&P>b6 zj_IZ@jAARDXEf_G>ZqaJmXmtP z{2?LE+tIyZv*+^R+EAK0-JZQuTQy#nUcb_dpJ)-(Xx&LR*qWgs*HX{Ilg04{spsq< zhmF1I=*J|rqR2t*P!XG`sg7o$Z99Ic&P0X{*`W?2E_)mefzLTk!t0Hj7{hbVfbU9g za5A$ai+aoPY_nV@Oc~W#2{)%gl#+@B$_k#hiPLzdr!t@>Y(MXNQFZvnTn=zt$$(u1 zX_x5-snWI*KsBAC6RM-WLIzlbtrEfpy9eD+v*``yKKztwm}TIfZyWBR3a$W$)2ozi z7^(FCm}RChO33~sNAAB!yZ4NFgj`)HyO{_rBPut$1-8zZ)Sx|sEJKnW5x$6C+$6|{ zkTx9TiK=K;!W+Y1=KSg zbW?f++1|I6ew(qhL9C!6@l2Ef<&~fUqcx^h5TOHBI!*TTuum2{Nle}e$yW*&M(XcN zRBP;@nu09@@1?0QxRsk3w=uPX$rmN1CqipER=@h}xWgQxXFURVTl`bx(#zUf2nVC* zlja4N2U(JK{zCKgAir>G;&ax}HyI)o;u&9?Ss@}03q=RQTax`P$*NA-^gNx` zTv~%7OE6POA@lDd~cWg ztSZ$U@dE#k%ENor*Y2%M={FGQ5K66G*ypLek(*2pMG%Ro2$xIU?XrroThvbz1|)XI z88rM!q8%mjMZ^l?s&mq}Ykm0{uUM_eE0Y)R$c$waof_K$-fY3%p0}d=l+!4_CZxt~ z{f=Ra*)m~Y=zvRXY@XEzRCZ2@sRjZTJ1=_Oj&y?4oN(#w&vFJHGmB!qH= z8wqsrQGIyw?h}@#8iyxOSL+fg;~Nfah>Eqz*Pq)r67w8k4!9pIh?)euo@8;A<3X5N zCV7=5`UDGB3(7*BWYg05cuBe^C;Gb1^~lB;NLl37J?-okek{Q%xC>_|OrECU6*(pe zl`dz0AXIoRO307WnSI1hE&l^yf1`Wr0U`4n6REuv`vU@7jzF4Ed0n5z8HT9&Met z;Y7El(?t9R6i3%m%2uLg!kg;aLW2hwq{KpIx12>~d~Z6Xco!t`{XAAbMn{Y^E-EI* z8Z_u|M@chZ6q2d8)r=0x3>6YZu($=&3rLBDE<#T_5QqBDURv$rt1KS&1UKt-?pg9W z<;N1@d|_s5CEDN>Pyp#!?#JT=CCSUvFKEi~cXhs0w`$_rc+;fbi==~a!raEQ8H(AZD38Lh=f*(lo7<@mFc72(P z&+4&y3jivVg=e|9qS)ax&~`LCbNt7=$H0u#n##+>v@kmt zSLMOK8OuURLoa)Hb$>)FO+v@-c-LUQZ9`2k!>-sfzhm`%%K{z$Wz+JGXk&g_e5W|86NW?FeB|! zM94Ql#q0@{@$%e?>20%J$(O?qKcbRpIWB=@?mJjq7|Vn`q+Y75YIZ@5aM+#|_%gmx z5%b&b`Vv=|OLXWy(CE%@-}606<68-t`UF$HZ8q_AvSb&!$MwbG4Bio3o*+@=cogwh zwB-l%5y(=A!-C!V&rk2}8%7tu7%ua^`DCDFH>Rade90VHFBZF_uOb!*eaR6)L}ed| zV*B*w3!!JGqekMJ4@Oe!Wl`oI$tNP?0w~A1TG&Nl)&CAT|E9P6U#wW2#s2$}6QTd) ziQ3g_72^aAtcn&ccFUh@h4}0K`dT?sBzO@IrI?{^lo~E*8BSC)3FvB*6hl zj8g%~K?r~(KpH@sVaN~!@IBiJ_{4~j>47b2+Jm=YNc{ybvyjZAAjtv`Gtn(8y^07x znx|E{lbhqkC_~xgLsTfuCwjv{R>U%wmt>wKvk7HpnJK9@eR7NZ9M2J%VHz_jtV^cU z46V1BDif;1NR3XbJg8Z_EKF(&*r($#RKW*+5gho2PXt6{WF!=1ls~I{`1hN?Hhkiu zK7a8NzNLnffQp)si<^g+kA{}xH5$sbYDt-fd$7|ol;K#tXi>4c|6Syr!xGmLh zVf%rJ9<9rcO$HT^{lHt=pjm;mLL)sLZO}}zht>bf{GTh~jqMw3Y_Oqt1^t@v<2Ks| zZT%ao^U|l2#q0{X9ZbRh7iC!r(aKO12_KrjVFv0JJ-%q?xE^pL7Sg@$96ldQ+9@C9 zG8EG|5;G=r{zh0_Bg>ny9{aRL-;2P_+mMgTa8x51Ef&jtEcuV15+7VpHBqt~=&@#H zRZP!#7TzO|k}&NC^^nQ=xq3s@yK$}}n1;C;m0Sj8ZtbDOq~Egq4UF%WW8T~)_KqkP z(RWM7G?u70B{bkGxb0%e>k}8H1c;_rNPpgrm9e9-3-xhL#muHwd^0R0tT{@@xpC^) z&eb+Q)gs{7r9o)hzVTL=YPY`d+BIQHPnkR6=laPFA|j;%qTUP{H-U9-K6 zP-weBZQ6D9i8Xs)*vO-{_+9+t3$@3vRYui&es8l{*R|TDT3l>8+Z;xSUQ}jFRMz6z zNn3WR1(kz~Le&n?7m(U+@L9;)CF|TZMm?3(ZnJt-xOCP?`B_zmm2<~}s3$VoK zAcTe~(-+Zq*#(HFd^>xc@TKDPpl|!$vd#ouZ)WG(Oq`ki@#6=fq9=uCK0bA1#*lQd zmPPf6+4-XknTqz)MD=O+eQxC0d`MkA$?}u(7w85@f&ENT;qMVm-HL>;q@?VbX~@Ks<>BASUPT8_S(1N z$^S)JJ;hT!Fm>ePQs9Sa5!6qQ?^VEAq%FQ0Y)O%Y)Vp%6|1a}jR>G$WHw(v&+oE~O zAA!GtuElEy$?t)ZPS0c4gunZ@DuxNzf5lF|>-*5EOYBoDHwOe(MZ$kv=lqY?`CR?${4JJ=GeLcO` zBkA+@CO#LDwbEr+hY2iy)t|REkCfL*KpD1op15GhAfTH(3_UU{D=b!IF?V3^%Cvq* zOeykhZ5F%NjgS50;zK;;$!@60)*?piwRbnoBSm%pZ{XvwkOg<8=%TYu8@tP=XHYnib+l43{nJ7aMVFY=YRh77Aoky286y%h&idNV>|} zb_UwXZ$JgU->iFCq;rgCucP}O*}Y(dbo} zGHD2P`*#XTJkKw|jA6M6u4|%t<%${wZd_!S8rEsleRJ6}bx-z^H}^yjz(z;kYP2&k z3mTcR54!Qg+R3k;IO0m11u|%U+Lj99_cElII2-;Abl4FsY6nz1Bajym1?fk^y;tHw zd&D67dhJ-> z+&r@MRz-4_tuL8F_!w4d2g>4bBtDS6FsXfxO#4Q9G$hUyTY9I3kb2Xw<+Bi*bbk&I zyv~1gkUq8XfQtU`@)sgfSFU3X$87o%X7!Rvf~6>oQzK&Y@aqXKS}dxd=vsg(V-1`1 zU@gsq*wrAsUhV&?*DQFw;@)W@1{pBsJ(s#HVb*g77AvBPY0^SC6+8o<((bJ)rs$}s zS|q$>TFQALE|TPdPju!*30B?OTgHmP&|aF~tfKvZJ6uVjEl>5faV~=qa@@fE)&ajm z8j`|Pvrn#ybNfyh!iyZBj8Jmc8Z!2DPW?VFer%sN8mB2>xpv;@P}?Ka>BQ!BF|Qr_ z%2m4pa|HBeAyT9N%F5t*k21MW7L7$b#L8Z&Mfsq0CrhU6G{uO-gXv3+BL3TobtXA` z=RNJzCa|jM*Vxh?%+Q#*#Aexf{D&ZEK|%Qg%^%f%_zASr0}2c;PR5z~{P+m|RC;LL zIhPMtn0ninIMn%h0FfAFU>+U8aKE@UvNVGlJY^g*E40U!2bJz`A*2^{tF$z1lB%>M zdv7THu$-uiMD=|Q*RSuJx(6ckX(pR<$PaizbrSO98JY;yuk@4rB%V+xdE=mbcgHfb zW}%(?h-&rp9n~eKZJ-;Or0IvZH3a2}fm6r+MP1+ij6LV5pH%MYy`O!Se4P58JN;NK z+&Gf99zEufCDVUp?}NEqvPA7L#J4Xn3f|MvelA+Gujqo%Fg#6YS{-nUjAF^r42z-rzos7DvRk=?VfXRx>X z9ouvM-J#^L`*g4ERQP(tgj6%jbg{!n%?_-c|8N?(`O3wlVNg+BlDpPOh97~I%p^1W zzwz|dQB6PI`zYNh-Q5T{I#s%Bz~~swfYH*@UDBb_4Wm<<0RvRJrIAi4L4SMu`JUf7 zJKNd*+`ad?_dfTz_f_oc?>H{P#c5bG5v&`|)pNPAW@>Adnr4v4)c{FL!^z}@DZI^JCiZ(kYV%fQZTtj3Z&11H$uIl&(-IE_}FWQP#pnD{Y zT7QNiU#uugeaHoP~&GFH#lM zLN^8F-p^TM%gt(70t_lt*b+g2+d@+is?oqfoJa6RoGATTwsq^?C@eRR$*^;LuOKU@ zQG&*u3iQIXRgSz5Y8<7qZ!N`cWlI%wzU^csc_okb9N(DyMAbVCa=otYw1{5u7bVc* zVu|4|ieJOo{sDSwYQmSTt9RlTXBC5FUvt^>#C}F$v}7;E@mBpFIGOOFz6yt~D$I%# zPgeqB%lEvau6tIJzU~V`V>vhOma07ZQ^Bw5iM(#gQS_2@SmX)bmIt6+>qknRHVW+2 z3TJve@ygKYS|)4Am7Pf;41#m#20%J%h@FU(nPL>k0;`P$0=u3!aj}<>bEU^z}1jBf=ulmssI<_@=dOl(T>r24^Im>LhjTB=nmwOSrH+OD$=10(lp3Gu*T{dgPbJ_B_TzLoAqPf$MiNWiU{;d@H&gIMQ zmhYe@)gdT+V4a2b4kF3a!TCYU>m)t7Gks0kjt-$ZKGz7<8HBx6o>|-gpGgfneVhTc zGohAG+JP2fZY*7b=y+Da-zuL6|2 z0EL!3LDq+Z#+fN}Z}hfn$Qyw&qRrxkG=470Uxx(Na@fkL{;c(J2$C1gf?9cy)C0z+ zp?UU^8Vz6qVd0E7M8-aIuBnc(M6NKI=+#;W4b8SQM`GI0<@_@h%QQ`x#>wTsBL_kniJ-d>Dy8KHQ0(=WWRr=_p8^uOTxFe#7_07auyU30@-XPC9G} zf-ev>I|!321IibQu^(r}xwb#Nq0EEWW5kRca;_mdn~qU@MQ^23WmnH&$Mie&C*dD_ zIN~imgh@xYOD`>(iW%KK-$XH7>eRNiB0G~&!ge5SYhmv5xaofD>A_N0^0v;!#wy^NZuqv;>N4NlJ*Q~Q}6SYKl##XZuO z`ro~CZ?fWj3GXri_<@qEX$LJ*@&u{7`n zkCt8F>F2S-gxEl+MsRTlkl)4i>nud4OE9p-OS7S;?CcHRR9LbDUyx-<9dZ0&stR>2 z{h4|wWzt>a*u$j~uam>?7am=2ghrEhAdml2m26ocj_+nyUtqiXHQDtDMw6z)YDDX> zf7K}M&&Jlo9Pz>9iNhwrf%4UcjZ7e+J2Fn226pi5K-&VO1M62>^QZT(!$S}qQow*cZxSt)oga*A57k{)}UE^ zw*u$7=K@l+ioEEJDtS6fG1#dEfQocpvzw4671Jzgo-|F@@iOeg5b3q}DbqJgx!P-n z)%(>+r3vtbnP1JB9GN@w3?aN*&9i*9(dDGB$|DVDy!lfe=u@0c?gfr2&ANVERUAJHixWy|qs5Wdjx!1@uZ7|R`zAaes`9m&% zHTf?3V@h)hd}wf-fmgf97vqYG8m2I#z+w>(v$vt0NN*%GJu2|z2T#Oj3GsuF>&aE4 zwW5I3vgUK=z2-Wz!V(hoy*<2e??X)`=B*xi#H{Ks<{tIW*}De(lXfyC@7j3MrfiqW zD>q)1ahK|ldeTaO7dtlK?wY@*99e4zGq;zS?0UZiX8~PmgX!&|_dx>cS!x+FzsfoQ z2yG)T3)4g845KeC<}Xp?KcRE#p$EF69Wl$M^jv<#KoTYwvfpJ0{=qb9Xf#*pODHx> zGfF%ohNEQcP5zj=B+m`~&j}4Lh|rbt7REUDX(sYeR{#r5?`O6tW#&Q0y_v+i04KmF z7A$=3M|@Ay7JLj?<7nGj7kjT;sc?fMv81JMzwM{8pX;yoG;*-sWYY9-zIWSayR9k}(s7DR{`(u5aG0)sx7GF%C%r?tB7`Gg1(hW5&&cfM&P+o>= zs=e3shm&((9vKedjG0zMPkOATukEex@g>mm3M;oMNqCuTL_nelElJu+BjW;Iu^!%Z2)F5NiD{@Ad&#%XfF&D9ZqTVT6rYzgj zyNbzQSCR}K$Qk+b5AI)>H@>5lP7K-7OxgJ5lP)#ZRUIN<{6zwaZ*4)xXH`Pzj&^@h zTD-GG-n?pG<0=Ec)h|% z*A$;}soxfk=SOzOD~Ju_*#*A*qe%8+Qf)&kO6@Y)sBZDFP$K1|_7eR~F8qyIihvos z-0nbfsVQFEQ&_A}t^S)47Fx<>fkH{i$Tl={r4(EgxFh0e2y2!jKB!>r$f#TNXnAZ= zwj#|gPPjt6QgX-SqU-WmRwNN64U+2jKs&>?=c6K`9L+^Uf4^&n!^zeZBevT$J+zxvGUpHk8fabt7nn`fP)U!=Bk_3h)) zPJAx2&t5E4%POkiw#3^JYkCG`Kb6KG%93uMESO2W&H<86N^^|yHpU8GO60LR1Z)uoQJvK%qrR_)h5PRP`hrT0m39jGQardCq)9LzNbPgAzEQ+*NCDIX zC|oYXQdqrb9n&GuLtv9N3 z_b&%07eFTdCZHT;#2IFi)?&HMuBK~cGSTnMhF{jLlx`XI-Se&0(D66l<34|ES9?zl zN{M%qT?92ZmK{$bZIYN*!YtcqkJXvNtuh~zN}owz%zr_VlooUJuzfnl{1w|gAx ztsR$ybhe*56IDf5W>^_(-uaKy_dC@iD>ibUDZ7*u{!eDwoncfaPdf!+C5Z+B6T5|k zowGjl_e-dPR$t*tnyP*`jecOk*YmJXZ@(`|w1;%3wEH;2Mm(F-tc+<)SeQy^&>xZ} z`mDd);7R{nA)DYEpMS$+hS%avph;y;`HfX(K%6+qVERK{LN%zlP1gcbHF$Og`|y0P z*2*ei!A}z_0@R_6{rM$N4z`DpRBg6d>f|oee>60SJJH{$)}U}?ba89KNkc2jrY18>wlaQTV0Ny-<;oJArytHE;-QsHKp7RE(2o3L) zz$iJS^$^xnKKKT?M_WJVvcgIS0$0@XEwjLyqXGW71&sl-b;kzqlX>WHkTK=F&W{6< zvRcCj8$;|m6)m0*UcvP#zH7hmx}GAI%FE!{A?>Bqg{HZZR@pzZOlD|1cMN=fb1 zyD7}p<$2==#>Ks5$11)A2-+=gzaQ^t&)x+vc7A08troErA4EBE=6lzw*d|4(qFN(( zT35Y@kE{DnE6; zdRxW5;QKur2>k~L@9hY5Wt>qFG=Eo-M5ppaziy=k)p*Ul*uEs!Sjr1~`S-3zKXmmYB`UB&J3Yc!hKlx&~jB^uEwQ*tq^wt? zN69B8q4>@v?G9e&r7cfZqq)b^I%&6f+(hd{JKe-<9*Pfrk1lTy@i!HLj&%!V+}04z zy%Xg{awn=>)dqjE=lX$y_xW&Ps~E-+#`#arkDCN`%9#lNuk&7?ku6#8(%8owFco5XlK5AlXdp)s>i_ehsF|hJIvNT#tXBk04b<#y zC9c~h*3SORy}s-vS!ypo}GvnY;%l372!?=q+dDxNQp_c8eTPh!op)U;H{!16FO@c%^(X zF5DAVT_E!SP~xr4MPIWKlAXou;0vS#RN6k6Hcek{2@!yNuh_s^4|Zcnr^%h`*IcR@ zAmAj!*)!dsODb)!{>(pt)}mF(wu)XQHm9Rmeg;+e#Xw@CF~K&*vMAd;NN!A^)gPtz}@ zerVb=Ed}i^8U&e5s93cGB@-KsFP5A^&ZK-w3MLU(V;8h_nkgtM$$WA~t=l%j%GBRQ zrUqWmyKHuV3LC(=^CqSzy6se5QO^9HRVt(BJC_aw3=f3^h<+uF8TSq;2L#jlgv4zW7F44 z`+j*mJaXe?V#QY7MK`Fx?JGd8RpuZ)+_kcFcfb@BaIs??eWJ)n-C}=`VERJR&J&Al zy6wCZ3K=ITtVwC3m$zI*uLLRh(K9w$XMfGI_@u-|cG=zw)(XVC?Ud6t=<@39M}E!mY6cg( zbQh$>WZ7c^HJq-A$_od{&x^ZE`+_D*4C39%i<93mmdjdol3=QkMBPPwP$#k7w%b`8 z`x8xl#ZF0iZ;KT(vn>O@oHWFtvxR08 zAkjRv7oLcKONvLm%6gfR_9XEa<-eJk#-GpUGGqTk4Oo6Ig_d%{)PpjkFZV8|>(qLk zr^5FD+d>U3l?)EPByz;wP&~y4&n4Qgx!u-RaitO2DtiutN_6g0RM*#v#x}-&g0r8c zQRU>uYy(c47|>uNtM!1x-&^)^Lsqa@QnuIH5%CQ7IB^WaEtg{pVFu8g4!cY!f(_=JdE zZE`)$(b$V@YIZy3>SE{h!)IMydD?&^&)v`J4IN98f^b@oK|6WglCH=OQ*aRbfuics zRg*}-o1cX$@u!;i7Ir~e1$7?Al$$j^xk50iHzp2d6}%K%RRG7cEx`H)`|8s4gDt181J;jl$B7NGaNlIrK)FPS$i)^f)Yn!%S8 zSHcY;P<=wahe&(rdh}*agpWL}*fTUrRd1)^z!3>I*e4+`s6p*)+3%KBwM)$VqY6oD zQI8w!(+8aJ4tJyS%V9<%LsXLt+g`0+WU2aZaD;37no0IQw%eTID)3oZhsor)_^Llh zwYT!+-IOQ4v*}kqO2)45O-`@jTQo?vQ}!8UE^ncr$jgIphUhVWYv#ukN+qj)5t?;icU#b`Cr0+e$rrtpvX$NiQ~7(kTy_ zrY+|7O2!LHcrwiA&R*IS#Wqqu96ov<0Y8loiAh&O_6Na@sghmtZ;N(JOMdS-WIT*Ss*OU)878+ReUtBnq`e$X5rx)S^39j_I`7@1AYNt37No~^%z z!GESAy%Qb5_JG{6`fw+Fp7(^0)Oa>7kr&NzE(;^whGr1=S|(4(*3&4#S#wDm3~@ir zcx;Y1b+O%H?U*rk&xw?~vSCWvLnr}o-N9UMkUg=xS?w#q*6Inptfd-MGgV(@{~E=^ zPtnG?V%5sGF1AUAJtq&}XTpG``l^H{Pu5JO*2M`=7=H)X==4?QtNdmUP+Nz%n&G z@#(KDmeFblx@@s&KPPzomRASO#(zSo{i;t0-MHq&3^yA!z1}dr#9N_rOgD7!(e$&f zWptUh4Jf24X-NF6tR*5zE<`1v!)Fj<+w{ZRvrBceTlCEjfXU6p{Ue8rlBxT_gA;0c z@q`?cd14``ctqoIvV?%*^2liHagQwy^HkZm9)Ui9okL*3Yq}cgadv3nqr{_6CUiZJ z&IJOB!}i@qSG%WNT@v(Mu$b0He!(J4rKyt@&~&COkQYAtE9HP5$4EAt5u{5was>kH@)SUD-`H$izq%|d)Cd0^rQGx z$&9ie$U$12mwD5LU1S4CKGHh-a%x&V;VD3Bbq7u>~e!o-Vi&Ud0u5o*bQ(yHrbs{ zr8N(hJT5#1M`lY5@rxEt6&HI4kXS-IE6!q_v*v=|$}Iq{z_%aE7Buf;ZJ@$bb}wN& zfC%P3g56<7QO*>UXZ6RQ8}7b3x8Ff)Nkk*=_S#qin52NWH{cRP9WIZ{80xe4cLyW5 z97$T>>c1#Hd-{#4ds1(-Xy&{4>_4yJs^2yX-bmExv03soLN+ql|5 zbMiuRwoigdZBE7(x`GTWN~Dy`^`(4yKBC7((J!3r@)rC;LOWSKg+MGkaYC{T*1Usx zywEKez-d$Qo9g1?)-k>G_2Dnp_uDo0O~cFa#NWH&vzvRYx}%%bG`8^~VCr?Xjs&1c z$*T(&tFZW)&QA%fz%9e=jzT<7+SP*b8 z`PEY}r=m+p-WprZQ$0~S%Mh|Jo*mmLB;xegQIM<@y=j#&g^FpUgdC$q$R+VE=Q}U_zy-DqCPyJhM9&fwg1OaH)9jqs+spE4( z#qTDsnHBIj7SqD@5l3L7kPF+qSj0nGb7sCP&G{tWFP?)H#h*b{4a4Sww84Wz&!0Qr zo04mmh^yXmX+XI+mta6+Yy+*?x#ZtYXo?>~;%EbA)6ZcaBF7uS~ z%7%=g?J%AXPtN^lzPzp-clfd-U!lkt&05Fmsl0yqOI2FdM&6b>(#h+~P8v_E5}O>f z7T2LAD)8cA%IJL~RVA>S>3VRkD##KAJDu-mesO?IR2H+dj4a75cdv=(75ds z{Y2~LDWdAvCrh2>2T)P->-YC`Np+Hk6aBzO_xaTC+#)~aU%9?dswf4CS=klO%{A5! zSvr9PIucUL)?9pqg)>~KCusY>ZZ^qAzEdFW<16Z=s?)xay}isV|46{4w`%9|_k!WUwD z%=?)NoT*)x3VA(R?j|*A1ZOSPu9{QIT*Ev$Y+a(ehhKT4h-BOS_tAk3zR%?C%R5`Z zGg4`AB7e?{wX@7QSWM;1?@6aUDFw@M*SE?V}X=iLpNgA-gif!9*s3Tb(ab zYVwS6s>jO>j29m~1~+6(k`ms$CzVbYjtV@p-$iD{MM7jw^SgHw@DKi`VYFX#+ZcG5y71tCsxoi=_K6 z6JFBoJ>mKHMyJD5?B&xRR0T!p;bWxU1tY_XEmn$HHFjlo!Y>Tf*& zaroKlQU1ko94gYHdZYFV*%rULybbN(MAR9>_sqNePY!cS;zgFGqQ9359M$71I-&Sk z){T5HwB;_`w6+S%v9*uUuO;>w@?lBApC=OZg|N0)Rhaxwx0gKK*vB!wjwZi6U&!q0 z{G1joXxvHz<=0jAw_24@d@H)#5?nnWJm_Fm6Xv+Kt{;@sNpnhu_MSL{0s`v!piAC@ zge)1+QZ1GtGn1$Z%iTEcIgaMDqCh}Jws|NY4fWUeQXdGLO!NWegq4>`mmv?dp1t@}__P z5J&0s_C*zELgDjkRr9ah0$*U#D>ws$8>)>T|K&1;(;gaECDl!iL6#DMf!hHCl9+&n!+mr^IpyXaOUc>;Q@ei3jthjmDTs-sgjORMEO zqt)e5s}E}ZBZf?K1BmP#ho*G;%#8MTbI~&@yc11U*2Z`z`_6G z9b5}osI(H?JD}iPX}l^wGsNlrB`?Zejpi=A{Ko{=f>=zcK)lWowZ9|vPY>h3Aj0lR zlUUTQyRcAdpE@Bf08mKjRIz#(qZwa5KZ|>B5QBV2>@SMYK~81`UI4uEV)Ay}6ud&J zLYrx{KlFAwme7@=f^!36F1k{AEiAe_Y5y{FG%5bX=G`Uv=)+g(;xw$=T~Vr5#H3<@ zlaQq1P0LW`TExp&t*b7+!tM`c>Kb*`tXl*mz{I_IvFJ0z1yDHnmv z$VpE}GWEWryW5Lk6Hov#^d$3}4zR zY|_q{IjtrsVDvJrR?v>uPE*u8+W0`#L+ORx1&ao{XH2ILXj+NXyNoEH3ACAe&{kg! z(^*76S;!3xcnm4m-ohxbaG>@G`gAbl7hX=h(CPa4II%@vuwqZZjMN*)mwn&j&$Dn? zb_^;yC>0=TK8m)fzrN>R%a@x8w>+AvI|Aa{NdZ>!eSoD_gnBgf*C$5iV?wlVXPR_6 zXf2q|>+jTTmBQ|6G(>V;UoAW6nQN%%H7=xjX6id6aV%}b# z2b~ee%a^}JyT44<3p53cp6~s%u+K7FTATIr_u^`1TP#jJNE8yUb&+Bp9UaLcAly;? zO*_Wa-?E|N~B)9g4xiJCiZX7Kd;)B?;>^F}xdldKn6QBRk3-dg7+Zo>b zObT2c4Ph1t z5#_Hz_Zs)gA(1p?L0r6I<}V8WQc1|*l%rOk=`0?JzEM5S4NFM#O}Ibc29R!HWBk7lF zy=#P*BT-}?dx`ieCM!8@znd08Kz zu1aFZiK1xE?fhY8&nvff+V`U##U<__-ZrQJKlbd$=z)rL=GM2&#P?MH+5PQu{5Xq& zOscQdFiu&sA5{C^3%7MTd)A`UGjo>&I8=^5&V-ScKMD7w<~5B0VAQ>(?9jG$7C{P^ z$sy6BUv7-vEHhT6Lsw4Y>#l zGQIf`L0}aE+<+zS!))U2I8}BAJcPCE*lhXY-dzR30b+b%bn!Y)yb3We@)x6>&PjYL z3PZhv@Ke0YHIf>^GA_}YZ;OlBCn->Lz~~F6KVwDwYq|S9JQ_5HE84o*FV@V}bufv;!&Y z9L%$?nTTR8T=kd7T2ktWklTr)u;Detp#rHPRZxQr>h3o_4pkfFHgJngKoSS@M30S~ z%v+-l9mr_QD}M8_Nrj3QXq~}$xS>F};!gndKY602rD+!l1+$maifouf?On^aih)QG zCe>HtF1T8H!bXB0d1h9b$VU}l+h{wXy@EhH-zTv%-(Lm34Qws=Hg$Bo(|D<31At6K z^t7qFJshmX^wjwzk8PG0y`5{B=b(s*Sc5xj=|@h(4Yg-K=A;Quq}F=k>R1cbDa@Dw zW=wtTmf6|*@+0)U3Di|+SJ!`6emyVI4yJV=!&w82)vZZA5rV>2G?L|RQz2p6g}cwQ z1Ef8fb-0Ehv6@_m3fO;9^kOXuAEW`D+ZMsB_6A`+4yyFeRPMl{Kl%qdY|K#5UZ+$P?x;l4Ph`B5uf7&$xxfaCYxR$t8MIaI6ZtV#IhmbkGXE^`558#@4qNq z=kUZWDBp2DQrwTa!T6etNxjB2KuITIG3!{mrf}FJeFYs!Ezl=kX9ApDAkJ(V>Eo8L zDY$jzdMXcro3QwCDAJ);n# z*g?TS(;5})9;J-&35NG=nAOF`blOMn4_O@Dkc&KO%IRJs{Q-J z9K&i3naOqd}=U9-Q8UL zp}bA(#2KNZsVP@GW0kAZ#rp=}kLO8Np5Mo6Yr>Lme3#NR{pI}P++nb#YzbvAnXy#=ZD`Tl=zm0l#TI&(gEBBLQ`r^4YE;N_=;Fr0kl1{*_#jeXd4g9ZFlddA+PMY* zNS;bN5l-0XgBIR+c<+vsmW*)9n5@($8nEiabGtH^5T>Rw@b#c zly-V4O5ZxmwkJU3bQD4U8Q*X*raYu)LwoYq$)f#nxOd#r=<=eI(`hs+(12?H3y7h_ ztqk(=7V*k{;w|dp`0P*L`Wx89%B$&zJ(B+g18w4Ul%SYPau{+m1b=K%*D`Y!-$&LF z>}gU_ZP+I5R2R&@=KqGnRbujt#d3wV5ctv?KdM9%aR zGiI13i}zG4FhoECX`F6f?N?*3T*DmgC0_eXFQBs*FEYPHP zu*C~?ocFUuM+r&cPlc|lj!=nsSfP-D;%m@6%>458w!O&8!Y-QH0!_-JfGQXO*mBzJ zh5LijSVUsUf53>`MPdGnLR9REj?*73bxD*v*a;q#H~ z`V;{FOEalHD?~4VQ}MWfyhw_c$fUx@fYEt$(WWC?b#jP#);%md_-=e$>BbZ(r%btW z9DEl&B=DzVd0%;?*@4eYj0CXE10*?@!J31enjh1HY7$-Z&O13?5(PJuJ#<=W52Yp~ zA$WjshS`&k>WQo6az@+D>hx1r_|E=xQmd7&Ae%7mEW2?X&0Fqjf9JkgVq^Zr z-#@s{2NJh(kfxHZ>?exjO4#Fk_3}T=ls3BINeEsPKu%=$Z-NEICHo?gPv46xb)Btg zv;uYCl*cnq2&0w`i7#mY4Ec|Nug#RO#ewffvAO4isi7DP5(rLsG!N)Z)B#W*Ts4Mm z<5_vced*H%JaMk&uY0BuXZZ9zsrxTV*Uu%AD2)5tYh3C3r)Qy0Z?F}5{cHqlZ=1B} z$6NiKf&pOU;(UX99u@V8I(F5di2FMS4Z0MR<~o3r-{h$KHv}mzBCn)EMCpt6grCjx zj_CJOPl^Za)PL;Si2Bw(h-C?>#5F}a_Z#Pvwe)97`1GSlDz?&e;8-nNQx=!Z5R67P z;+b~wW!**7I8Xu!FUmBe7YHzx?HXI|NGiT;YoDV(&n>v6Z~BXp184t>vXe^;Sz(-G zSi@;xm&A&A?XHQ;z(bL<0YR0v^ka)lRjV%qs0bNig*NvP9vf%5kSu1MZG zj2?4MbfEYFX!)+X%Hf+0V-vlqn7!xFz2gt8Ku|A-2@}vTezNeWB$FAJe#3O#?(J@w zgxl}}#woKh?Lp<>_i=?$XfAZnH;s#D5@Yj5ysKQS@CagylfI^RLb`AZWZRJ{61jvG z_y0$5BazWE>cU8&@2t}}?ZYSU&lv;=LL6*}u_DJ~6e>5x z7zehhC5vPB#6-M~trl(E9$awN^E=+niZIXlTd5sBIgYOa4PKczA*SW67N?uY-H1!Zp9A}`#=HPNSG|jBzDH4zD#>pl z_3Z3YdnsGaaVHpKT+JD1YG>gWKhPLuHL}>@x&kmH-;Ux>;-jmB;`2iJ7I{r2S}!1J z+*Hz4(b^|~j-OP}uBo|a$r##|Q;hb5&}L$V-(VcM*phV@`xJ&V@aNT7dAq3;nq!>m z_dn)fidDB5d4SYk6#OIU`xNjj%G@lPnu6G2`Rk3 z*KbVGpOLyTSVhVh`~KvZy%-U=#}p~18JG?F8fvlh@>t&RXy;_#U1$yg&VioftNS12 zIC^;J(zxE`yVu#}4ozicw`@SR_PsKF<@7Q+Ru&dgYlB|e>OyV zTI8{aQeQd8e<&LNKNS7*b+|Sa>4seW-fFyUS2X*-_?V1*dN_HXu z+t^Tw3dJS8395KzCPa$b51ESd?XEafHYX3ySX`nr4ySo1-yTd45B9Z2qT0k3>_k$= z1;UTA?yC^()+UYdY-}Np(~Jd5g>9ngBT~K%ypTbt_Ro~q@~WQ)yn_;D>n*ayDSst8 zZ`uUVJxtqFb?zRXx3>6vE2Ux1ozhkEtn-(jO-TA4H^&r-3)u-ro*#8^CLVV zSvd-~G#f-38ps;~H5cpi;!3S!)xs4laPs-c?A632iJMx>f@dT+&ri4~3o|)*$TjIL zH?M7s8leUDgFL7fXU4Rg zxmB{|l8=^cbfcprIVFtwl>QUg@bDc9JaME=XC>`fdf!&{SIx2DqT-Cwalsb4T8`S5 z-bkl0)yY`fXZ4J#XD6GO4%8)*fGHz8&mAW}NZjb3e|B|nqbo5gaZ0UHERWcTLC(EQ z3u^+9&gQ}pdhh4?`wi74QGWiXJq zTRwlIRZO$|E0uCfYf3C^B~(&5C4$MD7w+33^|-X&u5iw{zCiC1a#Qu`N?T_g-&cwF z2(^}?`Nh@)HSEJqrQ;G$GaeR^9U*zwz4~pS>98l++Umyl*!W`AE;7e3beEt7&edE) z20_1ri;Ozq*V;$BD=al31ORqmqCEvjTpiHxmZ>2cGXA(*P&ehD0B&!umFl?m&=CQm z52@R_tCg!uG0o`dUHKkEq*Iw7u}c)_6U3<@F}`P?J{yIMWB?olTQi1uLNap{gQM5*1qqW!@}Jj}6(KkPS;yV;AIRhp7Q)GmF}eiW74G=tZXlCQJF`%b z#Av-A_Vv)Xfkn75Gcm#l{(50BI`(N^?TI_0NSgWXhpMI12(RbBW>VC%E@2PJ886s?4P zf!z|aC$!dUklwcN_k2d#?{c{LbebiC?^oFOipzj}i9Yq%lk2tKwLcs2Cg*7(fL5$b zo$)^E1>$k-CD$%PMy%&J0uR~SQ5j!HblIU6uOh`0SiGOn+lnE0)Ji-ut)4zfm7aNg zP+;&kGSeKdMpi(gMt2FfjMkCepU^4Q>B(ujuylG|2a*)(Sh57Fn(%@{E0{lWs;9)} z$|E)^)o30vUOjjZ6iOI75G41paz92q{n04di=0*O~i-ii1La_WcSepBL4HFHIb`lI^csZueQuZ z?%OktLwt>%dlK8(km#yPq#`i%!piMsxUNAah4Rl|(zrq$Nr=%EeL6O$k}+V{7*^7u zTp?MUT>0%kyPgHwI)g{F?g_<;R$ot-m9XxmmX(H|utE}d>sJQIuQDS?DHu&Zle3F? z!kY@$J2(_a(fyv6DQV?6A>R9MmQAu#?e1dKd;#rkD8u2MV}*WY){Wl~!Z77^B?gT5 z%!we`{w2qlzbFO%az(xhwp#zPr=xtgFR>e#ML$vZzv@VgWlL{14h}Q5zUjXs%!*E0 zQQ6jp?kS9N@MY%3f8;Y$Kh39Ll^-!v?rWIbo6C&x^m)%-$23Ay*bMtsI6k>Hx#WVY zZ8wTOgHCy)UbEjUZnbQp_`HmOfRz&blPWAAoE7_fcY1eA_$LR=gs7IVlYNhOOH=Sg zgAnS@J(F5NLg1}LJeb<@JljXNU}sm=mhT+b@_{8eA!VpArIbFU`+g@e!x&i+9lgk&fDhcO+V+pU&h5~P#$k0`a@>DhGFa#(gCIhSL- zH}_LBuX1crRn!ABRC08WOvq2?0H4R&H3)E|RkYHo8<4qzqtF<1aXirzHK@3HmS(TQ zo5s=Iwd$gsQ~HVPM$)x(=}|5DoHHZ!-^%@~&~?d*wniiXSAt5MkP`j74?DkX7V889 zERjOz!P}G43q%3n4{9SMt3cBOoX@3djFb;JlE( z4qN%?V(u<<_?G17S^yvEpQ<*yfLw-&t?4%>YXnj$Dk&)hfiB=O){mLpK={hu2TlKyV!sWgB;);K_(-8mKqXV$i#!`UI~rdsd~8G-$eKeJi_Vm_WS- z7>PAR0&bXp==j37J_jy7_Evi=|*Q{ddym zZM+*OgLTHQMMnh1MOPdeCwemPX|QKtNDAepfAluA#k|g?pMf>vqi-bp3Ob8CrsE>A zliok%6Mt~W1&_GhlZsU*ZaAv5Sk{SM`iu&`uVM9(14cb#Va_=d;A&drit>GKC{^xF zKU^nZwZA5Xp?XWtuxC8}Adm?g`^Jo2I$_P>X;)fXdifnTR|CEhhR(r-h5+~c&L=v+ zR#C1bx6qT#>&vbnk#9_myPY2A|Cd(9+~{^pmZbk@)D#Z1fW$`}uCc@aAGEL4TJt`U zf=@|JNwpIAPMd2i3NoB1F29FWt0Hkj5nK`yn>{w(5@~PLw-TeXcU8CE=Xc8gy_bjn z7rL5`;?3fK+}3`_(D6)5l!aFkv0>a18V>{Ov{4K#gxl!?)+M2DqrH!j#{y#$g}yv!!0 zE~^Bs+DTG(Q5Z5n+2;Zf0hps(8>B3(FY<@KrbN7UQt`nmigPli-R5l&JPveY-LMwn zJTSFmhqEsIRN6-iQ%pc)EinyIVIC!K&^B7Y+s~8G&ei9`~)u^a6j(km~3^Mtz*3;_+i6E>tyx)a<#@ExuW*3|8WX092b{`hH@7YQ&XoBzjt1+w{f8nRnB|o z{^mTu8ku}4?qwQ5uq2yCF-jEhj8*lIZx){*ZwmOxHF8!e`RAll&3rC`$S||YSS{B8M&i75JrD6i!FxTYAuPCwQ&KTcjNN@*uF8?TQ*j1Cv&%~g$&$3w0( zH+k_2j$BQ?;{wFUk0`D-!rka z{dc@%|Is@iVWKc)-k~lg%kgW`(lU@22MEiLrqd^@>n#dehKoOF_IkBv`)R-lx)tF4n zU1b!sCeI2r6OgPREN5Emt3PO?9XFLV-Hz{KF6Wkbx1bBmaLza;%=(TbmLsWf-}EOb z)YP{>LkUkaaK(%iP%DhQ*qP>)G6QWYV;iiCQJmH3Y-RGor2339ezP1lAmsKpIMXfz zamh)u)1ozh@W0u1oO;9aW(9AhqPYc4bTtz`?F|$%tGMM+^h)CU#>L)yQ!)q<=`8{g zMQ@2e6@dRJ`MUVeim_K1)ps1wSYsg0;d+?^-2B9Dzs-ZQTeUX@ zsT<%Nwby=|SXziC3Qnc8($siY8y_D~xq9>024hbgLyVkSNt-d%d;B~Dwcrvbis1a3LX+=IP}Ml3Q_W?Xz>qmfWF)i^i2rUt zJ8R&O1;k_f=Jk#h#^m8>kVk3MM_l!9p-}R#@}iuyjCZeA+BNOgItCxBOIF|5TKk+F zTzzaIbIx#AaY?G7Yjk3@mY9s%7kRHmycI7_n#Ex`szFg+q*wo^TFzmUqw% z+TyG6%ulIIib^{`tGK=ErxzgAr)*JN)e0%`o=?WF;RLj_)rHYuN+Ttjr%kcz4DS3U z)tcHxO&O#i5@l#$v33G3D=T zxVu022JiUEdy{J-SOp8{-{`#>T_LU5M^IF?g?ZJVF|rCzxC0-VM*6AUMK3kP9z%`} zJx*^)fFuL6dIF<8rEHQK-^Ol*5-lB>w(Zkn2;k|$QnZ7eo~PbhJz9-tQ~pIRj z%9{4~IeB^20p|dMU;W^rvlz%qD(@*Wd=q6&LOSBoCdb9b{aGcrtaJCLRof4k2F>1X z*j4mdb?UP=WG=Nhw=qTM-JW;wAQ2ITeQIk zW>&p?$ryRmzz@tx!GFP|c9&=1yBipBKE?z=QVGxTal|<)8+87HsaQC7J|694!Y}K& z>9v{>j(_$+a!XjDNRIwCUq8pN5o$hiUE9E(NjL%KF=uW6LwFD##Yj}n!TX12Y@av3 zY=;?lcCUorXqzu~QP-RHVhoyDNbHPTsPAP5oTl|#6EVd|Qz4=w2|+a97xS&D=Y}3k zg9mWc6D19K3=QTvkOnRwZm) z&;ddqPOBQ8CZ2TV8|>XX2{vyCs%n74D0Qv@vP_W*c1u&QrY;xf#^FJiSTFPQf73L^)fu9(M& zc9=L&BkH@q&+`3v(q!!kPvxOAv4oGFg-^TSHBXh|eIjWx>YPbp)+*O*Yn64wCe8r$ zJFh?QZDK)LTPjS64?d1$Fnho`%pLD2O8StnN+YmSBI_=n7TQQFR}U#qjH~>VKoG{E zaDpsvQte%b_HLe(2>(66P@2XGmT7FWE|nq!v%plCGtN=%a)T#BGxp1#RxX5esCAK# zJEhyf%ZXq2#@#CV9KP&ZC~TCK=l$)7uI*xkk!%KPA-P0s?!!{Tw6d-0?_c6$^+fQA zlVCeqfpO!(>G;ku)D{rR>17-)q{BQ}i-r#5KJT@6jgjH#`93|uQNQBQP03{c!ENTE z2DzQv{E8)&rkb;vKAxFT#h}`7LiOeg;8LmH8nvi~!6L&X3 zIa^aOrUkwzzAB?ADlG0bAGbq*km8Sz8L;mbuD4An3^IzbDHB#264MYB(?UPw8y{*E zW5u;;H8At7GilheZ(5du8*u+b#_F493HaRGX z`LcOsIF0b~kI({Jg9W{bnKngu)#lGreHn=T>$fK9R2kr-ke}%EqYW9Q@q>ef;VbY7 zeV%E5L`wJ!ghw_3RW;xtoqG14Wl0I3uS!)fJ!5#HrSUSr0iucE=|waRDvU}|WScrw zHrr?#N1(FV&RPSm`fI`*m4nb7elt)ru)Qraw7mOt%f4m>(raEBY_&l_ti2 zxv|k85)RcZNc_Ppzq%ezvfllJ06FC+7Ntpw^9D;kGbmj!k!*u@v<9A5uE zC6W|n_Wtvyz@)>e#%7Z9mraIV5sMKqJZ2b9+R8hK+gto;e|<+OpN^v8J=!)NcP#E> zBhtwR21`0hjy4e$UW_c#9Ytuh&D*^Xo>Vc7a@im=OBbiH8PFt%)LR-v^fP5*WW)d+ zms6#Jf^+R$_0rJuhK+!$(9Ut)l+TJ%)47>+X3Has*Xqp5UvZN)7%i3q z757YBg=R_Bv6n*nz`4ux+2gy!i!l>pwUwYM)*PR0+>sxH;iFI%-TFJTIM?*4u{oT? z3XJo_w|s=|YV$Gkq|0@)YH{KwmBS3{REc05qb^zeiD%wqV#X z_QIjbhekGVE;VtX7#tj{3uSke+UL0wtJ;{!!sFVKj-}61Q&mg~=I;v!vZX7gGdQd* zOQ(+3(X;A3E5ma1F-Z^3=4w8i#U!2#y}kJg*IrFoSxMEOF(+Z?jkXuTepn)W8*S>> zGIgFkcf`wuh8cgFq+oTWq)RYvq(mKybLDitth4d@+)aF9XZT8*mBHxNF6KYeYmluaNuGf+G8q<5y}v zc)zTBVlxuyMV42XYKZ?hbZ@;Xk=?RjaM#cpvqoFh)8Ml8pv;cG;`^)VRgeeNE z{GV0V9bB4zC%Y#QIf(>JX&hC9-OZjMQvG#&ES-D_arMr@4%j-kDez}sI`T9h%DQ&_ zFm=2WoKy(XDg1yGfW!hbHUzzuM9!}91KUueDNqGC}U)+jP} zd^LV!yty2rYc45GiLtIc!qjy&vW$bh!WTmte@a`#6rKHsd*3~-M~`W)>#Uma1|nKo zhd@8RZWS%$L>rxM?%pXlUEz&N{>kyWoiV1=iNv-z4O34es$>71Rfgm{{|LT87ksSHiGJ&n^7>aF+Khc*f_Cw>u|uHyIHU?$a6esLBr zO3k{IY&9|oGVfbvtNWeRvu~(yLl^0>kEvNAnwKSl zkSU#5s}r45;Hr#rs%4hC^h4f9zwa0eB&aYdrX^X<$PMGIP3&TD(dUPlN*;F8uXQ$H z7zHj%Ko*=VB~OWOKZkIuuePj8v&Sfzu%%nooz@>}0K8K?nwdr>^Bp2o{l%C3jU1W5 z4g1P{>%@j_F(tsQboDitYge4@rliz_q<=<^@mMJ?!%<1NxE_l_f3}j=3gpRP1h&|7 z(EW)qrG(-tj4cgxmqV?ShmWl*34Nv<&tG}Mum$9}HzFknrSdq)Kv-=IT0`C0-8vnn zRqk()=!^e?!7;Ukef1a2;EAX~xz3f)IXnSbRw#(fWGZz~!deg1z2y-F4jCVwR2@jm}cK;$RPI0{r;SSPD73udfB5q9h;f=>_J}R)*@O zW6MS<=_-a}+^))Uc_Ul14?W+}?D3Lxy1mPE+G3cy@-mxGdh=~`{Z1(i^Z8KagPS7i z8Nfn=jM$%B^IAlzXBh^yABr6vvcKlQLko|a-_;^!-WM>uZ%!tdBy=7}uXdYshKLe+ z%D12hK5qIhX9c@FIN+?N8euo$8`R8X`jv^($TQSCAP;=6%P7RAwAZy2Ew&&TnV3o> zOGG)C4Qhtj;lTOwnRdA*4SEBd1TTCIE zsIYZjL?mM)EQ{fVZeWJ6*Jh*29%n}tsf~#V<)dp%$-)-Glu?OEU8#;tn;=$=jxU>j z9pO$vPg2gWAEPZ26!fEc*Yp*&-ug}{W@c}uzYpx`So}3WHslnnD-+8a4b!$&E5eJ@ zAMiI=N(njW*y!s2`lc*mi zc)wG@5wE7LchJ_wmtFGF)M?iZwx$=>+Ub~G>w>6KO19fiC-jT3cyx96iflWjmE2k= zQJNPfrry!QESvwbJ}fS;>osY@fcR-L zEN&W?f?+u1esM^s)Kb~{Ry$lfa&W~i5YIEm4{PzFc}(a*T8~F5LveZXG`#h6^;VvO zb{b4z#trI{oqI<9S3WH(+GCN zFL9)kYB4VyFu>im@`x>@2C+5@l93w|;JA2`#EOR#GbYCS7JVN=;EgPJ=XORaKdZlv z^ovmnW{2??t2ktrsZ(n9EZ5`Ikw;2qWrgtUe`bD5p1j2Ie^fMQi^poY(Aj8e4SMagy862QG&gknZL`*qV@7ew$`|?EBS=@ z!}UV3Tn7V`F(^UX-h$@1M=V>N?`k$;n)7XSs_Byj@v50iNnW4WYP~=)L~{_L#ULN? zk#wo4lY8kWOv~xG0ad@Ci{K^U_-%*kLPwpRN6Z7vBw4xPQrkB~>ky#rPw+pAFSsi| z!TlJ#V5#~9XFYg9Tm1>v8hU|Q`w7Md@dD}VCyV=D-&~tFH~v*BGsHmk1h@-4w7&k& z!xKc{AwdsuuMceHau0u(4`O9%4}Y5(=b=3ssnY=)FVm3QN7EtSoD+>3?`o~pn?|5* znhVT&agh_;Q(o_Ctd`ZSX7MoMg9qPCo8lX*T7uP_u21H=cfM&S&>Pd*<<*$(Pv-pp zT$OD6kwfg#(Cy)eFuA^%eBJjr)s0mYeOH|E00zl%-fZj42= z9iTv8WZaqjSE2gPm1;NIqTEikdet%WN5#%1i#x0^Ous`FDbPFIp!o7rv9~6b%kbb= zi10gVNzdbvIWL6%-X_7XniaNxYH>sS7Yu0_HrqIIW^Gn2^~|r=LOysQ zNYo{_jvoF6n)HtWOwOJ)^VU_up9kyx>vV4nYbqz&@O-f9UX*7`hUyy;1UMA9U}bA` z(a3nDn{(neHz172`e)c10pR+ySr0*Tn^Irnsg8I@?@ow8%pq!*-{pa_Jb_;<-^5f+ zyU+w;$%aHRIMSx|4hrlHYDeM#&<-ajO5S&%8n)-_Y%9YxZ@fV9o;U5tJwCyQPQW8Ka~B%cl%@8l2l^G!8!8#@H7{MMc4sU2eK>>yg-BeixZJc-V2h_ODyLl-;&O%iWyeal*s6 zQQ?x91`pAGUMgvNs|gra@-~UTzO&kMy6+dx^3Jw{B(BJW;gnqwt~+W+q7G&;bVCa2z}ZpN1<+B5aN&yYfpbvirw!JCI~S$1b~J6txv7bRIb~3C0nPCdA-+MEyl+Z5 z(zJ*4f%R_3UE`D>=Z*e&#MAG4Ge)PzcDkXP*JSmph=oMc>onB&llnkDE;JK@^EaW@e#o8dNE1b@L;4YhLf5XZ~FC0D?n zRz{DjW1QkKsc>Q>su5=_?5(UEfxc|Wq?{ZM?q!{u=${JZrcyOPD+7vk0=QPJo#4Am z=I7PoR%;8cyCyJN_Y=vyK#O^GIFfj~?N09PRGYxjnq%sp#uGWe?Li7|f;c7pvB>>->=b?{Pn*Yha%S=&@+jJN>6KR~>EE(E$IuxDQSdxnxr7!AIs; zcCusviQ z=ftPTn=59qwh=3~nH7*mT|oJgKDc(DG6tXcEZ zeZRcJ>C=}YSzi9qm7VOXh!iSV zW;$JcffYC_^nIr45D}Qx+6qOEw;KjFstJm>0^XNercn!3Bc`S#BatGnuFZ8qWj0d= zg~@FP9fDP4pSxchZ@{6@e->NieFsRqo!04xEmuGVx8I8s$x{|0xwQ+_`@3`(#mcpZ z-TgP~E(5G9UJnb2aFi%`@yIVa?joB8Oyp$iFT*@S_-u`#n6o75ReIJh!Agcg4%O`z zYH@Lk2x2UuZrBCXPQe94o-qE;6-P7$zxx?S%^!-qi&A_NyVACW%^ujBv&JT-oHm3E z$wpXnaL1Xw^<%Ki4B(xWo0flADUy8ZH-G;ryDMnZdToh$=Mr0spIj~Z0>6yj5<|Ir z+EvUStBHfkJ6rmSb4FePH9X&Tw;tBtwcaN>I>x&-y#`w6n`DKaT?S=k1=M_MFmW*Z z5I(wF)zpG#0?XT9a+LNAS|sUzU<&XDA?|u~Fhxcbm31!W-ijj>t-TM5T)ir-3u7u9 zuv1+fA6;>rtgoty=L~i^IT%=Lg__7L}kwPz$2O+xenz`Sgo_?g)a!3>PRm@}_-zfO$p~sM-ag z*aN|@gh)L8&FGvDZ5jmi@V$e)1uFD_$MFNww)poJZu*+%8$5v6Fb0UH0$2!q$Wzu2 zD5)#2`@FK2NvL3>+1(VGNk&5_N40g9k~81e3z=UW?8UY;vxd1*Q?VOhYQ|((xt>dJ z!7%?$0H!zm_N5UavUDc=?qTAQTF0mG&%(oCchk^U-gH{IB%rKm|FWFe*?(1o#u78RZwtO1&8*%-YxgK{SFnSu6K)^tjD8tDbYHz(@4U!5 zysNKf+YIJLnP*jEtCy`j-!YMUc{P%V{E)`dj+MZPIUE1I`~-7!3Y85;!r}P5;9#`m zK|g%{XumMM`v*|kHCg0Vo84oE&~)}Z;m7feEZiVKJzCKHBEiD-e3xyNJ5vYP1HGs{ z)ids)&oDI(u(ZCCVHM{(^hdu4Hb-G70nGssD-E&2A7U*G%zyLeP<5f+*I5U95j3y= zks|4@i``9Hi`?zV0gVZ;?#!D9SQ#!uO%@R@I8i@1r6c{9RaT&FLz~m=b!0A0!ATqO z84^Fy2@8e%L~uKqq=m*tEd`j0Ij#Yfb1}SQO=4?xACG2gg zl2<|mJC?v1j^hz4DNCYYrw|#XV2^AU>o9qx67gDWqA+*9UAQ?%;W7TO65lOg^i)c` z^QpN)&EJ(mIp#;}jmBD@@q5ClUbaj|HEGqV8}iD_1T)~&u>(;VEG?i;LB{?0Stflg z$?uXmmSf)3CU))+-+N#dPbq#a$l@dTXJ6Z7G(k9~v)d+L_wnQ^{vQB&oDivk5;8Qs z%~8#DkI&izm}A+s=#Kz}b7dO}y=Kb5oq~AW=+s$B3X7uiG*j$mD~4(Opc0-t@zEV?MkVf1Jixdih4$UWZHkGX(gY-$5BW0DScN7eKPd3V&Ch zC(Ss(m9>d`nPrigou*3Cx~pTf_$)0)i{E9wqQxcJ8*1j!I-Ym*6WlfI>s*N=^!C5N2 zrS?E{Q$YlAke;>G>*ytRD5#iuMLC+fQHl}X#+MzrYke>uVMY#HqNPhkEhyg`myGrd zsJe%<^l;R2;#4=URrjfY z+vn@K;m9_ugwzpCbt`*zf>Se*-~+pK?u?40$}{hQyam}|6{3m`lqXPz=6pZ3-O zxDrj;9pg6zJULq8Ud?yUZ059yP;O-`Gdg(eN{}`+XXR3C*%mEdO|?P=9}l~27C$M|&JsqXheqcVB9Ng>i6H{k=9L(5muO-;X~ z71z*w*Ti-{=N(@WQ3}v;@wx*;rU0kO8R_XtrUAuOl2q>LgrxO)69n4GL zlavDgnTuN_$F$rS!!6wQI+4eVVpe3Wia(U#y~0%R)(635DAR+$$4JAzrlF}=$=Ri1 z@`}v84cq=oNgGKErchf2InXZ7SJy&YZ|Vc;!1+po4NAy)i*hq|q9+?n9;8n(rX z-fh7$2SN{OWVw$?(<^?`G%T5=!n|haU>C-EdOfo2`HF7Q##9HwavNQCmB&RcV789( zz=~s5!_reSC;ZSj7?sNKEj~1~cmBtcRQ?hZ3#=)}uW39x$Q?^6^)O?NEw?zCY(}p7 zlxJx60>PQCtz7&2B$w7;*#e=F5SR^Mjo{NQ_hI!fPuuS#Miu9+6#AtTfPM_Bf%@@uzzUvxc6 zxi-&-Hbu#sSxL-DzC%HPdke!Hhr$KZr2H3*%?~ADI@)<5X3B|+EHEzv`YdTM5NtrT zqkd0xv-KP7KigIHineLLYeWyq-1OU@OMzqc z!+GXufOx+Pa>zbPxoLIC7s<5oA@+s+uYX1b8jOEDv8Ik^=X?muaFZgBh=K4rj0st@ zkIk1hp*K_eh$Fol^))4;ChVr_6$}+jkQ%OI^9SPL?OfftRd#IzItnb6((;lh2HioQOA=Td~FDw^^Gz!s@spPH0 zm325*%g6!&Mj4hY~^0gTgcLrWFXaIU8y)5NzKP=QT)aJ+GYYfZaMoEIyVlPgk}83VX7 z_FMKC_#-!P>SdUhBb)Lrhe=VBDciCLGCRIGJI_;;nEaLP?DKV#TOlW-oNl9cm^1cX z#YUZ`=J=4UU^n8Ci&T5sG{KrGYtF~U<WIQ8FT&-Uq!gNQ6za?CADf2AKH)ZBv=|5 z_Eb&06Ov{Vt+bd(^^r7$>|uFS#d&RLe}nw1LI!b_&Ec1B-fgxqqfKVm)|E z0)*;y@djMj*!rse9*4wWT4=U!b)dJ<7{LsOWa)!=d6FMW{K42Fc|pK9UPY?boa1aR z0nS6PVkSn*rU9!(a<~XU5dsFWev!M`01z~&8y-3zR&@V^&JNCqp^Oi{^?YRQG;(XU zeW|T5HXmx@{D)y`yC=DO*VrjRC5cVCjAY(kmF%M?Mx>KhQ8!uPsDbM`rYEN8deSSK zjw(aL=XdkWDl-{lwgxe&m6IHDBCkP?9i`DVFlY#jDQjbHfdC;bvCIg8^&5?MNiKRK zIg8LKWSB@Yv(Ngn!8gl`+OBpKLBUZWzJ{5pC*gj*(k)#la84tq@v*pg(sES^@1~x2 zNNn>?uTA7s47ncBJA{5E37Q$4;Fq_zeP+27PF8QV%gY3_&MImg?lQ5hyu)H%Cc&sA zB-PCq(^PSr8y@Rn6GIO+@M?73T9z5%Z!eKKF*S2Ya{sztvTvW}RSlETArsm#0;^dB zz^!U7P}Ee1c8mjg?#JfXvnQr-x!wtI5nG*8lznU?{f3#!w7JfE-+xYKbPFu-rX zi619OxH+hyjBEBi@Y9)V+f4@rM%$#PHTMv&^G7UdgVXN>lC!eQAuDvwyWgTxNT|gC zyGS{e9-*P4Dxyb5o>N^%m&}4aS-bH}lNJT1-R?p+cohxjbddY|kF>E)vuJJZ`iJL%CBOKevtThOBoy|3TEZLuuG5!_Bv4!Tu?r(6g; zy`k{Ty?9ks>KHCSCX&ffG|Y_@-FWbtXPdTG0#1e z{+O_?R-h!ZGv%S=gfBIDMJ2oh?$TC9?6tv+??SWLn7F9b==HvO-A&Ij)r}>1eE?Zr^wjM&uUx>E73&mg>4j-JQ$(+DfQ z+gzBl(2+O2Q5^!%38tp+?o5fJyd!<&vB~G=Hx-~!eHK8_$qGH>@3$lO77i!z6_P!6`8lwc`KZK=?prE* zxs&#afTJTXVA6fVJtS2wJy+xirI7v|q8wj8JfTD3?ctg3rw31%JZ=x(J) z6jBni!eo7@mj?w*8pF?<$gU9mg3+d)n%$sp2eElh$*7fVR^?a~H3Ll-e#OU_uXjJx zpX=`E?-Ws)q)(0vx5!(IoAX~o8cS&NylL|SZbX>%h1a|a0m9&(gYhitk$WC9|DlVl zH?#9*k^u5}sA)8c42GADKK2iPI~`Kcmx%9G`CCu*Z5>z`k34rgr!5zocClYYooPE< zOP%U>oq0DtyBviMU{B&XVYP=Nf_Z*F#Hb5%eU#$63P0xXG{DDGSHj8%aFZ-3Y>nP0&N$`7W{@uF~h; zwLq%^9YN#Cv zcX8a%xq0EL9X3qPISHM3_Y@4yd7lU@Ph-@3(RyQOuydHFS!kyyohrA0)WI^sT-c2Q zYI&uW-~1vu!^O-uXNtlbQLNOLoxyO4ax9akVK-o3kOfn_U{H(Qvz>-}5|4RK42s7* zt3S)n!Dt667np{*4Kb`sD@0J?jK^bkq4G5Hk^c5)RUEAZgi7fn{o{fLvE5kS-J#Vv z!A|bu?vY~LhwK%=BTKSl&ul9&$0X!3M;1~<%q*n6`Nab3BNw1NjqxR&6O@;sLQl~| ztAr6VJhf4y5KLevGk`CX

rGRU41rOM8@-&^TW|<)8*h=tHZX&M*^uvx;8nn|9L< z+Uz(dAn0oyW2}F@i9R#PYww7ByS#ZEp48PSlSXh;cHESWbM@7;;Y5^#9XIQCE1^Y1 zluHD1yN)4x_Qsa2Fg)b&m*clcH7u>cd3YdFvtnEFt<$BF1*B1cT_~s4VK@;wb(w7N zO<9S`opGbe1(Vfu?OX%x3kAvpY!e?E8uzS@h1u6PU@!lAr~To_Kr^d9O=$vdPnL;b zo9=T!?rfT$d0MmEvTd88b(h+qN@VxKZ@YFsC1$STpTk+gf?Ap%AIe0X(E&h$B5bRt z+;^?b5VqqmV5t+Rpuni*H}^NSww>;y@~`Y#^nhKIWXjW~x_f#LtQ0!i@Pb@v0+#sf zi=B%%^|wAv!)v?Bwkarqfm>O93rV}tW->Svb)r~b z*mvW$d^haK!}kSDrjOs+Yq8pB-1ESDBfU}3zTfim0gXr{ozTUVV0muEJ3%v}nNvBIIVqMDh#hQJ4!Kf5 z7ybEOhjjS-U5W_OSsX9R){Y>iYWP__X}@i(gKie-3~A@M7ZB8L^YvnBF;)?r>HI6x zBr~{I*7i5L9XBau0IWCcA$7r$@)F>IzvX7!jqd!p$^|(yufP^d2UXNTVKtdsbIQkf zIe5e;U8>_Rw$@(Pl^S6r<=1w+;0=!63rWR}7$|;q+&T6J?;E_Vo}WS+F)Dw+B4a!1 z6G-E4#7IbwYbor$tnDv;D{JZPH-fWKR$vUgIT;Qf72EaVPmBZC>1y{EcEyJ*u%ymA zn>J`nO&y=KWmVRvJrFAi8e!`dy9)SrIN*6XK+=XNKKyE-zO3k!RDDMQwmqh)qT z+C$bG3w{<=DsBLI@vE>b>)YsgzTcl) zc^#U`aVC!3(LiD?SG%l$mzR=Viw%H^gyH_r3C)wV8k4q&L&kp>)yr&Ro|$Kd7KY}1 zSDFMF^_`o^ho`kXR{WD;jZNF!UjSD3>&1eLjrm)n4p)6s+2hRcbKc~*&Sj+?V!+Lk zNuUr}GWh>*{HH(gBVnt$8i7v3cvkoi;cFj~{(^bND)0kXqMn@g8C3m<3QYB8mEBO` zhXo_sOeWJ1**qA5YBQ(}E2EC5?)cb}p=SMMQ)+Tm=YPGawHp!>n;IVpd?Gy7W=PqR z)6Jb1E}OLeulN4Tm)+)3wp# ziBQ6^i9nms^-EzVY@3(|?ZLHbX)@Qxnw2)Ej?{FgR+yjYsaxUFq=_F%RRm+B&rOel zyyn`8PW_1co|&ErSg|3>ITf~ZnfHqYnA!c7#CvIxuf{ZMc=a!sUd@M^iz&?+emw>jOrH*yP`C+Kr?qjc z=z#;apF<`tKzK`0hfni&0=81Cjl_G)yiOoH@L^j~(_js34Na`wdfFCW3x*?NfAU80 z7QSyrtL-h3J(fZcs!ujYRM0?Z_MlMqh$!B)>zbNMW+9WqIOAS)q9pa}al95FMz*7| zu-4G6@oI!`pHt&AZd-hDOv=Vv&Bcu4+i}NziqTd!Xz_k29UVWD$KmFLH=k!p{S)9| zY-{$D(`7VPTtT(ziJsNg^J_~~=eNrrPS0imO-8r&Km9-3QnufqQN6OaR9{-oFlPou z(o@LwRT}P8 zSFZQL7uh=NkbjAscFcz6Pl2)$m(Feul1(*+^J{yD*C;EgU6zxrX47yI<%&cN-E_ zy{nURlV^AGjh1( zq2s#0Hh*)bnRZaNoUo<#Zrw9f$IY-RwLUp7;PR-*XEo|`?YBFUNYwjoo_;0%l_ra^ z=`zLQg*8se6bh&lSTnRnO-i|7O{dCLp9Y1}T!{!Nt4Up<%)!Xie!SXgR)4q|t||3| z?)fJ2Wn_(bki&(GKCt9DtS&oZ>mW|FMCUX_fIiE{TYqa)d%mGKbMO%5Y$}QHaPmAI zvJ_(%W<1J~DF^|)&C30hywraD@U_UbgnR*DeV-Kn3ytoRP6kJELsM%}#nQD>oD^{? ze`Y7|RjlLH8|&SMFgd1e;$>!4?`=Kk2rqZY5eR)}qnR^Vb+dx|U4yEEV|GMQ{QX%YJ=m*g3wO?)9(6JX34`?^}ZBo*Vn@Espq-5&o9xO*i3y zyW{_+&`~f>8`pHT5~T>DSmN_Au_61_tl?vu{E5U&YiL#Dzh0=H*`zR2C~-EMC0Kns z|Nhm>T{apEeou@2P=7odRYudg*Q(Sflvc)_X>`W6EEm1r$ewM0XV$(}vbzia^0(gj ztUc>X_5Qcq$t-KF#0abS2&>pjsucy&D67aQYuKgD?rFiTJAF6lSZ(O#_-bfhyYa&+upS>>o~XVQPNsPPf`E+H4Tlp zdcu{km}dlFIgsxBPKyn7uoi*;nU=5Dnc1{BAbWzbpq;U(9mwU1+U0DjS7jkA5x6^(Z2QS`oUl1me zFJ3BNqNDsz)ePWa$!D9A&z4e|#GzaWdI9NhZ)R#jTp06pUX5sqRlHHI2vL8Zukk8! zj8X;25Z<%W6C@d`5LYZ1++fy3yBMML0C6hXQA9FdnQ{Z?fJ%WcL)w{nmcH$EThh!R zFXgf$Vn;hqpSFxZzEj8;0loge3#8A)*p{OCFfZA^>PUGr88g9R=qMj3dUD~Zt*j7i0Lj+zAS^?Y!0$&CiMd5M+j_5LG3ds7Zkj+SNO;PJUQ=v#< zM^O@rQDkC^QG6Cf3Bo~mha!Rfg(XgmqFvM_=ooPhtq8BT$~w*Ybyn{tK7uxmkz}9~ zi%6TY_4)x_PeEJi%fJQpIPKa}KTvg5`KSfynIy&GcE1E0f;5YG!0rKiqc2H8K|vHI zK-0Q@K+&dNom^9kIpv>s0Oc6-tSYs#v==1U_(eNtKF94Q&fG`-_1pVI}pt1aCMeqe<8S;0J_!QmZ z$-c;!m#FsP64lG>SV~}LjBIB@y^ca>(mv7WfPel*(HHQOl>$9Q(5SLmtA4enuP5oU z?d93!*^`6jnZxQ5J=acKLf`Gw@bk_u6c2<&^*^t$jM8w$fWO8@O*a@w%GMZwqg4%M$rR@@nS1G>usu*y9J_~Z6zxo|Hl^9kU*EFD--7qsZrfT_3ZS`_(o^=TqY z_5hT(D8dV_3qXVdsLq!R3;j@tj}VM$&p>q#@>eGZz_^a8;-r(HwX%0~rcYG+-+m+i z4iKTh)o|W;fAm#eK>;JGr<{Pa0~7*K!Uq#kPPmFyF(y2fdIiZA-E+ZAvgq{ zJ9(b>ea|`f!(Hot*ZpumoVE7uncY*}J-c>wS9R@QS3`&as3@qY$R=^HP|?vb&@fO? z0ca%X%%pmn71|@^@!ZwO8B8DlR>ewn- z!G?B3Jh!nZebUPQS_fdGpa5`W0e=A=0=}iFT1`US4*vJ}{|N^qWqxn1e7*ay6`Ito zv(BtkaweYtzvurs54!YV;LY2=05r?88!MM@2F^rt`{a7eb%Z&-lNN*+e);+<_PPIb za`__A@z}XbGV(eO2r1DkV}BAMDK|)v*i5`wsZgKS5)fe?PGg})_U z_nUc01ZWt7zO`k}iOa^w5P zs6Iy-^o@CKUA;(SFy@f;W&dFH|kZ8`MJIZLZ#pxY2>p$@^*M0I-OX$bV50Ik}MZIgxhwcU-d(gIKhn5$GZ+) z6sS{eU`b*=TWl4xO-|jTDgUD^&&Nrb()PPol>?zi5i-8E0lVvZ2_+l=cLDa@P2do}t^+>e(e=ILC^ zdc>$J)tYH8tWKFyLP2olbC0J0kE_PM93n4Mr$EMs_@=FwpXZ*r0Yhw5 zh<)bjIM(b0|8M61R}3LfRc}uj{M$%*i2W)acKKlUQ|c~@7Qq6qLT#qR^!|K~;eH#@ z88m3x@4)*-DA`~9Dn_q{oV{Q7$1B5%AJ)#55-yBBIWay3@0J|f-R6NbAFikN?+<7{ zpGyWlbmYGanz8J6;Qu0={R7VJzb@^mXv!hf&(X)d6FC3p!zOUdrZ)U)$CjJijndC1 z4qsIMn_pIKZ1WsjQnfccMNRNjQag)!#&B3$A;Bc5ay*0-iE8~Zr2eaj;YMA;g90Li z*}I@C*lw1k$5&gAkg3JAs@s#2f7I8KgMP=+$3jcf^ zR5JFbZHbS_wj-%-oFqd3lLY?x=1+l4d?-Gt4`r?Dl|I?WY?V-H>R>y5QKq*z`*sY% zOke*^4E$}YNe?@pWfe?qCeE)rht@sCi5gTh{zxGCtCsObZL#xP|C`bG$Jv&&tTBDm8Mzgwg)#?kE zyX^3p8kStPkC(ikto6iX);@CmzdoB86YTJ{cXCuoZVQd)^cXc^v@Nz|sjpuvlW z6UpU1y``KQ#<2bu!S0l>QDAzor&mep6XtxD%_4vm2|ILt(e^q${yhDNcNAs94f(JlROOcqcMg< zp$y1CfOA&n5Es&FgVGfQe_FrSE0d@f-#q+I`%46|orOtCBiR^Qno7C&Ij85b7qn0SysvU2jV2|=`O#j%X%(I{Pe^U<{*Q#A5tK=@6B0cmlT5>vsuQD4W9 z*B`5pn;&R|qUdLoLoov_hHM&m&)ZsmrhJrRl+UxQy`qriI=n;@IG5!KnPfg-}E0Q$(@&o;gWMVlU0|QY(D<^?-R2YhQ_mNN)=LiL&B8)j^ zfZqkI90uay#_JLPQUk%wI;!F3YgDwpo*`MFos7ACT^z{wx|#NA9Dwxwdp?|>$(rgk zfY=%Frvr=A(8yOq&;h3f=Ohe2Q@7Ka&)e`5dm8aFTkVC;_SB=$5F?El;ZC* z=181{A}ZQo7lbVXe&CdKf4%ywJKUrL0;dWG15xbWs%uS6!)k`}(K_eL+%Jr=x;Kd+ z{mZDU%8mjOkY=XcXc z9c~;%Bzuy16J}kt!NO7>kzVmCfd$b{s~oe7BT8+_@zidCA_C1GsI?n7rSC*R%d6uQ zzFPc(4b@3tuK8(wFPg=Ivh&f=qUtxW_ukoe=)P<`nCgc;?1c~`^ncS?F+0MqUA61rPN`^s+tY0gGhQgb0 zmMcLAfx8u%Y8Y4%L0PAG7nfGhSd<*2UuH$Z3o16iQ&bUbf z2kUa5Hm$P;OoasSN{5DS?QNY1@S{xu)E9E~YNG39i4CUY3?VlYnq(AlIx>y`16Kqv z>v1(F63&93TCX@BdrDfUx2ui*$2_s zzX0LGkMP)KGMuPke3{0R%l|2nXF>~TV&2}UMh-l#YI+#=gmYXdnp8@5Hofk?!Pkju z$19=(@e*1D)EI5y)JCelxVlKTIm|C=EA9)a}ZY2hDqhYzeKFq29HL)Qo*|MT7zdeoEUF1 zJ${b(MBtq0r?iPIqV=u`qhDXDzpBt|467*=+Ddp6~o#2zGaA^y>*cq zjV0&Gz6hMbB*`?u&>Cad;TkR!gXUgnuR=HGA(?hP6=OVkT>1rkbzjNu-Nkd2nMI(A zAp#X0Agfzg3k`8_h|6;^_Ts$5r3bLpl60+VcX^biUeX?=$+kw=YDHiPdC zsM=Ge?$|{ADCooWP8tH;=IEuzT!lJqk|R7_Y|ebMFOHA$xV+9=(A!i@at-Z3gGX(} zC;dlJksV|DLK$E_Icbv3EN535Bnn+mEP_o=@FL?dKl;V-y+v8InDy8GpH%nL_JzcZ z-RS=?$zd_}ki^L@sF_OUale@rJ16A4=hw1je4bg6P3 zqq)TdW3(ObnDPxTjJ%9}Vozp2Gook(Ma3A@U%<@>#M}t>jNo|w@}s@ftD@7h!uiHS zsZh%&EYEn;EXFq2R9ZHSC5NRGG~1FGCFR~=$V~^L59e|jrf6DMnaaMMjA(y;)E*2Q zzQ4?q1KNCcv!tDr)VQp2(mK)pzN}{oGttvsb5gQf6ZqO=JwM)}KNoar%`N(bbQoe- zUebnlpT|w3t0fcf46W!$4)8eS{f5hUP$9qhwW(qZRTmxoXXB&xa1Z@KoL{ysLlGN43H4p~+@0uOM!P3S1F9R_4_a{zC z>LQ&s$kf&kHu!53(ru|!2vp<7`csMn*b(m?;8{G*c+zz~%S8RNbgK>^)3HG2-Kk?s zn&s--kBsLSXT&wHOAHSN*6G4>nL=MGZNy@Ek*!hleX3`A=1!=fX97DBOQ?ER>W0?9 zeC!Q|Wawauv7XYz5)Hx2dl#3vPUkFPPh+Y88yI2ogq~;E!aJ01(zr$6v8>^p5KY{d zVba8|t|?nVTdJii*{C(^70(W}O7}iYNtr^mOEbB?n-15soCVtb6h6Vb9hcOK6Mkgg zttcONYjhlJHc0(pa*bVCel_Y)-rbrss+yQHl@izOmtWy6wrU6LKGH=2N2{gvR^VL<}6m=c<2L#;o zf)YNE#W^0X6F9A9qQJ3+eues=VYgI?wMUHa_&)t(b>UFu{(r2NV32+B;QUkpnI}e_ zZ6sv<0aYm53PN{+?i#Chynns_P%;HThLR@k6W9~QsT)TdauQ;QWSR5O`G&?kMUx^x z)X&KXLuf|N*d7unS?G(bFLSC1KWqxG@y8#)8j(@R{STxI7hyD6}diuhARHVrWw zB_bimT#fc{DK!oWGP7n9g+FX4jTG0kD>`9OW(~~_MJ#&w_}^9^3c60iu~pX)H zMVYq`3Eo%^W!h*Bs+ee;rIR}r`u4fWtZ=Ggt(WBprEZLG;6z$z%P}qF_kLTC z^@k;x?t`Fg*4AGD^_M;AC^Jl-iIqu|QlIb}kBLBC9(U)F7505ypz!Be)4$nD5!tpQ|^cq`>Ki(>>$3f9ud{ zNj7y%Rz2Ptg%y_tYx5|KkN((=Z&LXfNiu|sZl0O+9l81S7=g_Ky;d|;0Bn>3byqL$ zlBADPAs(SdLt|K*eT<--wbZO9bdLBLr(E215KLQY-+=dCU94nV2R-PRmXVDPt_`1{xTprD{nyK4RX&Q?`LRXLxw6$7h_R$ z1opNhMLLMW3{55lP)nLTjrL8~VQ04-y~Si|q86pI#Jqd8MyX)a>XJOv8Q&Mkp3Qj_ z<-FKPsE zNYzyMpMfxJi4il&-IYXTLBv$;$49|NGab#-a^IvYdDk#@Gd^R&jGWl#(w@KPu*xTC zQsy;=A+rpqze-e&>{9AOIB?NNYQ^Oez^j`KwW*-abRZ9NHc7=Xw|uJbkrUC9Ew z!ikrU{ZdNw(+a8AJ8R1DRNo|fg&qnWlI&_ZraREG82;{#n4zo?&en?6Qjv&9vlr4((l?d)|w znYFr24egG)*L}35(NQHCamgSb71YotpGmMx)82>5&Rg)6Xr?n2tzbM z4boCE(PPOm0m3n7HvLw#HWOYghi0`#3_6RmqzKy+&mk>-BO(z-?hYR}~);O(QhR^hOOp-PR{eDRZ#wrnR6% zSlyT2;XFyKGSPQEw$4i>I^t}4rOlrDEGdoW#_CHrm}^r6;7sb;dUl?O8dvr&N(H?R z_**avc~?x-Y-;_=pqU)R)B?18iep~KKXGQ_e{{#+=3vWP5V+hfU7p*l-=*9K4I$9P z^cr8*zn3d8=EL`xTMHK+bYJo%jK(IM7>N=k^T}}Qpcs2kGCjLq(kQeyR=Lk-@j(?F zwFP0A10OSP{97{@d@7E)nx`UbtTKq=7ExCtzq165caxPRxpX9CSoy%6ZFXiveDwO` zQ}0^}6PzfUv7r6cOIi+4ZJR>+P*u9%5e;^x;AFCW!Jq<52Z*DHm6Gu4nOo^5u{rlo z@9kQsU4<6j8u+_VZ6k+bW~Ey@X6=+fb==4^u{0_WD-#=^MsydtrP>7wMfP(0{K{uR z1A(35=~!#{SY7)f`aZv>HIV_2=H($59)20EFKjWQn}n0quwKoeT5srUS+>PZFlL$7 z?hj$Z_dc}UMJkC!$DqSIoc4@A<8K_agPbTu$4PCJMUb>QD;}U36SN~2#=#nA=)E3> zX<*Xk{6%5x-rf3wDbzftm>N?l!zX5OixKQz6)42vYVD4BH-?8FBitse( z^fg2$j{(LtGDaVCP<0jK2T^>zd;|EcjyA%JIL?SvhLLVdmY43XXcC3s$%!o@f*w zG*!+rO{*lY;+1|$IJYfFlRVcZi0+rqHyIl+l}y&Kf6AL2x^_Iri^QP}uXtkJTh8m#3AW6I z8HZ@&In?etMuO88v;!u?VgYfOK1|L`sInyrul2F+c3Km=p|GPmybD(6$OIV^GxHN6A4jdM+6k@Jz`|C3nCX`J4cn<*yxMa0uAkJs+_rw6UM5uRV1mO zd?O{EQNvM=xj{}%7KwW}`DDq%`UC-4mKokNzU>Os)b1%InHQ1pNm)8*Q=i@z(2{`KL^rvUqhpEjMWi6gI%QQ3jtQG zXkNB(rpf4-=I;?Bah<3=$OULxo^~U*^0q;Zq90xp`Xzgbu7|ds6EbskM03#Ox5cfp z32IP+zy#;?=JK)|Q(tkuWelgScCLKvgh9unr*8&6R;PZP7A}i# z3v~>d66fwfv9Er9zHvX^k(RZ#QPN_ZfH_@Gxj+nqZZ5YiqCX`TVzEg`6JoPx1zO?N zw_Xo>I*J7sn<)rYEi}HVUdntlihN+(MH-!{Rbn=pDK~1Etx0!yD)SDRv)+hxt!=~4 zEgd9bHgc75^L^J~6Y?K9m?v_i?QLnF;vU0`xBGwLOX9?vibbcqT2=KIJ(EhCeUEyJ z`Z--5Flocq_TG@8)U~`jc!60jF{0A8`kd^=di=Ib#=ao*ccNA&>y71a?(@b2z zun*01aE2fdOMZq~57R%{6GH9ws+wNxSi>;`PC}So7$>EAfI9voMX!j<3Z_o0S{`o# zH=ulyw#a6oySk=JXm6#n6nZJ?(vsR*sG!m!DA@kWHv&kC#VdfD@R|<>=EzLxZrY2>ULe|NgX3q-?m=+ zI?x~E-Eo-tOh8}r3kO6Ss1&PBO=9NYcVLJ#_WY`EMSmfZ9OcG7MY>if`=Yf@O0vM| z+sbh2S6@iMP1@#3Q5z%Y7lm;*&W}bl%{qj7SHGbYyefd+T>S*B2#BJ zucwWG&Kl<=k&^S<&SJxH#LrJJ(5uxgUsL2|axmSsqmky6cJ?WIs+h-&>Ff(6B;GIg zY~Bs=neS$n81Q+QDQeF0wmZ?86_kU0l?A=@3Y3__n_Jm`mGPymRi!V`EMHd^piIegQNjHh}-q`(6306Sv!GB9*I)M91qZl^HRcc$wB) zd$NmI#^(VEwVJ30-&`?%;NUuU>jd!A&2vb2qb?^YJ#GJv6R~p}2WG<4Agfwuh+$9L zU%W~bYo^W$ij$;pE54vHkTkHN4x4m;rnH@8ATwz2{;@UsM6xfXFue;tS8-)JX{!m7 zw=O3bIO@)Abo8DpGOE7SmaW@F`vYe=|2+LO@#q`nQ6_!FGZd`SkwJk;wFzj0zjP~6 z-5wd+C)Vmp{exmqJ=ubS2xh5P3AI2~eg@fmuNweZjCMVx&ta1MfOf56ZYJ%6C#`6+q_0%C-x>YKP1{~z3XKa zItV2HS>Sc6DqJs}d*k)sIu2G8Qlq;6mGwa%-_6=ewKuhv@Wtzw+25RBkK|_sAkBEU z<_VY0z3Hd>RrN~J;t}7wQSF$6tuJZJufL2Lc^t8P`Z_Q0)waU@z2?5$&3aZenLN}x z$+5DG@5YYrVibawK6_!d->@F*3O0feOsq_EBF21{rBOYDT{ThN5J?x;s2U^E&k$k5 zMxYnjlP7^38cTY$Rnq7VqS;jUQOCq<$%fCOMsf`rt+dJ*%n6wR*6a9p#lL_5c(t=Z zp4qpA7$e?0p$<%X8ZL;Nct5H^z3a=Grdtz7=(2I|GpWnIIX z8sFz9JQ|9V+sb+E_@2a z%NBkCszvJ$?V4#juF~2bHJ^T>s}(K|NIWTmFc!g?mWZY;K-Lj+wzlLdy)3wHt45q6 z)iNs9gGX*Eyr4wU#?W5Xq1)4YIq@Q1IKC%bX1;domQD>(u#p?m39+` zqMtX^ABLAh`Gy|hyX)Gqf?>LK@QROGCBPSc_)(>3us9<2yCfQ#+DZ!@H^Dy2?zfW8 zO!On@w2><76K|IxnhUJ+Lc)aY1u}NpIl949{g{*^v-W%w9-9mAtZs+fwEd}^3dq)J zn&tlIaBu^z=iWv}8&=RF8#SsC1sXmLK7@=KDtWr*mjz!7)xN&&=5b}cAhAvB?{+a0 zv$$Vply}i*U+-f>rKE6T)JST}&?Syg>#huTyFQJ|@CK*n+w~8v#a;L;!QNIST3=Ak zo%){6*iZXl(QOEFem;FH&6BwQy{~T$iabp1b>)4T3r)C;D83$>H-TiKNaOMnl#sbF z;w&PCLQ0Fb+@2-(OGQ{@wsRdNT>H6Nei&t{O2;#zSW#~Jd_A$JIJ$-H_G|^oQ+k*5 zYjO#+wRZ-l#V{NVOlv}Dg70zg*U|+6LsnV}t+XTbZ&{}cg`S_=El7OQ_ zik`aEfAZj5f2 zz@WcKop9U8Y-t{Jk0m17?AImosTv# z(pSUn6lW;2D=?agd{Ji}yqnW>9{Ohcorc%StfzklwWzhGvO+iN)l`b`Q7xBrhrs?A z-v@Z^U|z+dTMCf4;ys7Tq)<0IU!uOvle7fqvzM~H zQ+Khf(9*GA!52kkvLAv>59K))HnmMb%<5hD#Vd0bFuX}Ws5js&``QtxPOgAjQfSB% zHw?xGS2>is5e&0OJ3Zt2S5G$Az#n7fwcxD!GOhT6C5zfow3&*dm#w@N%b=(+W)Mb9 z>dZ=;776Z~<;9(Llf}m8_?O8a483dyoB=BQ2_yRRHRHw@t#Kx0470yh5tIStp~{Kp zP;FS>@$P2oPRU<@CqOP8U;*j?arqP{9V~6#<`x{t)vwuaeZn<=F+qbji3`o@W-TQK zB$g$PHIxdMferVGZGq^|aATO1`lJ*ARlUfa438JQH_vl&8uEhoawfec0zT@M;*Y)u z&}ge&4PZtxZw_dehIzRnkdK}^(K`I5!Z6E3QXprJbgwxi8uH;%c? z=9`D}qEZ=GA0Y%VRas!;acAyS-o>vHNZz>B*+OXM^hi6wK4@a^78^MnBy z-ok|v6B(kw1|h##pq6e8%%GB3SDCa)zFereN5imXzhrHsPaw=M9!lZmm0wjN-`As| zua&;hc2?*VD@sKMY*%Pz0MRHCksQZb#n5z8+KD|S$Zdto35uGkW>Q{2@+aV+^)S?( zy-5l#y}&eil8Hnw#-F=iRK^1IX5eQE}`Rd(N+NUiJJ9%E== z@$?BX;CMbPbe}Z4;5n+MgE>2(AhCw7fe}mhgPgJ`hZ5{6r$cAnD+keSVKse*u21Er zR1kn@enWCw>$Ic~8scKf26QvN*z_a-XY1WU?pQsh^C^%J_;0hI`9(36F zpB1u-(&%T{vnt#bn+hbROP))}RG4%Hi2@Dq(h^~Ehy@(vk_gxbx+zk;#IHhW3hg4; zn7iDJKgPT@L(WAVWFj-(Nm#`YG8cSj1APA`N2_qJwWd*~KM{WIg@4;?!Cw|fUtrl0 z$-9Hx5JH+7C6c>pc4Fq@POZZvG{lk`%byw zE=__L2RLt~NYtIT8Z{CZsl5rzvPZlt`WV%W$BZ?TUd3Q{f|Ce(o+*t$=K8^O%ss5o z-i92|%{cLgTpy8jI*b~WQvyOIREawTLj<+MvIEYxK%3{wIzHwxrZixMxZeA|j?9-L z3EY}<)DY_I8plP76;d&NE;noeJjm9lL1}SynX%_qSX4#^WEDM#FLK_;uz*NS-=zuA zCFbMSK_)nNLEQu2!NCh> zed-soF08vF!n9>k07`pMSZ0J?C1L<}Cx%JjYCk9@kmnRX&+)4#tu>K!u@@vSOs>O? zX)@`S;gaRqRmU4b#Vxnoz4vpdKnJMbUY|o5KUmb3MECf9wNff?sh$5XfF5Sq`Q>Sf z?9WH@ZUR&oEOeuyphqH=aeh+Jw3>=IVuVl!Cbr3zz%)rGjEK13;978+n*vSjfPQ5e zA911pVb~|mt_g0G=Vsh%xg)78 zrs_K$$s1XVOe&a`Nzr;m(IpuI7T^9C;O*D=f1oV?B~RkA6yTgnl`Nsga%_Kk>D?X?z)5|{gcHN^+Wt-hJ46B@3#Z4^|L?a1PTt2Anc(ot@F^N zZ8MHn@5VeE$lm^`9Oq97?x;>%ab%-x7vU3s3hDEeLA&#x_9ksJe-@6)zIulL_m^1M zR7eI&+MJ*oxIK|mnluA93jYv;UXL`vg-r!K8Fqlum)CNRl^SGVQkS-} zkP~|Ps!c;4pK_g(uuxlWtp8bLK=P^ zNh+2q_iCgDb-oF|I6&1nrqv19r>*3mL*~{KCg}3z8<7OO!SpK9uv=s_rPnRd(}`e#jXdLZ{HC+Rd~U3Rq}!^jx(E?9>NbP-+8_oH0_bv zv3V9b!z-8IVGq!1pL&al`5EndcuW6#M%Y5jmApP|0sk2b`Mqa)-e{ck&3kpeiAl{$!hWQKD1 zn%1*}v1=i^%AeM=o^V%-qU4j)Q0B8_3B6daN}Sp6BHKXev3yq59tUjQ?>VUzND1Te z*Vx9DUXV~oh0PF`Au~9yHMVJy87jyO(mxqh$Xc_WZ0ED7b}b+;{_9tgKfgL&YmDo% z$%{2pwJndJT+dJqbCcvAHMecoiF;MzZ}%okI1qet8iu;>YS9fLYdWCnI*;{y7jqul zRD?`^B9nbgvY(qlB|{-(A2Xq{L$7`%V%=!V=pr4&IERxwjN4MTs*%4ria39UMxF0d z68$U;Dj9r4(n|cHjl{X}kkPT0B{(G7{!jAv<^Mg4>Jg?|IG{uBs2ad z8vFCGem;!l^Tr~HOc=(BIGdb-oMIw{K^4dr$5k6+?0PGwtxl*(Lei{mXXUXNYI-Z=fjEEO|9R0P6Kh(5ECZO_GCP|%O}yrqkpfqV4UaEM$iF2A zDidh<82;+P`AgN8u}iuk-)s@_ohs?)z?9c+baP)BTBEp}d?~^mFFv7E2|oQ!nRHRp zQM_L9Li!E8Vj=i^^Mh((F<%$+rYMbk>qzNS-bx@_iRu8UZd#%T1`biysQl=Q|3E~z7^?6 zH&|BQr$pr189g`dJPTI&Szwn)Nysv)Z~=z#iX8LW ziCCk;BwY6xH_=4}zfzZea75xAeD-&v$h**Ju(Mnj`a4F*-#2)=?7K0Zw{&T4SU4Rd z(F3=fRbMz6QLv$qeyail7423@lB}aM;29()yu1Qj7ukfpaTKnSj3U3^>IJ7 zLQOhPM243{UZ)|b=}-ync?u?0h{V4D`WSG%S)*llK9MNz=NaDC+4tLX(pV5NBx2A0 zIwuYy=iL+OG!#)`R#$;~0Kz7|S? zlUVV8?(yA8XANRXBw|l2N24q`wlL~GY8aIAns*mCuidVr5lqbQ+J*!UC>z~#V#R0H z3Z7a?p};9*o!~bl0vAS)iH|OmB{GxIKeImFufiQ#J#bdEpEypp3Ofj(0^n&;VRXeQ zDMQRs)o#y{l{SeXVSJcnqBPfDco6NcD7o>8K-7uC==1xSHC-B!L|P5;)vS3g4E9^#)#NdhMj7Is!iTXG`(%=jSjI}*e6n!vt%8>uz=Evn9I)5&YF$V zmDLZVWdE78cvvovyyTFrx4bl6#LICb(}jE);za3Ej|k6(tblC@fP>EMLUPOx+)(!{ z$Azvgo-H$2xP)G+qq3h20KAGsR+Jds=S0-&eGbS8V(~P8>SBh8ul|FYi>2?>*%_yK zX3Z5|69&U%QsqcI*nBKo>x}wZnJRoNvRdrrDrL({21NHYCr2kBInZdhq|TpMLgBIE zky}F111Xh9F%(alpeH3ghZq7k&$wRiMIhGjG`*1MAEwP^p|DC_E?zm5zExss!r_mDyCE*R6WvMUrvI3XXi1Mi4Tl_M*8NO4zKOO+^ge=Yi zo=?Oc?yE0*;2yK;?L;CczR!|I^%1Cet!W(=IlA&bUWL7rb{VST$~>6I_H(qxPa*|Q zOAruJm3FG!vF;{~L1T@c7=q%S#VcjhnxH*Nl696C#kmIk-0nUpONo;_tpDO@az zZa)p8lc01Z+SL29*36d};$w>!NtHZ>IedI{Qv4-#;^oE*ih&XOLq0llsP9%){}6-U zfy`-bq=(tEH+(MHVV&UxZ_PEtkIGNZ{lHqQ8mcUpYC1zh1C#JvB)=)BjbSvdil{gr zxDqhqE4)6~V1P1L&3#2i-}tLc=3${d-?XZ7-;4YaH~d8uE#HlfDgTLcL9FCpP_#VK zLKku}aAy8!kTi&!g7rm0^4M>600;1zP|1g|6=c6&Bikta50X2!@vDs#RnceunH@=e&pg2nqSYfgV; zZu;Z1<3uGU+PV|1*@a^X68Tv`v(z@M z-MremMGW3K{<{ZDbi~S%pU})oe%6;RKbBITwE6U+m-1di%OKBsk&VtQKu7jIz13m^ zC(GdWcs5x3d5HeAe}r%jq%9u7)jEf$X_z?_5KZ@F=eT;oYJiTj4HRxqarMAyBK=9z z7WZZj(b=|@dBIz1=0y2PX>dWRiuqQwljEvY&>hm=e;jMb!;e@CmtZe2QDmtBhzVmm zK}7P@)tYIvGNx$53+L~<|K=D9gd;M8*^$vgDtnJ%vt=naR!JJ765D?RNeGU}~^KXJb zMzby0ZZIC|2QL@EcGum+_(jg71yjo1jgDL7A74}MjLak6eHW%uskE}@k(j05Cvmv| zzcK_z=yD>r@ff7z#UepY<^CQFCHnG0Y(WT^PY)tceSuIdI`tFtLcK`|%RRb0S6pGz z1(h0Zm|MR^WyM?kV<~eD{-dWy1-8>mDB0{AOl=yCb0UdEx#2+?l;%89tJPeI>w0qL zZh@NCw46XO5>8Hm)QiI9YahRTF{h&Aseh~`yUPbx)rj-ErN00M-H%K5)fcsp&@Qgr zH#n+ZHV&<+o*y(HuK|Nc)gTr;JfB`~hHgz~5s&YJ?aeuZc4h}H>Kt)<-J`vd-4S29 zLvdELr0Y0T#i22zPWl#LXr`AojK)N-$=b%q%*QB;lD@bdrM~6_c-2ZdPUvb_&(%n8g&Fg zqTf_Ylgtf2<%r}@Vl(AF>e1gQ^ z>=D!2L>k22OzSWoIssw&V13YdT)JL`(AN4EI@4qw%n9A>(UA^IiQf*L{?|vRlu*BH zgvz{dchdHg5*Myz!eO9shI*vQh_S}_yiYM^x|bg-i%$|!*LjZv^hT>2X?2Ina~SR> zT54bkzo9-j!6Yh|vVK?G+-7J~05n-q1TKg;GhWg`QEi=99Ilenu2nAV!YVc48BHj! z>fr^yU)hZ=>HbU9Ui@3s-szl=PnRwncafPalt=z}I{n<_^|g))@JQl@HDqOhN8ciis1sLh3Yb+8$Z2Jd zS8~v4`5CG**xAiB5x+Egp*j%Tn%+PmaYOjY-eCEnmX#YxNgh`9-2>y~!pZa7aYj{P z@Gde6n!|H1CKC?QE;r`$gnqe)1G){)sDu{&lf zm9q9U1FAtg%LyIn-Ja^&%s{=Gk z$XvBaCDsm2MoqaRYRJ{W*EKODBG;mFU+S?X;IV^s@YRE8sLuHnu;C`e%f9G2HCb2L zj6I>~xIbqni2S497@iB6u;A0Y7qv(02J98T*czSSon+yKx(#l}PSBT{D#I$QPgIgs zWP=Cb0r~YaIiLzOPSZ+3uJG$P;^4FDk(LHGa-!eu8a>ivpyToS=-WQ5RKhI%ny+4* zyg+RYv4oR%LVZfO>)`jU@}#ZLm^yNn-GJUuXZt(uoX|Gw+$epQz0We0x6yw%u6Tae zP+jyr76{8p{lt#~bhM-|uJiX|9=VnF>m zcz(g^arRRJZc?B=pYK*b{8X141f9NPePG&T|R#GmL*`(^pSOZ^vvg1aa0hvkUF^Q3Z@2jxUr*ccpt%qBu& zy3KF$q|I|U^L{}>Ql&w@HdvlbWslD8(J8fJ9^WJPQ>hTRpsXhyot`K%g*>qs0Lj*i4m@DqK~{6cTIvy;IbRP;OfI@hG&(}wyN#~RF)YA4nD z%s}rM%vxh5)B=!}2#Ip|j8uo$he`uZpZxBx1LZ$ zxklPc=-qz-Y7T-y6`eWFzru`#zKrp61B`tYYltVc{MJV6s!1Z5B&*4;ee12Hy2q9= zxokWyc|e#+NDfyVy183nBzz9uF%x>ti(Za$Ort6L(pHc{j)OZKxlXvIHkdx)yHIo0 z%DjzD$WB%rQO!R-hqT|jOTPA_9yosFdf%|jcg<5Pp#IHR4AJ~#4*;(btR^6m4 zsr2#ga4<%@GMhP=*rxlv8|-zg{x9~vGAxd!OLTCz;1&qZAi*`bPjCiz2<}0G26vYU z?moCnfZzmzyIb%e2|)r0x@C+)JeO15=W^^+?`SaNLN%HsyfG#v}=5SH@l&6uX)s*P&KoZGC3_zy8~-1r** zIFEM03J+AhcdS1aD>c%8?%Xc8N-APjjPP6}kwz;MAZMrNFP49~U-(6uKL>p1Z^xvj zwsM3&-10u+;`_6S5RIMWB~Ke3j5OjmU%+pfV_gW4PkY>2^@4GhY$LfFJ;>H9mqV#Y zS~8F-zCGnmYRF0;X}i)AIUW>VQ+na43FEHG4T(R{Esq%{c-3l~TNYSllGSb6+J6|g zJluf>MdOmf4@VNPoZ$-Exw=rTVWbTgvl}3LJz~HcZVqNkfYJ6%{nb^cN%N7gHoQrN>~-td9nU6**=SJjoaNO zZH7Rh0N#p)@Zl=`76D~SiX;KS{OAg6pw4bySq3KTY@qZtPYc{n;Nv$ZRHs69evUQ= z@q>_=K65SyHOwml8un$Vl6LEXhQo!iQgtePxYs$9Cc&$A$fWD3o z;}vgpL+6Cd%Z9E%#vG09Rhk(hswffGH&pjAY12=}hkJ2nt(jn|fSp#25Xz>?9(j+5 zEq)9bMY*XAjnE>p@yAfb$__N!j7iA-Y=JOjx7b5VF(Q{UzI=-xwPV*00j08Tf=b1r zXmQ!5Sl$0>w-!EvUuRhj>Zhz63c=eF`SsVLdOTMe$~O}}#P}5UUTJ~Lwwgcnxa~R$ zZuQa&35nNaMw0!tKwgw4P8e1(eBd`|m%1(N${}CmvtGEnFcPKg^9eway0SCYfo1XW zvYVf&H^d3dsu8GYrB1~?=O7QgWn;W#vJISnbTpZoD?#LZ9>^yO<&(ym}lhUdPwUY$LzZ!6k)@SF> z9t+CLbj`(gv!4_A*%)t8X7A>+FIJTQMdb5eq@58|Y8TBAMZK z%zQ7Ux*VtcvPK@?$VTk!Nc1HJ$N$+r$fUN6>Tl)P`CoW-Uvz<8>d!83SiF54F{1yj zsw76N&TzLFIH>+gwVPnpD!x__G78Bce$XW z#vFf?^(V>!1jJto&^E5`*nW#rM}#$DYrrha{-uN*QRv%z{ncna=i-NUNTLiPk8iW9=^T4=YO#? zrxG9#lzrtC2B#_Fp_1l&6GnTs)ai@3CG>^sz`9+n#3SWFRJLT=-c4lS?{+qt3uVTX;f^DDjdqczTM1pg^089`+Gj)d!Nvn<((^QsBR7JsCi-!g3IU`E`6n~?$X}|q2I;rg5B85(Q#SjY zt2(!trA4dJdS`{9MJfaZiu-s;Fk{=K-SEDTgf19T#0Okx)TwUNOio!sy7_(S0vQW^owvezS&3VUBT5Lk2Fi$)O$LYBchD@ z>XbSu?smY;msxpIa04!kua}IoeT3`)8|~XQ81z&YL^gUlAd#j1D^cVbPmAPeXhF@- zrfOQ)$y;*R$>ZOyPP&oeV$A}IG~6j`Przr-2|GK_%#{}~$e^~2_8dEGV6vZ-q((&- zs%@Rrnst6JzifW%CX8uh7gbo<^HNaPKrQATU5|F+rdynj`em(aPPiy>9N z)o~`lqk}_d&DOd37|U;51CWf+^TM|Pp`7=gC6E~`Qrx0%8|^*QDiWi|pj%UI$6Z6n zoP?Trgb#wAERjsHNP2WbB{Jk^UleV04}X{6y*t@LLdY-SB?$k7*tSZ~BI;|YhnJhC zivYXwpTjIKh-wzC$?=Jd8#tn~Fw42xO z*%;H&M-rXaRd$1%h_$7W%|zxfqTp=ZpoK}rPr4X7O_;`Tosnh;%n&E4cjp`5orSO@ z+ng?VND?{3e94Vn!Eh20y*gl#j=$%whwM{cgMWGZmJpQ2A9eWdOIJ0gpQCJrT8@RH za5%Xnhm*Qd=yP+wXeZ*38*(yE$&a5vs%EG8UT+Ab1U@xw@tcY|2*hoN;yqeoBJxjK zd6EB`1!y-5JmF}iMRu>>T}=ykIVYKW%41R{Yo4NpUxLVB=|tcqvwO(cvGSY~MJ7)C z3hORd<9WP6+F1b_Go-1GiOlBloh%`aXR!?8^4KdPVl|cw>59D&_HshDtV9dmWlr;t z-^d#%qup0!#otYaqPkAhs$Eg36m(N6mk}$>I=wEmkniCvmix&Z<-bFg6mh~VJvsT; z+Ky{>Z@Ge&Ao)o+g?4=@JSi(izZRNvs)Ua+ys!fybL4ov$D~wZ+tUlS$EhEP)AqLo z-KRDEBJz4gbU%&`9l7tk_V5s{cM64G$+?L6bJ7Gqu`0{>? zZoCcSZn@;zBUN8`dUxn%i)D16+Cc15^480BMgGkZ6aIMSR*4fqb0tOE$L_kKm1y@B zqA=92MnTV~Eh3m{J%`MksP4ry;Mf^oD9*&_M6fkWRyne9^eNvOy&d``{9tdo_18v9 zywhA-*uDkQlzt1sE#$6@?AJ82t0=vc3%b+n*%>RhV_uZFu*R~l?N}K9Q))S`z?u5+oXP#aXa0BeUvR4j?f=9 zyWid6kX&Zuhj*1dm#E*=B8A<@?{kN?KWb5kChl_&03ND8p3i_M49)FVIeNn9J5@r% zG&3=@%Pd%0ey4wZp;RUzBzvcdKPJ{u|8fp5s+#qlNDD)o@8i6G5GZN~jK%5= z{{~gCHo(+8hDA(Y^+SmTK92ncVfjG*x&iFD5}-}MM?xz_gXEg?H@Sq|qyg0$U1hu~ zj{|yvO0gdvaQ3++kbIPNdx6LHVv(fa*as)Ap~n7&Kv}JKQ?qif`9VVV*PM_d%{|SS z>$S+MtJ$hY-hGbeeKn6yK5&Frupn1j@HMl7bKN6J!tE8ck6W0Xo*w11vBGD2A`+inZ)?ZOrB$IX`*j>rNOp>8WZ(wWr!KfVrMx_#l#*;N2Et9658_cCtfIXy@3yYr%JBY zUzK@2Nu@&b8#JW*%LeBeT2Zd)*Bq{(QZt%cZ63)jYu8=BJMf3kLIGs2Kzr~#(WXd^ zVDex{%?I`!&%Lt@zLUVXJINwo3*;F%?0RB!wg>+WirZOeX!%$QAjD%Tpo-B3AZKjP zA16$KlH)Z?N%lqIJ&AbgJ;rHzTyabuB!qkU z{jPq4##|ky%Z7n8Q|C;$W-&@ky{e$9t@1~$y=&9KfBVC=(=Go#ous%ixt;ZnZuhi! za1y<83n+{DPgzfavJR1;7v(wI_PJNzb8xhJ^4x>rZ{{Xl`Bm?ICQ<$*?QQv!^hx>K z{G4q_?)_W$VDOvcyGbOVw0}f*|05dtuPm_tGz$Rxw=92Qvj0;Q$3KedTVT=hn$cmv zSCf~W#H)$%wI?6q4#4-d86DTz5m&Wn!?=7y+^&|oT+{ELH{?vXcf;nI&+KLxb|)CS z&OBOevLmkiSrFsWlDu6l<;O(7|GQxzQ7f-&K4U|6*d0e4kjT--LoH<)fTjO|_56W- z17Oepz~Za{1eO2-8vsG7UU$EL*3dcO-sW?l6mGw;{`rAj)-_13l*RLm92%Wc`pTe> zlNfdFs_6gDp#lDXb*O;<|8j%Qgu!l->g3?#fZAY4xT?D-qC zlks8JInW?I_z+KP5n#4Hbk+YDzvmoqi|ED8U}qYOSsPq#+nBn+8aZ3ay=s6)4hmXE4{1Lh8o#%GW_dIpX;8k`&iM^8abAu74Av6e zWxeGa$ZwC12$+pc^yXIkDwCa*nZIWDRww`R!>v#jjiw|%C-ajIewoLtyU8bT*8GBM z?Kc?mrSiwkkf`mGhH(!vSjBu}1cR0L$$V^2rFyRR~2gF04CeGqFnR^6zh>FUZjcOpVAyw1pUPJ!0RN%Ya z4~pOK=RbV-drN0fcgTyST8v#ghLM3u-N(fz8e(ookE0An$R)Irr&-EvIKhk2MR~|N zm_sh~;?2nLQg=qu!bZK*jE|U9u|Y9&qm88&(ZVxDo;b~b0voxPHV;--L+VB=sZ1KH z<>DMLrS3q%eUj*J>n@TF;O0W<|b@kZiRmDU5DsfsDv6_&CwZSlkQ zRz0M78`ok?N}mP-f699W8*%~UA{@_ok8GO;QFr3ZEL$a(z}yC(_;t(dpV0S|}FvD0t`0c9kem^YZ6CAO(POc(w5B zFuNIoY1b`1=Fw?|d;#J6IU6s2Rr<2~NxCnwk&3}okyN|7mFDD1iaReaLDLawaQYTe z5~C>Dz8+-0Qp-In>9u*Sr5%qZyN9HpAjxMHkvhe2{XUmXQ{leIo~FZb@EsdS%lK+u@OZZAN}|~Qg&R0E$#(7AuKN~3lDx1 z%GtUjPTKr3Pt$qq;DZ3)#C^NpAYdYQUw8#C$?p_bQ$NBDCuhkT7l6O@{IQOqZt}bY zS%fK%)vj$NQ1bo*T>am5nBwitzdua-4>{ilsVpp#EbR=U;Rwx@=ct8yDAb2mymYDAc;M~omp%87% z;|v&j8G;VnJ>h9~Y!!~IZ(W_{uNsLxU~KR$Z-G?URs7UfDQ6i&Jmp3;RG`?@1YN}y z&QjMOmer;n(~3Um{%Z${`O|?k9v}blocC?$QCPgxk{IQNIGUAgMIL$uAN6uI6m=Ye z+-c|t-yX|ql}Tcv z>Z6y8$!l>d)^|CPydymsurf)=Oyx$P%5z=F=lyaO$fkBD@iVXTvIGV4c*#Sh*Q1yR zgRwx3m6yf`)yu`G&hYow()zhj?~tch)n@7L+4I+B?^e@t<<%uwwfA&}cR#b%06c}; zU>D#}GN79oIN2qraE8D4R`%q1koGWiyWaZb7FYsEB8uYxw6ZRMsIB!qU&pD$uJ(qo zk>iTw(*+L@q76oSW4-G{s7aX zAjTQtFdkHi2myP3DP7=2t^>sopJ&$|?CuFx`<9RhH;`)w=_hYxh zW%F-P%5PA|iORbyUK-bkos)*lg`&lZP#)bfoFtHWJFS7EW%~%&O4@uLV}nuz#`iJe zw6O1jM*CU>cqhGkmJsM$`9t}vY%DKe!T&748oyLke^qp;Kbs9zkTtJ?N=#6FY(N8T zF_3-eG5PSi;)h_}7af3Zq@p$%U^1Z6CY0@Hy~I=eUfekLJl0&+;HO#-XG1MTlzwds z%4J5YuNmlG{`NSOOR;N_mHUD`nQ!I&q#8lOv(H0wr2z)=uJJr70)(q`->zjC$|l-X zJRRO6)b?f+D@Ec@qQ~Y8zP4TMozx_YT%OpoK=!rKqr@hi9yA#=)qUaE@cI54;P*Yg ze7xY*r&YimDYBvhQ%H4mPA^-iTf; zyYDh6rk#{&6#=^QM`{*gmU`U$H0K{7;e`w`X@Y2F0H=N)PXTg2wGYgJ06mlj6(FZ< zVoeRwy-@JLpE*OO}z6kI^+hy@}*;KQNj^>zF>E_S? z&FVDeZGZQ`nOav9x*%q{UK^?cf!24L7pom(xB>m@@QR{Op0tvlY7S~63l2scnxgfl zkdkFIf?Zn3JMIfxzNj+Ar^jdZ2pflAT8#q#8Yo8tHx4`pmz+6Dyr0q`(Do(C^BP|~ z+2;IMt0-VuQVC ze~L)Sg$Dgt%Lsiq*aTFlpZ^(A75cgZ4e9`K4g0t5lMV^4RPomKX^JPQaH`qLNZv+m zZR?mgr{&4HanKBEwpj0(W%WR2zO8Rv<<4Ql)A}tjiOx9`?er2k@z1P`_j;>Z zWn-^JCfg3pUhKBgAY=I9qvFCM`MP-A3 zB6kpyJ}*TJP36lu?&Yc{td;j{^#I?HGw&M4XCyg^HmijqiTVlRXm_k<=OK-fF0@n< z$6DTD32PX?QA^`nr&?hZPX6^*Ga?7ra*W%)V5-?7O=2FH<6V0rHZpI(mF zqMsx2Bc=BM79#=LvJmS`>~TksumjJambIk3S~ zf}-!Nh$P&%pjVVK4n6=yIhUOb+SZHVAlZu1Ng?hTJK-#(;rrpeur9xU`n*o_D=>#K z0_S67&oZLc{A8N+fSHAgws{J~)Hd)ix$_b@aN8`Z- zY@G1k3T!(rT0O}mJ`*)q2pe&yy!$+vD9HH zHM$a#-z#c|2yawR1(kc6M5V73LxYks6 zevs17vI6YoB71VSyRk~!(u@cC3P^ZHi1G^Wn8Q4BKgF@;3fc^v8C!XKD0fIMz$nHn zlN}$CIPvy54Y0OCrwbS;8H=eatx|7>7Zo&2FC`xbp=xDZe;E6gU$Xw#^863e$RF)R z5O~_@=z!-U?iu9{@Y`p+vRj^CRT62>zx@&Hm|&lC2rk*%Kz*fB>m~o>>Ud2+WU7-! z{tnlf^=QFZxd)Y2S%)mV$}$;EOXUgfisgB<`!op)bu#~q|48Rw73`5)0mT$i(o@Y4 zXgCd`@t50RN0vsI{BAb+Y`Qx-0cVFmjim+S(>bAW(=|n2fLD5`+C%>1xrCG2cMQ0t zuqb61C`FIJGOagH1@kg)!EPEE!KKWGRkP7Hwk8T>W1(E_v0BiiXJjJG`D8Em)eyo) zGa5<5V8M7C?Y#fymaL3GL8#-Z*`%-nvNd)EIHa{4y0@g?mV5o;#f?e5|32NTBTG9_ z012i&hQAri+!^!q<>X^Jb@H3jIvuJJEn=+%)JJ z5u#x`zCncWeq9dpW`uz03hE#Q3YO;;3h&#lsF8A|vz8g7$T#Goe;$^q+4ACItr-{l z1syKT3|KhHe~`=8NPD-zGbFH|BPU3RQoL5n{ekXI4=44{{OWO~uETLb%o`cK&+SRl zC9-tR4boZe$ke@cc8(y-i1lM}z(c-fHI5fp3twxITiZ%P2eLgUjaV&$i?WAV@{mhTy!s*56; zh4$f1fHt;@p{yh%c2&6GftcSVj$E|}XQb*;`=2WPs8hDp>umQ$AJ2!f$BOTX1UTxn z<$vso^6=!K7{%_x5h;tbmgKJMUi}$4O>nb!XG1Uf6|aI3srniS^~Wwt!zwz?x;#+t z2Ow?cnQWOz=NUcP3)Sf3p?IZ52u!R9nkjfE0Xl_=rQ35Zr-8S^#XKEeH|S19y>;P{ zBrP3r9;~yQ_IS@QTI9h!NBj&r&p_;w3+oXnEhOx>b_0y<>M#}hQ!bpnqW~l4cs2xf=HIPo{UTdXz zdyi=`)wL6VTl1M}k^Z5a@iDK;StRL)0YEZ%JS5m<(2^=SY$W)2lvF?&Y?p#Pz9w#q zdO)+e*lm9*!XfU&y=y%~%C5}~Ns_Qd7<#K&j=Vc^_59&S%i9S^`|WH;F0D%2?mUxB zRHpa6Hp^4xyd+ekt-t2+f_qP132Tq<6ywb2a2%gk=GY2QsaK|1T{2PkJ_XJ*CxI|a zi9Zwg+YdEbz3%g0TnLFi^_p?Xi`;R zcP35`07dpgoN>l7f*89}$>ibS!<=YKl9NVTAk73PdC$j)ZCv?$vjx0Tgjq9C=IbzS zq@t9&HvDmOZv{6+Py1|d#x=L*)j2)<)x%h)I4UQ5vJ|+QjN^-~O7p`8s_#i*hbcF> zw!4?R6h9Jl)7u?;&YEn|<^#dQZ&Tgx=N|&~@6L&guOEuJS$iz(Zp*;=z800YWt{om zkiwL~b^9@U8)lz_lOQ9LuSniJyJOg`G=OOjTMnk%GA$^t6K4K9U_=HK?=&jSJg9ql z!@qurV#eAD#8)Nlf24&0{g+f%%T+;wS^*Ez$^UGOzqvWQ`dK%Rq3m#D#tpOMU`T5R zsQ+b79lN_}z<6WUb}8k4dex(q%w?AZu$H`tFaaTku$tPfTAIFdQzAMod-2)GbViKV z2ERoCIU$K@F;p~LMaB5}E#<&t1cnlI^+@conYUErUM`m}Qg9XPuYt=6tN26N7X}GN zGWrqJ^86=SRN47W%lG> zzpBXZRi?gZT|d|y9@;1FS|iwf4QXS`M5!rtFY*nxLDwA6?*Giir@mNOsFIG~^3su^ z?R=;lccxVJ0|u1@p4HxuHd%MOhIU0 zjm+t`BI_)NYx$C&qkYOjZCG=ksD>@b(UCROjRmf0Zzhl+1FGzILEeY|7F|<^Iz;YrJ)}Z<&)xe9lRqo!>@tBsi?&3Q_T-s_*Q6B@w7){ilA;Lh4 zW!*uNyz{=;fRJUsfF&)q143OZw7z?nW~H+GWI?26n(_Fx$5kcllkD8Mbo)40y+`Hu zrS&;{7F~*?g)3#+M2%l#k^9~|#&(*l7YGL&xm7K4ayGKFERr+EbbJ}_K4R~`c|Wsn zS72o$h>%8P*qDy`#g&r@T&gTa#oItvgy6b>A2u~7@ty%CK}iQ(zn;LM#bb7w%eBWA zVhw+(ngnPZN$!4RMvDKDwX{3>jffjld;;vcx|iV{xBR`z@`6x(Vfd!hC@8XU=kxu$ zr^3*|1qi#Y9x=7fE~i-iMSNXIrOL^ie3@;~K}Thg0s~RXiWPXl|4FvGZ|j^DYF~V( zPiL}{IAD}uX!dLY0souLNDtLKu;vU9iJYOPX7W|`%nRm^yS-1`7y5fNt@|P&wxmK` zi*d|^`80T}(3)KVy1u5+IEPOX4~f<|u8}6{77b_cs7-F(`K1%d$+mU_3dfPf2pPY84xLSh8aYwuf~~ z?6vSpv>V?G+i8pTyOv_on{jxFZ!=;pkDRmfxq3`Bk~$q&oak0~Hnn_qj5LljN30j) zX=^9fMwyyqvJ{p>eyaQm8v;G9sK+t~a}1FpiB+@HW1lig*Ep6jYl=&I_ zg}wO6M;TWJLO6=ekAk4 z6*Z}?P^E{J2C_c|;hNnQWCkz^e@ieA;U>KQMCpKkHpU+@M+8rShc*OAA^swbr92jt zsLuOP{TuWvS|hm=nqLTOhOtsgRB2H&Oz1eGvyJdm^o>{SqbPhA^veMoBmrH)rGWXk>f5qB(^}}awV$osO z1g6~UBW&}7JUQ6F+?;WWW)3s;`KOE0C(eAAD?{0mY?wRF zl5Bu%Y*K7+>{@hKw{nx0K|uH1WE#pEs{tNS}Qzu$pwMei4sXevOY{1)q2{QcN* zM^~xG`le>5vkR-SpkP{UcJ`RA%EUff*})}j&fN#{zX|@g9)RzGO0XoWZtctc)NjzK zgg#){*{@o`SBa%f!TA6$kKH}yqE1vPt#qM#W=VB6Y!H1 zP6f?ieCH@ZS;~TDQYdMg^1$4A^nai64_-!o!~t5+pR7eVa~dHxfBR-ok3v19I)QWD z|H4{?^N$FWI@Xx9ulERcxpG-|nSEpuy=tug^Lt3Kbc=}N)n*Fvgxsl9wR{_!w&nvV zZ|G4yl3{_D_@xbCt^vcp{Qu`(?aRs%b}5S{>4m!T>y5scbTV*8{I?C6A>CB6bti1@ z0AISQu&zN1iaeJ2Qf;<+_mUv|-XNihquCKa_||qnNDw=nt&4yn9`iVIV2OzQH^}=q zij)M&mPSZUI5w_jP#7zx)nkaM{{V%r0hs5G){|I;%RD-H@hP#RW1Rdol<4ejVFbx29a$>&5uqq!_`VpK$oO8vO?gN#%K z$rTrh+TYAk&@R7Y>XQuRaioKTg(er^dh=`qI~obZy;vbQERRy!bn?Cp->5)cpV@0 zDQcSEww%=vw*|Ub_WmWa^=7ogk(OVr#R9(2W5hEKjgW$4hE>jZh@*C-6zZ@au%r*L z$WJ7O#D!ySqzNCPNYlUzjV@DX^l1 zyR9EZE4<_R>LRNY2tuo%^<^zbP=jE5+Y?<-&5be+yKte@7FHx#$>4mI?h}#)r2~zUynev ztkD&;wiK~Zck?gmpB|jp^akVQnC5S``N9Fez8WbjF08HJnbKr*ci@=%nlSaH6j?^_ZTD z)2|vkiWdahEtt{g$`fl6`ghrMja+*#fz_|g%gwTAT$0jH3_G%YW(_YS6073M!{2K` zh@U3wOw*D`SwOjRn7H-E{K4m0-ro``Sj5E4VNh_2xb5x{A<;ay_nX;r4SzgEk*FNZ zWi^=4KC7Wd|2A%UO&GQP2V}1|UhLz;`_bbm%4fMRr>7lj^l5! z4!~GkU;EdMECec7Q4x@tMbxSA5KTT+`z2oWwW2OUXrWLFNBWD3gf+C=?2`pmHb(#8 zcKsIfEDU8oMx!u6$RcH%~yeAngRo4PkE~G~l z@FKu>oypCg$#tul6&-&Og=&{j*7gX}ZtrbiL=#t<(7UjMB%Qrbu^{e6^~`=htu}O$ z`~xaY3Q@(|N&B~ZyOld>!Nq4t37nZrrQ6;;>aK4|VFNp`qusAg`ZGr}1`6UTL*3;4 zdX8_&%-zzzAz#W8}8fz4z2LcvM9h%v};iJT2{qU7TIAlXW4i%z9*9E zXRmQ3D~LvH_&u|KnM#}~64Sy#?1h}O@{t%HC_1(;i-?Q($dYS;+c8?RiUj!*96y%c z4xWodv3KdUZ?5cNu!=$!2$|MKz|2)v0rb^tR7r=o9b8+8$LEyE5C-sWKa_HNJ8Dt+ zEf#CI#m7`Mf23VKLE#fKd2W$E;+Z-h8^u1OL^atINImb&%&LJUqjph_A}F8zhuXLC z;l&|~D$4m9{ZH=+-o3h?%gd$zJQ7u@luMCKWYMNT0`|ibTE-;CU_Q-ol&~%TJQP(a z-OcM!sS?we*Uvom!c7O6;1j_KE?g1s+s3PFd%P!k9C2GClw5+S9K5$=Y5%jUGF_4j z9T_RQx}Q;O_`klup(~&u$!EKW2*4jLK05NhdaR7mS~3Vc&8KAtc0}VJweZ5u1@Dx_ z3EQ{!xqhgA4ov%hMB95*3QiHF#dG#6y)cS5#Ih5~&K-o1Ie+mP`ybrAJ;k2k<-ES8 z+Q>MQ7{QKZuF>%3q@^#oA|sAeIV13!A?^N8Ze5K07Z@sj{aPkp)0X}UDEakk0)g}% zM&gTTIlo)fyCO{)QNPNK7 z8~XI457l%iZu`@3P`U#CBfAaSHz4ihxjfD8Dc-jtnn$(YgCGb22muKJ83h#+9SI!~ z2>}E`03i|}AwPM>jZa7`rGY}EY2j8#$HS{-9+I5W(1=RR#m6rV4Q5E4rDv3s(YAc; zu5KplQ8e>=6@-p}h=3pg(zxMnqcO)MvW;(_p|DhT99|DR<$P9<(lMpg?VeZednl%Q zJ?WeKCFfdnN%-VVt^53i(P8E0KY>O5Yw&-Q``>}x{ssBSzm!c4l>G;}r|6OpQ1&12 zXQ1pq-~`~&{u}Ur@#M8YZesv>XYQ9=;Kf7$9)2dG{xp#|-`*rW7<(1>b!`xOEGslpE` zKC^l~b`qLxwKe@;sa{|11ernG2J*;KQ1<5P{mECOyFJDA?vH++_n&Uhr$YT=>Ry%F zx*OdgyQXe^pn@@gQRQvHSs?&t{l& z#98_)3xGDay8Q=FOY3IreB*b%-=NX}JIBv2@Ba97Ey-=0o6&J9w#7h1lCn$T~;sEz}*ZKEfYx$oL3^URFBa4>sL zw~3QF4G(lJiN|R)T4n0)d)^8bQ+wHMwwKP!`%({wF2G(HdY5XT)F+TSRfxqFMv|#q zuK8?Ny2`q1)=)6HSi7UXXqFeFMUGO~J?I4&46Uw_DQuX7%8E*ygAF_e3UOK;YOdyAO42nLOIMF&-wDJgWOoV+mRpUXyede`n)ezn7X6N+}d#ONkeZ{}_%b z!2YOyLq{Y{AvxpIMDdKIU|roAlb|;~RwC7Tqj*%*GEM(Qp(8^eAY&S1`GRP1TT;c& zRvV0vb;x(Ty_@Y)U`$qt3kus2g_R}IY4NfkYJ(X^n~8gugFc+nH)SV!o+$qpUO4v3M$tuWar=yj>C7 zY$rHfMiKm62pc~RA1wynD_gXzohtP%NY9UAg#P)3-1lwmW~@KF9J zKTefGs@G6=-_1`woH4+@a|pg8IFV>o8Qz+NJ)U-w;8&8iz7Rb$AH__Uv!bof5mGy_ zj@UHYmC@T9xoH%*Hsk2()H)xy88}2SLC0CP7rTLo=;P+2Q>}60t3u1Y-Iiyii5A)YTlqLyslb(rL#AYbxF(;|eRmV(BaN8*OF<@Pi zP9{n0Il=p0N)r$zIA?jqM|H#gsPf(`k?AbfndGOV&*3jEWFk_QE

CgIfOH31(y& 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 literal 0 HcmV?d00001 diff --git a/packages/meet/.github/assets/template-graphic.svg b/packages/meet/.github/assets/template-graphic.svg new file mode 100644 index 0000000..24c0f64 --- /dev/null +++ b/packages/meet/.github/assets/template-graphic.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/meet/.github/assets/template-light.webp b/packages/meet/.github/assets/template-light.webp new file mode 100644 index 0000000000000000000000000000000000000000..e8bee3fc1f14d2fcabc9d91abcbef65baad1bbaf GIT binary patch literal 794 zcmV+#1LgcuNk&Ez0{{S5MM6+kP&iBm0{{RoE5RuMcj6$DBtq!K|wrw0~ z|9S7eq*x3Crfnnl|8sXSF#mPf-AN=ivlKAYl7dpGDU#4J^V3jhC}1eml1MBG9SSh0 z=M%-QKy{m<`?ZVbdq6?yAql7|fl?#}RkffLGXgaeP_O@$VAr6k1foik_)DsOyD=j( z>-k>0cE1nBaZXz2y+ES1ZEf2Cmb@f9NMf@0-aBgLc>meq{_n>KyD0sk(-Hlj0P_E= zOvpuNZ7>riNHelEz%rN zIR)ajiv}c*i!)<{hnFYZm+%e%bhszEgz+;^l4?4hIv`9L)J|Bo@xLBZ3?h7M!ipV%D)% z#o0)_C~9_#Rz%#5utM4HA|)>OCH{l&_|f)bhbuijpId(W`ciVX@BOO%?R#f?U&+DB zhqI|_9@VS{^Xv2I*gV+8$~6F-BBQUAYU5-V#t%_(qsAXll4w#?JZoN2l4=rEylS3N za;Vuw#e?PsCFB$Ut_UisZKYJNiW4%oHb;T`5}knkGx zrvVz8eWh=8D$z;p`<=gRZ~8L3f%gjb_yTm)bqprEPDo2cLua1^Xe zu!@Rc%RE3q^W40Nil6n5v2gZ^kJTKkygA!_I|u!5&Q^MPy}hvf`Kjb=gd9EEenh6| zP+#Id_-L_gcY(QF)Dktj#bWGkl@=qmvsT5~77E1vKsehX$>ZWoK5HqYTfSrs#+N)v zqPg=VT}|pq&NP>vgm(d;%RLFqtGCs`Z9cxTIp976W4|KMOc=RGcs z67K=V1K+{TfP0SM(me1S7XC+`q)tN(B+UVxf{2|as%MEf2T^@8CL`)BG?pxj$%tA; YW635Xtg+~ + LiveKit logo + + +# LiveKit Meet + +

+ Try the demo + • + LiveKit Components + • + LiveKit Docs + • + LiveKit Cloud + • + Blog +

+ +
+ +LiveKit Meet is an open source video conferencing app built on [LiveKit Components](https://github.com/livekit/components-js), [LiveKit Cloud](https://cloud.livekit.io/), and Next.js. It's been completely redesigned from the ground up using our new components library. + +![LiveKit Meet screenshot](./.github/assets/livekit-meet.jpg) + +## Tech Stack + +- This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). +- App is built with [@livekit/components-react](https://github.com/livekit/components-js/) library. + +## Demo + +Give it a try at https://meet.livekit.io. + +## Dev Setup + +Steps to get a local dev setup up and running: + +1. Run `pnpm install` to install all dependencies. +2. Copy `.env.example` in the project root and rename it to `.env.local`. +3. Update the missing environment variables in the newly created `.env.local` file. +4. Run `pnpm dev` to start the development server and visit [http://localhost:3000](http://localhost:3000) to see the result. +5. Start development 🎉 diff --git a/packages/meet/app/api/connection-details/route.ts b/packages/meet/app/api/connection-details/route.ts new file mode 100644 index 0000000..6c150d8 --- /dev/null +++ b/packages/meet/app/api/connection-details/route.ts @@ -0,0 +1,89 @@ +import { randomString } from '@/lib/client-utils'; +import { getLiveKitURL } from '@/lib/getLiveKitURL'; +import { ConnectionDetails } from '@/lib/types'; +import { AccessToken, AccessTokenOptions, VideoGrant } from 'livekit-server-sdk'; +import { NextRequest, NextResponse } from 'next/server'; + +const API_KEY = process.env.LIVEKIT_API_KEY; +const API_SECRET = process.env.LIVEKIT_API_SECRET; +const LIVEKIT_URL = process.env.LIVEKIT_URL; + +const COOKIE_KEY = 'random-participant-postfix'; + +export async function GET(request: NextRequest) { + try { + // Parse query parameters + const roomName = request.nextUrl.searchParams.get('roomName'); + const participantName = request.nextUrl.searchParams.get('participantName'); + const metadata = request.nextUrl.searchParams.get('metadata') ?? ''; + const region = request.nextUrl.searchParams.get('region'); + if (!LIVEKIT_URL) { + throw new Error('LIVEKIT_URL is not defined'); + } + const livekitServerUrl = region ? getLiveKitURL(LIVEKIT_URL, region) : LIVEKIT_URL; + let randomParticipantPostfix = request.cookies.get(COOKIE_KEY)?.value; + if (livekitServerUrl === undefined) { + throw new Error('Invalid region'); + } + + if (typeof roomName !== 'string') { + return new NextResponse('Missing required query parameter: roomName', { status: 400 }); + } + if (participantName === null) { + return new NextResponse('Missing required query parameter: participantName', { status: 400 }); + } + + // Generate participant token + if (!randomParticipantPostfix) { + randomParticipantPostfix = randomString(4); + } + const participantToken = await createParticipantToken( + { + identity: `${participantName}__${randomParticipantPostfix}`, + name: participantName, + metadata, + }, + roomName, + ); + + // Return connection details + const data: ConnectionDetails = { + serverUrl: livekitServerUrl, + roomName: roomName, + participantToken: participantToken, + participantName: participantName, + }; + return new NextResponse(JSON.stringify(data), { + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': `${COOKIE_KEY}=${randomParticipantPostfix}; Path=/; HttpOnly; SameSite=Strict; Secure; Expires=${getCookieExpirationTime()}`, + }, + }); + } catch (error) { + if (error instanceof Error) { + return new NextResponse(error.message, { status: 500 }); + } + } +} + +function createParticipantToken(userInfo: AccessTokenOptions, roomName: string) { + const at = new AccessToken(API_KEY, API_SECRET, userInfo); + at.ttl = '5m'; + const grant: VideoGrant = { + room: roomName, + roomJoin: true, + canPublish: true, + canPublishData: true, + canSubscribe: true, + }; + at.addGrant(grant); + return at.toJwt(); +} + +function getCookieExpirationTime(): string { + var now = new Date(); + var time = now.getTime(); + var expireTime = time + 60 * 120 * 1000; + now.setTime(expireTime); + return now.toUTCString(); +} diff --git a/packages/meet/app/api/record/start/route.ts b/packages/meet/app/api/record/start/route.ts new file mode 100644 index 0000000..dfe88f5 --- /dev/null +++ b/packages/meet/app/api/record/start/route.ts @@ -0,0 +1,70 @@ +import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + try { + const roomName = req.nextUrl.searchParams.get('roomName'); + + /** + * CAUTION: + * for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName + * to start/stop recordings for that room. + * DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS + */ + + if (roomName === null) { + return new NextResponse('Missing roomName parameter', { status: 403 }); + } + + const { + LIVEKIT_API_KEY, + LIVEKIT_API_SECRET, + LIVEKIT_URL, + S3_KEY_ID, + S3_KEY_SECRET, + S3_BUCKET, + S3_ENDPOINT, + S3_REGION, + } = process.env; + + const hostURL = new URL(LIVEKIT_URL!); + hostURL.protocol = 'https:'; + + const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + + const existingEgresses = await egressClient.listEgress({ roomName }); + if (existingEgresses.length > 0 && existingEgresses.some((e) => e.status < 2)) { + return new NextResponse('Meeting is already being recorded', { status: 409 }); + } + + const fileOutput = new EncodedFileOutput({ + filepath: `${new Date(Date.now()).toISOString()}-${roomName}.mp4`, + output: { + case: 's3', + value: new S3Upload({ + endpoint: S3_ENDPOINT, + accessKey: S3_KEY_ID, + secret: S3_KEY_SECRET, + region: S3_REGION, + bucket: S3_BUCKET, + }), + }, + }); + + await egressClient.startRoomCompositeEgress( + roomName, + { + file: fileOutput, + }, + { + layout: 'speaker', + }, + ); + + return new NextResponse(null, { status: 200 }); + } catch (error) { + if (error instanceof Error) { + return new NextResponse(error.message, { status: 500 }); + } + } +} diff --git a/packages/meet/app/api/record/stop/route.ts b/packages/meet/app/api/record/stop/route.ts new file mode 100644 index 0000000..e2630ac --- /dev/null +++ b/packages/meet/app/api/record/stop/route.ts @@ -0,0 +1,39 @@ +import { EgressClient } from 'livekit-server-sdk'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(req: NextRequest) { + try { + const roomName = req.nextUrl.searchParams.get('roomName'); + + /** + * CAUTION: + * for simplicity this implementation does not authenticate users and therefore allows anyone with knowledge of a roomName + * to start/stop recordings for that room. + * DO NOT USE THIS FOR PRODUCTION PURPOSES AS IS + */ + + if (roomName === null) { + return new NextResponse('Missing roomName parameter', { status: 403 }); + } + + const { LIVEKIT_API_KEY, LIVEKIT_API_SECRET, LIVEKIT_URL } = process.env; + + const hostURL = new URL(LIVEKIT_URL!); + hostURL.protocol = 'https:'; + + const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + const activeEgresses = (await egressClient.listEgress({ roomName })).filter( + (info) => info.status < 2, + ); + if (activeEgresses.length === 0) { + return new NextResponse('No active recording found', { status: 404 }); + } + await Promise.all(activeEgresses.map((info) => egressClient.stopEgress(info.egressId))); + + return new NextResponse(null, { status: 200 }); + } catch (error) { + if (error instanceof Error) { + return new NextResponse(error.message, { status: 500 }); + } + } +} diff --git a/packages/meet/app/custom/VideoConferenceClientImpl.tsx b/packages/meet/app/custom/VideoConferenceClientImpl.tsx new file mode 100644 index 0000000..8834ce8 --- /dev/null +++ b/packages/meet/app/custom/VideoConferenceClientImpl.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { formatChatMessageLinks, RoomContext, VideoConference } from '@livekit/components-react'; +import { + ExternalE2EEKeyProvider, + LogLevel, + Room, + RoomConnectOptions, + RoomOptions, + VideoPresets, + type VideoCodec, +} from 'livekit-client'; +import { DebugMode } from '@/lib/Debug'; +import { useEffect, useMemo, useState } from 'react'; +import { KeyboardShortcuts } from '@/lib/KeyboardShortcuts'; +import { SettingsMenu } from '@/lib/SettingsMenu'; +import { useSetupE2EE } from '@/lib/useSetupE2EE'; +import { useLowCPUOptimizer } from '@/lib/usePerfomanceOptimiser'; + +export function VideoConferenceClientImpl(props: { + liveKitUrl: string; + token: string; + codec: VideoCodec | undefined; +}) { + const keyProvider = new ExternalE2EEKeyProvider(); + const { worker, e2eePassphrase } = useSetupE2EE(); + const e2eeEnabled = !!(e2eePassphrase && worker); + + const [e2eeSetupComplete, setE2eeSetupComplete] = useState(false); + + const roomOptions = useMemo((): RoomOptions => { + return { + publishDefaults: { + videoSimulcastLayers: [VideoPresets.h540, VideoPresets.h216], + red: !e2eeEnabled, + videoCodec: props.codec, + }, + adaptiveStream: { pixelDensity: 'screen' }, + dynacast: true, + e2ee: e2eeEnabled + ? { + keyProvider, + worker, + } + : undefined, + singlePeerConnection: true, + }; + }, [e2eeEnabled, props.codec, keyProvider, worker]); + + const room = useMemo(() => new Room(roomOptions), [roomOptions]); + + const connectOptions = useMemo((): RoomConnectOptions => { + return { + autoSubscribe: true, + }; + }, []); + + useEffect(() => { + if (e2eeEnabled) { + keyProvider.setKey(e2eePassphrase).then(() => { + room.setE2EEEnabled(true).then(() => { + setE2eeSetupComplete(true); + }); + }); + } else { + setE2eeSetupComplete(true); + } + }, [e2eeEnabled, e2eePassphrase, keyProvider, room, setE2eeSetupComplete]); + + useEffect(() => { + if (e2eeSetupComplete) { + room.connect(props.liveKitUrl, props.token, connectOptions).catch((error) => { + console.error(error); + }); + room.localParticipant.enableCameraAndMicrophone().catch((error) => { + console.error(error); + }); + } + }, [room, props.liveKitUrl, props.token, connectOptions, e2eeSetupComplete]); + + useLowCPUOptimizer(room); + + return ( +
+ + + + + +
+ ); +} diff --git a/packages/meet/app/custom/page.tsx b/packages/meet/app/custom/page.tsx new file mode 100644 index 0000000..9c100a5 --- /dev/null +++ b/packages/meet/app/custom/page.tsx @@ -0,0 +1,28 @@ +import { videoCodecs } from 'livekit-client'; +import { VideoConferenceClientImpl } from './VideoConferenceClientImpl'; +import { isVideoCodec } from '@/lib/types'; + +export default async function CustomRoomConnection(props: { + searchParams: Promise<{ + liveKitUrl?: string; + token?: string; + codec?: string; + }>; +}) { + const { liveKitUrl, token, codec } = await props.searchParams; + if (typeof liveKitUrl !== 'string') { + return

Missing LiveKit URL

; + } + if (typeof token !== 'string') { + return

Missing LiveKit token

; + } + if (codec !== undefined && !isVideoCodec(codec)) { + return

Invalid codec, if defined it has to be [{videoCodecs.join(', ')}].

; + } + + return ( +
+ +
+ ); +} diff --git a/packages/meet/app/layout.tsx b/packages/meet/app/layout.tsx new file mode 100644 index 0000000..a812f43 --- /dev/null +++ b/packages/meet/app/layout.tsx @@ -0,0 +1,60 @@ +import '../styles/globals.css'; +import '@livekit/components-styles'; +import '@livekit/components-styles/prefabs'; +import type { Metadata, Viewport } from 'next'; +import { Toaster } from 'react-hot-toast'; + +export const metadata: Metadata = { + title: { + default: 'LiveKit Meet | Conference app build with LiveKit open source', + template: '%s', + }, + description: + 'LiveKit is an open source WebRTC project that gives you everything needed to build scalable and real-time audio and/or video experiences in your applications.', + twitter: { + creator: '@livekitted', + site: '@livekitted', + card: 'summary_large_image', + }, + openGraph: { + url: 'https://meet.livekit.io', + images: [ + { + url: 'https://meet.livekit.io/images/livekit-meet-open-graph.png', + width: 2000, + height: 1000, + type: 'image/png', + }, + ], + siteName: 'LiveKit Meet', + }, + icons: { + icon: { + rel: 'icon', + url: '/favicon.ico', + }, + apple: [ + { + rel: 'apple-touch-icon', + url: '/images/livekit-apple-touch.png', + sizes: '180x180', + }, + { rel: 'mask-icon', url: '/images/livekit-safari-pinned-tab.svg', color: '#070707' }, + ], + }, +}; + +export const viewport: Viewport = { + themeColor: '#070707', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ); +} diff --git a/packages/meet/app/page.tsx b/packages/meet/app/page.tsx new file mode 100644 index 0000000..d23d536 --- /dev/null +++ b/packages/meet/app/page.tsx @@ -0,0 +1,201 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import React, { Suspense, useState } from 'react'; +import { encodePassphrase, generateRoomId, randomString } from '@/lib/client-utils'; +import styles from '../styles/Home.module.css'; + +function Tabs(props: React.PropsWithChildren<{}>) { + const searchParams = useSearchParams(); + const tabIndex = searchParams?.get('tab') === 'custom' ? 1 : 0; + + const router = useRouter(); + function onTabSelected(index: number) { + const tab = index === 1 ? 'custom' : 'demo'; + router.push(`/?tab=${tab}`); + } + + let tabs = React.Children.map(props.children, (child, index) => { + return ( + + ); + }); + + return ( +
+
{tabs}
+ {/* @ts-ignore */} + {props.children[tabIndex]} +
+ ); +} + +function DemoMeetingTab(props: { label: string }) { + const router = useRouter(); + const [e2ee, setE2ee] = useState(false); + const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64)); + const startMeeting = () => { + if (e2ee) { + router.push(`/rooms/${generateRoomId()}#${encodePassphrase(sharedPassphrase)}`); + } else { + router.push(`/rooms/${generateRoomId()}`); + } + }; + return ( +
+

Try LiveKit Meet for free with our live demo project.

+ +
+
+ setE2ee(ev.target.checked)} + > + +
+ {e2ee && ( +
+ + setSharedPassphrase(ev.target.value)} + /> +
+ )} +
+
+ ); +} + +function CustomConnectionTab(props: { label: string }) { + const router = useRouter(); + + const [e2ee, setE2ee] = useState(false); + const [sharedPassphrase, setSharedPassphrase] = useState(randomString(64)); + + const onSubmit: React.FormEventHandler = (event) => { + event.preventDefault(); + const formData = new FormData(event.target as HTMLFormElement); + const serverUrl = formData.get('serverUrl'); + const token = formData.get('token'); + if (e2ee) { + router.push( + `/custom/?liveKitUrl=${serverUrl}&token=${token}#${encodePassphrase(sharedPassphrase)}`, + ); + } else { + router.push(`/custom/?liveKitUrl=${serverUrl}&token=${token}`); + } + }; + return ( +
+

+ Connect LiveKit Meet with a custom server using LiveKit Cloud or LiveKit Server. +

+ +