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 0000000..e984d81 Binary files /dev/null and b/packages/meet/.github/assets/livekit-mark.png differ diff --git a/packages/meet/.github/assets/livekit-meet.jpg b/packages/meet/.github/assets/livekit-meet.jpg new file mode 100644 index 0000000..5325caa Binary files /dev/null and b/packages/meet/.github/assets/livekit-meet.jpg differ diff --git a/packages/meet/.github/assets/template-dark.webp b/packages/meet/.github/assets/template-dark.webp new file mode 100644 index 0000000..3de98a9 Binary files /dev/null and b/packages/meet/.github/assets/template-dark.webp differ 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 0000000..e8bee3f Binary files /dev/null and b/packages/meet/.github/assets/template-light.webp differ diff --git a/packages/meet/.github/workflows/sync-to-production.yaml b/packages/meet/.github/workflows/sync-to-production.yaml new file mode 100644 index 0000000..3922a1b --- /dev/null +++ b/packages/meet/.github/workflows/sync-to-production.yaml @@ -0,0 +1,16 @@ +# .github/workflows/sync-to-production.yaml +name: Sync main to sandbox-production + +on: + workflow_dispatch: + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: livekit-examples/sandbox-deploy-action@v1 + with: + production_branch: 'sandbox-production' + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/packages/meet/.github/workflows/test.yaml b/packages/meet/.github/workflows/test.yaml new file mode 100644 index 0000000..ca68e13 --- /dev/null +++ b/packages/meet/.github/workflows/test.yaml @@ -0,0 +1,32 @@ +name: Test +on: + push: + branches: [ main ] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - name: Use Node.js 22 + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: ESLint + run: pnpm lint + + - name: Prettier + run: pnpm format:check + + - name: Run Tests + run: pnpm test diff --git a/packages/meet/.gitignore b/packages/meet/.gitignore new file mode 100644 index 0000000..7d093c3 --- /dev/null +++ b/packages/meet/.gitignore @@ -0,0 +1,38 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo diff --git a/packages/meet/.prettierignore b/packages/meet/.prettierignore new file mode 100644 index 0000000..fb6e24e --- /dev/null +++ b/packages/meet/.prettierignore @@ -0,0 +1,3 @@ +.github/ +.next/ +node_modules/ \ No newline at end of file diff --git a/packages/meet/.prettierrc b/packages/meet/.prettierrc new file mode 100644 index 0000000..4148c21 --- /dev/null +++ b/packages/meet/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "tabWidth": 2, + "printWidth": 100 +} diff --git a/packages/meet/LICENSE b/packages/meet/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/packages/meet/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/meet/README.md b/packages/meet/README.md new file mode 100644 index 0000000..9b9f566 --- /dev/null +++ b/packages/meet/README.md @@ -0,0 +1,42 @@ + + 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. +

+ +