feat: add initial LiveKit Meet integration with utility scripts, configs, and core components

- Add Next.js app structure with base configs, linting, and formatting
- Implement LiveKit Meet page, types, and utility functions
- Add Docker, Compose, and deployment scripts for backend and token server
- Provide E2E and smoke test scaffolding with Puppeteer and Playwright helpers
- Include CSS modules and global styles for UI
- Add postMessage and studio integration utilities
- Update package.json with dependencies and scripts for development and testing
This commit is contained in:
Cesar Mendivil 2025-11-20 12:50:38 -07:00
parent f5d0051a19
commit 8b458a3ddf
207 changed files with 20455 additions and 3124 deletions

View File

@ -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 '------------------------------'

View File

@ -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 '------------------------------'

6
ARCHIVE_STUDIO.md Normal file
View File

@ -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).

59
README-E2E.md Normal file
View File

@ -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=<id> 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-<ts>.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/<id>/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.

View File

@ -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).

View File

@ -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

View File

@ -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"]

View File

@ -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

233
docs/E2E-TOKEN-FLOW.md Normal file
View File

@ -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/<id>/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?

143
e2e/LOG.md Normal file
View File

@ -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

View File

@ -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.

187
e2e/README.md Normal file
View File

@ -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="<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_API_KEY> \
--livekit-secret <LIVEKIT_API_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")

View File

@ -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<sessionStorage.length;i++){ const k=sessionStorage.key(i); s[k]=sessionStorage.getItem(k);} return s; } catch(e){ return {}; }
});
const local = await page.evaluate(() => { try { const s={}; for(let i=0;i<localStorage.length;i++){ const k=localStorage.key(i); s[k]=localStorage.getItem(k);} return s; } catch(e){ return {}; } });
results.storage.session = session;
results.storage.local = local;
} catch(e){}
// cookies
try { results.cookies = await page.cookies(); } catch(e){}
// collect ws events & postMessages from page context
try {
const events = await page.evaluate(() => { 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);
})();

View File

@ -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);
}
})();

29
e2e/decode_token.js Normal file
View File

@ -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);
}

63
e2e/logging.js Normal file
View File

@ -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 };

109
e2e/mock_server.js Normal file
View File

@ -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(`<!doctype html>
<html>
<head><meta charset="utf-8"><title>Mock Broadcast</title></head>
<body>
<h1>Broadcast Panel - Mock</h1>
<button id="enter">Entrar al estudio</button>
<script>
document.getElementById('enter').addEventListener('click', async () => {
try {
const r = await fetch('/api/session', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ room: 'mock', username: 'tester' }) });
const j = await r.json();
const win = window.open(j.redirectUrl, '_blank');
// try to send postMessage after a short delay
setTimeout(() => {
try { win.postMessage({ type: 'LIVEKIT_PING' }, '*'); } catch(e){}
}, 500);
} catch (e) { console.error('create session error', e); }
});
window.addEventListener('message', (e) => {
console.log('Broadcast received message', e.data);
});
</script>
</body>
</html>`);
});
// Studio page
app.get('/studio/:id', (req, res) => {
const id = req.params.id;
res.setHeader('Content-Type', 'text/html');
res.send(`<!doctype html>
<html>
<head><meta charset="utf-8"><title>Mock Studio ${id}</title></head>
<body>
<h1>Studio Portal - Mock</h1>
<div id="status">loading...</div>
<script>
async function init() {
const parts = location.pathname.split('/');
const id = parts[parts.length - 1];
// try to parse token from query
const qs = new URLSearchParams(location.search);
let token = qs.get('token');
if (!token) {
try {
const resp = await fetch('/api/session/' + id + '/token');
if (resp.ok) {
const j = await resp.json(); token = j.token;
}
} catch(e) { console.error('token fetch error', e); }
}
document.getElementById('status').innerText = token ? ('token=' + token) : 'no token';
// reply back to opener
try {
if (window.opener) {
window.opener.postMessage({ type: 'LIVEKIT_ACK', status: token ? 'connected' : 'error', token }, '*');
}
} catch(e){}
}
window.addEventListener('message', (e)=>{
if (e.data && e.data.type === 'LIVEKIT_PING') {
// respond ready
try { window.opener && window.opener.postMessage({ type: 'LIVEKIT_READY' }, '*'); } catch(e){}
}
});
init();
</script>
</body>
</html>`);
});
app.get('/', (req, res) => res.redirect('/broadcast'));
app.listen(port, () => console.log('Mock server listening on', port));

View File

@ -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);
}
})();

15
e2e/print-log-summary.sh Executable file
View File

@ -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<count;i++) {printf "%s\n", blocks[i]}}'

70
e2e/publish_artifact.js Normal file
View File

@ -0,0 +1,70 @@
#!/usr/bin/env node
// e2e/publish_artifact.js
// Packages a result JSON and its screenshot into e2e/artifacts/<timestamp>-<run>.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 <resultJsonPath> [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);
}

View File

View File

View File

@ -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)
}
})()

View File

@ -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<Math.min(40,buttons.length);i++) console.log(`${i+1}.`, JSON.stringify(buttons[i]))
await browser.close()
})().catch(e=>{ console.error(e && e.stack?e.stack:e); process.exit(2) })

View File

@ -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<results.length;i++){ console.log(`${i+1}.`, JSON.stringify(results[i])) }
await browser.close()
})().catch(e=>{ console.error(e && e.stack?e.stack:e); process.exit(2) })

View File

@ -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) })

View File

@ -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) })

View File

@ -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)
}
})()

View File

View File

@ -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"
}
}

View File

@ -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) })

View File

@ -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) })

View File

@ -0,0 +1,9 @@
const WebSocket = require('ws')
const url = process.argv[2]
if (!url) { console.error('Usage: node ws-test.js <wss-url>'); 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)

121
e2e/run_e2e_with_mock.js Normal file
View File

@ -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(`<!doctype html>
<html>
<head><meta charset="utf-8"><title>Mock Broadcast</title></head>
<body>
<h1>Broadcast Panel - Mock</h1>
<a id="enter" href="#">Entrar al estudio</a>
<script>
document.getElementById('enter').addEventListener('click', async () => {
try {
const r = await fetch('/api/session', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ room: 'mock', username: 'tester' }) });
const j = await r.json();
const win = window.open(j.redirectUrl, '_blank');
setTimeout(() => {
try { win.postMessage({ type: 'LIVEKIT_PING' }, '*'); } catch(e){}
}, 500);
} catch (e) { console.error('create session error', e); }
});
window.addEventListener('message', (e) => { console.log('Broadcast received message', e.data); });
</script>
</body>
</html>`);
});
app.get('/studio/:id', (req, res) => {
const id = req.params.id;
res.setHeader('Content-Type', 'text/html');
res.send(`<!doctype html>
<html>
<head><meta charset="utf-8"><title>Mock Studio ${id}</title></head>
<body>
<h1>Studio Portal - Mock</h1>
<div id="status">loading...</div>
<script>
async function init() {
const parts = location.pathname.split('/');
const id = parts[parts.length - 1];
const qs = new URLSearchParams(location.search);
let token = qs.get('token');
if (!token) {
try {
const resp = await fetch('/api/session/' + id + '/token');
if (resp.ok) { const j = await resp.json(); token = j.token; }
} catch(e) { console.error('token fetch error', e); }
}
document.getElementById('status').innerText = token ? ('token=' + token) : 'no token';
try { if (window.opener) { window.opener.postMessage({ type: 'LIVEKIT_ACK', status: token ? 'connected' : 'error', token }, '*'); } } catch(e){}
}
window.addEventListener('message', (e)=>{ if (e.data && e.data.type === 'LIVEKIT_PING') { try { window.opener && window.opener.postMessage({ type: 'LIVEKIT_READY' }, '*'); } catch(e){} } });
init();
</script>
</body>
</html>`);
});
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);
});
});
})();

View File

@ -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);
}
})();

View File

@ -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);
}
})();

View File

@ -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);
}
})();

98
e2e/streamyard-flow.js Normal file
View File

@ -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);
}
})();

View File

@ -0,0 +1,12 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Broadcast Panel - Test</title>
</head>
<body>
<h1>Broadcast Panel Test Page</h1>
<a id="enter-studio" href="#" onclick="window.open('about:blank','studio')">Entrar al estudio</a>
</body>
</html>

View File

@ -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);
}
})();

View File

@ -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);
}
})();

View File

@ -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);
}
})();

View File

@ -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);
}
})();

61
e2e/ws-test.js Normal file
View File

@ -0,0 +1,61 @@
// e2e/ws-test.js
// Simple WebSocket handshake tester using 'ws'
// Usage:
// node e2e/ws-test.js <wss://host:port/path?query> [--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 <wss://host:port> [--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);
});

3994
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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"

View File

@ -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

View File

@ -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"]

View File

@ -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/<id> | 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.

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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?
}

View File

@ -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 <sessionId> [--db <DATABASE_URL>]');
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);
}
})();

View File

@ -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();

View File

@ -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();

View File

@ -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);
}
})();

View File

@ -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);
}
})();

View File

@ -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);
}
})();

View File

@ -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<string, { token: string, url: string, room: string, username: string, expiresAt: number }>();
// In-memory stores for users and broadcasts when Prisma is not available
const usersStore = new Map<number, { id: number, email: string, username: string, displayName?: string, isAdmin?: boolean }>();
let nextUserId = 1000;
const broadcastsStore = new Map<number, { id: number, title: string, description?: string, ownerId?: number, createdAt: number, updatedAt: number }>();
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<null | { token: string, url: string, room: string, username: string, expiresAt: number }> {
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<null | { token: string, url: stri
async function createLivekitTokenFor(room: string, username: string) {
const LIVEKIT_API_KEY = process.env.LIVEKIT_API_KEY;
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
// Prefer explicit websocket URL, fall back to LIVEKIT_URL and derive ws(s) scheme
const LIVEKIT_WS = process.env.LIVEKIT_WS_URL;
const LIVEKIT_HTTP = process.env.LIVEKIT_URL;
if (!LIVEKIT_API_KEY || !LIVEKIT_API_SECRET) {
const fakeToken = `devtoken-${Math.random().toString(36).slice(2,10)}`;
return { token: fakeToken, url: process.env.LIVEKIT_URL || 'ws://localhost:7880' };
const fakeToken = `devtoken-${Math.random().toString(36).slice(2, 10)}`;
// prefer explicit ws url, else try to derive from LIVEKIT_URL, else default to localhost
const derivedUrl = LIVEKIT_WS || (LIVEKIT_HTTP ? LIVEKIT_HTTP.replace(/^https?:\/\//i, (m) => 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) });
}
});

View File

@ -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.

View File

@ -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`.

View File

@ -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;
}
})();

View File

@ -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<args.length;i++){
if(args[i] === '--ws') opts.wsEndpoint = args[++i];
if(args[i] === '--url') opts.url = args[++i];
if(args[i] === '--out') opts.out = args[++i];
}
if(!opts.url) opts.url = process.env.BROADCAST_URL || 'http://localhost:5176';
if(!opts.wsEndpoint) opts.wsEndpoint = process.env.PW_WS || 'ws://localhost:3003';
(async ()=>{
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 };

View File

@ -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))

View File

@ -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/<basename>
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)

View File

@ -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);
}
})();

View File

@ -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)

View File

@ -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)
}
})()

View File

@ -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)
}
})()

View File

@ -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);
});

View File

@ -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);
}
})();

View File

@ -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); });

View File

@ -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);
}
})();

View File

@ -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 <<JS
# Paste the following into the Broadcast Panel page console (or run with browser automation). It will postMessage the token to the current origin:
(function(){
const payload = { type: 'LIVEKIT_TOKEN', token: '${TOKEN}', url: '${URL}', room: '${ROOM}' };
window.postMessage(payload, window.location.origin);
console.log('Posted LIVEKIT_TOKEN to window', payload);
})();
JS
fi

View File

@ -0,0 +1 @@

View File

@ -0,0 +1,28 @@
#!/usr/bin/env zsh
# start-chrome-remote.sh
# Abre Google Chrome/Chromium en modo remote-debugging y usa un perfil persistente
# Uso:
# chmod +x start-chrome-remote.sh
# ./start-chrome-remote.sh
PROFILE_DIR="$HOME/.config/avanzacast-e2e-profile"
mkdir -p "$PROFILE_DIR"
echo "Chrome arrancado (si el binario es válido). Remote debugging en: http://localhost:9222/"
--window-size=1280,900 "$@" &
--disable-extensions \
--disable-backgrounding-occluded-windows \
--no-first-run \
--user-data-dir="$PROFILE_DIR" \
--remote-debugging-port=9222 \
"$CHROME_BIN" \
# Ejecutar Chrome con puerto 9222 (remote debugging) y perfil persistente
fi
CHROME_BIN=/usr/bin/google-chrome
echo "Advertencia: no se encontró $CHROME_BIN ejecutable, intentando /usr/bin/google-chrome"
if [ ! -x "$CHROME_BIN" ]; then
CHROME_BIN=${CHROME_BIN:-/usr/bin/google-chrome-stable}
# Ajusta la ruta al binario de Chrome si tu sistema usa otra ruta

View File

@ -0,0 +1,57 @@
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const WebSocket = require('ws');
// Usar ws:// por defecto (no-TLS). Puede sobreescribirse con env WSS o WS.
const DEFAULT_WS = 'ws://browserless.bfzqqk.easypanel.host?token=e2e098863b912f6a178b68e71ec3c58d';
const DEFAULT_WSS = 'wss://browserless.bfzqqk.easypanel.host?token=e2e098863b912f6a178b68e71ec3c58d';
// Prioridad: WSS env var -> 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);
}

View File

@ -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;

View File

@ -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"
}
}
}

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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);
}
})();

View File

@ -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);
}
})();

View File

@ -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);
}
})();

View File

@ -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 = `<!doctype html><html><head><meta charset="utf-8"><title>Mock Broadcast</title></head><body>
<h1>Mock Broadcast Panel</h1>
<button id="enter">Entrar al Estudio</button>
<script>
const url = ${JSON.stringify(studioUrl)};
document.getElementById('enter').addEventListener('click', () => {
// window.open may be overridden by test runner
if (typeof window.__TEST_OPEN === 'function') {
window.__TEST_OPEN(url);
} else {
window.open(url, '_blank');
}
});
</script>
</body></html>`;
// 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);
}
})();

View File

@ -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);
}
})();

View File

@ -40,7 +40,8 @@ interface DestinationData {
badge?: React.ReactNode
}
const NewTransmissionModal: React.FC<Props> = ({ open, onClose, onCreate, onUpdate, transmission }) => {
const NewTransmissionModal: React.FC<Props> = (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<Props> = ({ open, onClose, onCreate, onUpda
badge: platform === 'YouTube' ? <span style={{ color: '#FF0000', fontSize: '12px' }}></span> : 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 {

View File

@ -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 <Studio />
// Dev: render StudioConnector for quick testing of the session flow
return <StudioConnector />
}
return (

View File

@ -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 = () => {
<div className={styles.mainContent}>
<Header />
<main className={styles.studioMain}>
<div className={styles.connectionForm}>
{tokenData ? (
<StudioPortal serverUrl={tokenData.url || (import.meta.env.VITE_LIVEKIT_WS_URL as string) || ''} token={tokenData.token || ''} />
) : (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-blue-500 border-r-transparent mb-4"></div>
<h2 className="text-xl font-semibold">Redirigiendo al Studio...</h2>
<p className="text-gray-500 mt-2">Preparando tu estudio de transmisión</p>
<h2 className="text-xl font-semibold">Preparando tu estudio...</h2>
<p className="text-gray-500 mt-2">Solicitando credenciales seguras</p>
</div>
</div>
</div>
)}
</main>
</div>
</div>

View File

@ -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<Room | null>(null)
const [localTracks, setLocalTracks] = useState<LocalTrack[] | null>(null)
const videoRef = useRef<HTMLVideoElement | null>(null)
const [connectingError, setConnectingError] = useState<string | null>(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 (
<div className="studio-connector">
<div className="status" data-testid="studio-status">Estado: {state}</div>
{error && <div className="error" data-testid="studio-error">Error: {error}</div>}
{connectingError && <div className="error" data-testid="studio-connecting-error">Conexión LiveKit: {connectingError}</div>}
<div className="controls">
<button data-testid="btn-create-enter" onClick={onCreateAndEnter}>Crear transmisión y Entrar al Estudio</button>
<button data-testid="btn-enter" onClick={onEnterExisting}>Entrar al Estudio</button>
<button data-testid="btn-disconnect" onClick={onDisconnect}>Salir</button>
</div>
<div className="preview">
<p>Session: {session?.id ?? 'n/a'}</p>
<p>Token: {session?.token ? `${session.token.substring(0,20)}...` : 'n/a'}</p>
<div style={{ width: 320, height: 240, background: '#111' }}>
{/* placeholder video element - will be replaced by attach() result when track attaches */}
<video ref={videoRef} autoPlay muted playsInline style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
</div>
</div>
)
}
export default StudioConnector

View File

@ -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> = (props) => {
const [editTransmission, setEditTransmission] = useState<Transmission | undefined>(undefined)
const { openStudio, loadingId: launcherLoadingId, error: launcherError } = useStudioLauncher()
const [loadingId, setLoadingId] = useState<string | null>(null)
const [studioSession, setStudioSession] = useState<{ serverUrl?: string; token?: string; room?: string } | null>(null)
const [validating, setValidating] = useState<boolean>(false)
const [connectError, setConnectError] = useState<string | null>(null)
const [currentAttempt, setCurrentAttempt] = useState<Transmission | null>(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> = (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 (
<div className={styles.transmissionsSection}>
@ -172,7 +251,16 @@ const TransmissionsTable: React.FC<Props> = (props) => {
)}
</button>
{launcherError && (
<div style={{ color: 'var(--studio-error-text)', fontSize: 12 }}>{launcherError}</div>
// Mostrar modal claro si el hook de launcher reporta un error
<div style={{ position: 'fixed', inset: 0, zIndex: 12500, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#fff', color: '#111827', padding: 20, borderRadius: 8, maxWidth: 600 }}>
<h3>Error al iniciar el estudio</h3>
<p>{launcherError}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { /* cerrar el error del launcher */ window.location.reload(); }} className="btn">Cerrar</button>
</div>
</div>
</div>
)}
<Dropdown
@ -203,6 +291,42 @@ const TransmissionsTable: React.FC<Props> = (props) => {
onUpdate={onUpdate}
transmission={editTransmission}
/>
{studioSession && (
<div className={styles.studioOverlay} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ width: '95%', maxWidth: 1200, height: '90%', background: 'var(--studio-bg-primary)', borderRadius: 8, overflow: 'hidden', position: 'relative' }}>
<button onClick={() => { setValidating(false); closeStudio(); }} style={{ position: 'absolute', right: 12, top: 12, zIndex: 10100, padding: '8px 12px', borderRadius: 6 }}>Cerrar</button>
<StudioPortal
serverUrl={studioSession.serverUrl || ''}
token={studioSession.token || ''}
roomName={studioSession.room || ''}
onRoomConnected={() => { setValidating(false); /* keep portal open */ }}
onRoomDisconnected={() => { closeStudio(); }}
onRoomConnectError={(err) => { setValidating(false); setConnectError(String(err?.message || err || 'Error al conectar')); }}
/>
</div>
</div>
)}
{validating && (
<div className={styles.validationOverlay} style={{ position: 'fixed', inset: 0, zIndex: 11000, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ background: 'rgba(0,0,0,0.6)', color: '#fff', padding: 16, borderRadius: 8, pointerEvents: 'auto' }}>
Validando token, por favor espera...
</div>
</div>
)}
{connectError && (
<div className={styles.errorModal} style={{ position: 'fixed', inset: 0, zIndex: 12000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ background: '#fff', color: '#111827', padding: 20, borderRadius: 8, maxWidth: 600 }}>
<h3>Error al conectar al estudio</h3>
<p>{connectError}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => { setConnectError(null); setStudioSession(null); setCurrentAttempt(null); }} className="btn">Cerrar</button>
<button onClick={() => { if (currentAttempt) { setConnectError(null); openStudioForTransmission(currentAttempt); } }} className="btn btn-primary">Reintentar</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -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 (
<div className="fixed bottom-4 left-4 right-4">
<ControlGroup className="controls-inner" style={{boxShadow: '0 10px 30px rgba(0,0,0,0.35)'}}>
<div className="control-wrapper">
<IconButton
id={`btn-mic`}
icon={<IconMicOff />}
active={!muted}
title={muted ? 'Activar micrófono' : 'Silenciar'}
onClick={handleToggleMute}
size="sm"
/>
<span id={muteTipId} role="tooltip" className="tooltip">{muted ? 'Activar micrófono' : 'Silenciar'}</span>
</div>
<div className="control-wrapper">
<IconButton
id={`btn-cam`}
icon={<IconCameraOn />}
active={cameraOn}
title={cameraOn ? 'Apagar cámara' : 'Encender cámara'}
onClick={handleToggleCamera}
size="sm"
/>
<span id={camTipId} role="tooltip" className="tooltip">{cameraOn ? 'Apagar cámara' : 'Encender cámara'}</span>
</div>
<div className="control-wrapper">
<ControlButton
id="recBtn"
icon={recording ? <span className="record-dot" /> : undefined}
label={recording ? 'Stop' : 'Start'}
active={recording}
danger={true}
title={recording ? 'Detener grabación' : 'Iniciar grabación'}
onClick={handleToggleRecording}
size="md"
/>
<span id={recTipId} role="tooltip" className="tooltip">{recording ? 'Detener grabación' : 'Iniciar grabación'}</span>
</div>
<span className="visually-hidden" aria-live="polite">{recording ? 'Grabación iniciada' : 'Grabación detenida'}</span>
</ControlGroup>
</div>
)
}

View File

@ -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}

View File

@ -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<string | null>(null);
// Local room management when App does not provide a room prop
const localRoomRef = useRef<Room | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const isExternalRoom = Boolean(room);
// New: tokenFromMessage state and connectError
const [tokenFromMessage, setTokenFromMessage] = useState<string | null>(null);
const [connectError, setConnectError] = useState<string | null>(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 (
<div className="studio-portal">
<aside className="studio-portal__left">
<div className="scenes-header">Escenas</div>
<div className="scenes-list">
<div className="scene-item active">Demo scene 1</div>
<div className="scene-item">Demo scene 2</div>
<div className="scene-item">Intro</div>
</div>
<button className="btn-new-scene">+ Nueva escena</button>
</aside>
<main className="studio-portal__center">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ fontSize: 14 }}>
<strong>LiveKit:</strong> {serverUrlOverride || serverUrl}
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{!isExternalRoom && (
<>
<button className="btn-small" onClick={() => connectWithToken()} disabled={isConnecting || isConnected}>
{isConnecting ? 'Conectando...' : isConnected ? 'Conectado' : 'Conectar'}
</button>
<button className="btn-small" onClick={disconnectLocalRoom} disabled={!isConnected}>
Desconectar
</button>
</>
)}
{isExternalRoom && (
<div style={{ fontSize: 13, color: '#6b7280' }}>Usando Room externo</div>
)}
</div>
</div>
{/* show token status / errors for E2E debugging */}
<div style={{ marginBottom: 8 }}>
{tokenFromMessage ? (
<div style={{ fontSize: 13, color: isConnected ? '#10b981' : '#6b7280' }}>Token recibido desde Broadcast Panel (length {tokenFromMessage.length})</div>
) : (
<div style={{ fontSize: 13, color: '#9ca3af' }}>Esperando token...</div>
)}
{connectError && (
<div style={{ marginTop: 6, color: '#ef4444', fontSize: 13 }} className="studio-error-modal">Error de conexión: {connectError}</div>
)}
</div>
<div className={`preview-wrapper ${activeLayout}`}>
<StudioRoom serverUrl={serverUrl} token={tokenFromMessage || token} roomName={roomName} onConnected={onRoomConnected} onDisconnected={onRoomDisconnected} room={effectiveRoom} />
</div>
<div className="controls-bar">
<div className="layout-presets">
{LAYOUTS.map(l => (
<button
key={l.id}
className={`layout-btn ${activeLayout === l.id ? 'active' : ''}`}
onClick={() => changeLayout(l.id)}
>
{l.label}
</button>
))}
</div>
<div className="actions">
{!live ? (
<button className="btn-record btn-go-live" onClick={handleStartLive}>Ir en vivo</button>
) : (
<button className="btn-stop btn-end-live" onClick={handleStopLive}>Finalizar transmisión</button>
)}
</div>
</div>
</main>
<aside className="studio-portal__right">
<div className="sidebar-section">Comentarios</div>
<div className="sidebar-section">Activos multimedia</div>
<div className="sidebar-section">Estilo</div>
</aside>
</div>
);
}

View File

@ -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; }

View File

@ -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<StudioRoomProps> = ({
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<Room | null>(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<string | null>(null);
const [participantsList, _setParticipantsList] = useState<Array<{ sid: string; identity: string; isLocal?: boolean; accepted?: boolean }>>([]);
const connectedRef = React.useRef(false);
const connectingRef = React.useRef(false);
const previewRef = React.useRef<HTMLDivElement | null>(null);
const [lines, setLines] = useState<Array<{ x1:number,y1:number,x2:number,y2:number, accepted?: boolean }>>([]);
// 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 (
<div className="studio-room studio-theme" data-lk-theme="default">
{connectError && (
<div style={{ padding: 12, background: 'rgba(245, 101, 101, 0.08)', color: '#b91c1c', borderRadius: 6, margin: 12 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Error al conectar a LiveKit</div>
<div style={{ fontSize: 13, marginBottom: 8 }}>{connectError}</div>
<div style={{ fontSize: 13, marginBottom: 8 }}>Server: <code style={{ background:'rgba(0,0,0,0.04)', padding:'2px 6px', borderRadius:4 }}>{serverUrl}</code></div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => connectRoom(token, serverUrl)} style={{ padding: '8px 12px', borderRadius:6, border:'1px solid rgba(0,0,0,0.08)', background:'#fff' }}>Reintentar conexión</button>
<button onClick={() => { try { navigator.clipboard.writeText(token || ''); } catch(e){} }} style={{ padding: '8px 12px', borderRadius:6, border:'1px solid rgba(0,0,0,0.08)', background:'#fff' }}>Copiar token</button>
</div>
</div>
)}
{hasValidRoom ? (
<RoomContext.Provider value={effectiveRoom!}>
<div className="studio-room__header">
<div className="studio-room__title">
<h1>Estudio - {roomName || 'Sin nombre'}</h1>
<div className="studio-room__status">
<div className="studio-room__status-indicator animate-pulse-recording" />
<span>En vivo</span>
</div>
</div>
<div className="studio-room__actions">
<Button variant="secondary" size="sm">
Configuración
</Button>
<Button variant="danger" size="sm">
Finalizar transmisión
</Button>
</div>
</div>
<div className="studio-room__content">
<div ref={previewRef} style={{ position: 'relative', width: '100%', height: '100%' }}>
<VideoConferenceView />
{/* SVG overlay for connection lines */}
<svg className="connections-overlay" style={{ position: 'absolute', left:0, top:0, width: '100%', height: '100%', pointerEvents: 'none' }}>
{lines.map((ln,i)=>(
<line key={i} x1={ln.x1} y1={ln.y1} x2={ln.x2} y2={ln.y2} stroke={ln.accepted ? '#34D399' : '#60A5FA'} strokeWidth={2} strokeOpacity={0.95} />
))}
</svg>
</div>
</div>
<div className="studio-room__controls">
<ControlBar />
</div>
{/* Our BottomControls will consume RoomContext and control mic/cam/recording */}
<BottomControls />
<RoomAudioRenderer />
</RoomContext.Provider>
) : (
<div className="studio-room__connecting" style={{ padding: 20 }}>
<div style={{ fontSize: 16, marginBottom: 8 }}>Conectando al estudio...</div>
<div style={{ fontSize: 13, color: '#6b7280' }}>Esperando a que la sesión se establezca. Si esto tarda demasiado, comprueba el token y conexión a LiveKit.</div>
<div style={{ marginTop: 12 }}>
<button onClick={() => connectRoom(token, serverUrl)} style={{ padding: '8px 12px', borderRadius: 6 }}>Reintentar conexión</button>
</div>
</div>
)}
</div>
);
};
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 (
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#6b7280' }}>
Conectando streams...
</div>
)
}
// 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 (
<GridLayout
tracks={tracks}
className="studio-room__grid"
>
<ParticipantTile />
</GridLayout>
);
}
// 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;

Some files were not shown because too many files have changed in this diff Show More