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:
parent
f5d0051a19
commit
8b458a3ddf
68
.github/workflows/validate-studio-flow-browserless.yml
vendored
Normal file
68
.github/workflows/validate-studio-flow-browserless.yml
vendored
Normal 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 '------------------------------'
|
||||
|
||||
80
.github/workflows/validate-studio-flow.yml
vendored
Normal file
80
.github/workflows/validate-studio-flow.yml
vendored
Normal 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
6
ARCHIVE_STUDIO.md
Normal 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
59
README-E2E.md
Normal 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.
|
||||
|
||||
51
deploy/README-token-server.md
Normal file
51
deploy/README-token-server.md
Normal 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).
|
||||
38
deploy/docker-compose.token.yml
Normal file
38
deploy/docker-compose.token.yml
Normal 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
|
||||
41
deploy/token-server.Dockerfile
Normal file
41
deploy/token-server.Dockerfile
Normal 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"]
|
||||
@ -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
233
docs/E2E-TOKEN-FLOW.md
Normal 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
143
e2e/LOG.md
Normal 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
|
||||
56
e2e/README-validate-flow.md
Normal file
56
e2e/README-validate-flow.md
Normal 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
187
e2e/README.md
Normal 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")
|
||||
149
e2e/analyze_external_page_browserless.js
Normal file
149
e2e/analyze_external_page_browserless.js
Normal 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);
|
||||
})();
|
||||
|
||||
18
e2e/check-browserless-conn.js
Normal file
18
e2e/check-browserless-conn.js
Normal 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
29
e2e/decode_token.js
Normal 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
63
e2e/logging.js
Normal 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
109
e2e/mock_server.js
Normal 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));
|
||||
|
||||
76
e2e/playwright/token-flow.js
Normal file
76
e2e/playwright/token-flow.js
Normal 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
15
e2e/print-log-summary.sh
Executable 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
70
e2e/publish_artifact.js
Normal 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);
|
||||
}
|
||||
|
||||
0
e2e/puppeteer-runner/.env.example
Normal file
0
e2e/puppeteer-runner/.env.example
Normal file
0
e2e/puppeteer-runner/README.md
Normal file
0
e2e/puppeteer-runner/README.md
Normal file
22
e2e/puppeteer-runner/debug-chrome.js
Normal file
22
e2e/puppeteer-runner/debug-chrome.js
Normal 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)
|
||||
}
|
||||
})()
|
||||
22
e2e/puppeteer-runner/debug-elements-screenshot.js
Normal file
22
e2e/puppeteer-runner/debug-elements-screenshot.js
Normal 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) })
|
||||
|
||||
28
e2e/puppeteer-runner/debug-list-elements-local.js
Normal file
28
e2e/puppeteer-runner/debug-list-elements-local.js
Normal 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) })
|
||||
|
||||
27
e2e/puppeteer-runner/debug-list-elements.js
Normal file
27
e2e/puppeteer-runner/debug-list-elements.js
Normal 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) })
|
||||
|
||||
172
e2e/puppeteer-runner/debug-post-token.js
Normal file
172
e2e/puppeteer-runner/debug-post-token.js
Normal 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) })
|
||||
29
e2e/puppeteer-runner/debug-run.js
Normal file
29
e2e/puppeteer-runner/debug-run.js
Normal 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)
|
||||
}
|
||||
})()
|
||||
|
||||
0
e2e/puppeteer-runner/index.ts
Normal file
0
e2e/puppeteer-runner/index.ts
Normal file
23
e2e/puppeteer-runner/package.json
Normal file
23
e2e/puppeteer-runner/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
e2e/puppeteer-runner/save-candidates.js
Normal file
20
e2e/puppeteer-runner/save-candidates.js
Normal 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) })
|
||||
|
||||
0
e2e/puppeteer-runner/send-token-direct.js
Normal file
0
e2e/puppeteer-runner/send-token-direct.js
Normal file
234
e2e/puppeteer-runner/send-token-to-studio.js
Normal file
234
e2e/puppeteer-runner/send-token-to-studio.js
Normal 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) })
|
||||
9
e2e/puppeteer-runner/ws-test.js
Normal file
9
e2e/puppeteer-runner/ws-test.js
Normal 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
121
e2e/run_e2e_with_mock.js
Normal 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);
|
||||
});
|
||||
});
|
||||
})();
|
||||
72
e2e/simulate_token_query_browserless.js
Normal file
72
e2e/simulate_token_query_browserless.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
194
e2e/streamyard-flow-browserless.js
Normal file
194
e2e/streamyard-flow-browserless.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
99
e2e/streamyard-flow-remote.js
Normal file
99
e2e/streamyard-flow-remote.js
Normal 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
98
e2e/streamyard-flow.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
12
e2e/test-pages/broadcast.html
Normal file
12
e2e/test-pages/broadcast.html
Normal 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>
|
||||
|
||||
159
e2e/validate-flow-browserless.js
Normal file
159
e2e/validate-flow-browserless.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
162
e2e/validate-flow-domains-local.js
Normal file
162
e2e/validate-flow-domains-local.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
198
e2e/validate-flow-remote-chrome.js
Normal file
198
e2e/validate-flow-remote-chrome.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
69
e2e/validate-session-id-flow.js
Normal file
69
e2e/validate-session-id-flow.js
Normal 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
61
e2e/ws-test.js
Normal 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
3994
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -2,9 +2,18 @@
|
||||
"name": "avanzacast-monorepo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "AvanzaCast - Plataforma de Streaming Modular",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
"packages/admin-panel",
|
||||
"packages/avanza-ui",
|
||||
"packages/backend-api",
|
||||
"packages/broadcast-panel",
|
||||
"packages/e2e",
|
||||
"packages/landing-page",
|
||||
"packages/shared-components",
|
||||
"packages/studio-panel-deprecated",
|
||||
"packages/vristo-react-main",
|
||||
"shared/*"
|
||||
],
|
||||
"scripts": {
|
||||
@ -12,14 +21,12 @@
|
||||
"dev:landing": "npm run dev --workspace=packages/landing-page",
|
||||
"dev:api": "npm run dev --workspace=packages/backend-api",
|
||||
"dev:studio": "npm run dev --workspace=packages/broadcast-studio",
|
||||
"dev:studio-panel": "npm run dev --workspace=packages/studio-panel",
|
||||
"dev:broadcast-panel": "npm run dev --workspace=packages/broadcast-panel",
|
||||
"dev:admin": "npm run dev --workspace=packages/admin-panel",
|
||||
"build": "npm run build --workspaces",
|
||||
"build:landing": "npm run build --workspace=packages/landing-page",
|
||||
"build:api": "npm run build --workspace=packages/backend-api",
|
||||
"build:studio": "npm run build --workspace=packages/broadcast-studio",
|
||||
"build:studio-panel": "npm run build --workspace=packages/studio-panel",
|
||||
"build:broadcast-panel": "npm run build --workspace=packages/broadcast-panel",
|
||||
"build:admin": "npm run build --workspace=packages/admin-panel",
|
||||
"clean": "rm -rf packages/*/node_modules packages/*/dist shared/*/node_modules node_modules",
|
||||
@ -37,6 +44,8 @@
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^19.11.1",
|
||||
"puppeteer-core": "^24.30.0",
|
||||
"react-icons": "^5.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
|
||||
123
packages/backend-api/README.md
Normal file
123
packages/backend-api/README.md
Normal 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.
|
||||
35
packages/backend-api/docker-compose.yml
Normal file
35
packages/backend-api/docker-compose.yml
Normal 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
|
||||
18
packages/backend-api/docker-entrypoint.sh
Executable file
18
packages/backend-api/docker-entrypoint.sh
Executable 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
|
||||
|
||||
@ -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"
|
||||
|
||||
80
packages/backend-api/prisma/schema.prisma
Normal file
80
packages/backend-api/prisma/schema.prisma
Normal 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?
|
||||
}
|
||||
92
packages/backend-api/scripts/get_session_token.js
Normal file
92
packages/backend-api/scripts/get_session_token.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
51
packages/backend-api/scripts/request_internal_session.js
Normal file
51
packages/backend-api/scripts/request_internal_session.js
Normal 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();
|
||||
|
||||
74
packages/backend-api/scripts/test-token-flow.js
Normal file
74
packages/backend-api/scripts/test-token-flow.js
Normal 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();
|
||||
|
||||
64
packages/backend-api/scripts/test_generate_token.cjs
Normal file
64
packages/backend-api/scripts/test_generate_token.cjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
64
packages/backend-api/scripts/test_generate_token.js
Normal file
64
packages/backend-api/scripts/test_generate_token.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
72
packages/backend-api/scripts/test_prisma_session.js
Normal file
72
packages/backend-api/scripts/test_prisma_session.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
@ -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) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
82
packages/broadcast-panel/README-NGINX.md
Normal file
82
packages/broadcast-panel/README-NGINX.md
Normal 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.
|
||||
|
||||
|
||||
38
packages/broadcast-panel/e2e/README.md
Normal file
38
packages/broadcast-panel/e2e/README.md
Normal 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`.
|
||||
|
||||
84
packages/broadcast-panel/e2e/browserless_connect.mjs
Normal file
84
packages/broadcast-panel/e2e/browserless_connect.mjs
Normal 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;
|
||||
}
|
||||
})();
|
||||
|
||||
122
packages/broadcast-panel/e2e/dify-plugin-playwright.mjs
Normal file
122
packages/broadcast-panel/e2e/dify-plugin-playwright.mjs
Normal 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 };
|
||||
113
packages/broadcast-panel/e2e/gemini_agent_server.py
Normal file
113
packages/broadcast-panel/e2e/gemini_agent_server.py
Normal 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))
|
||||
|
||||
272
packages/broadcast-panel/e2e/gemini_log_agent.py
Normal file
272
packages/broadcast-panel/e2e/gemini_log_agent.py
Normal 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)
|
||||
|
||||
109
packages/broadcast-panel/e2e/playwright_connect.mjs
Normal file
109
packages/broadcast-panel/e2e/playwright_connect.mjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
216
packages/broadcast-panel/e2e/playwright_py_runner.py
Normal file
216
packages/broadcast-panel/e2e/playwright_py_runner.py
Normal 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)
|
||||
208
packages/broadcast-panel/e2e/puppeteer_browserless_e2e.cjs
Normal file
208
packages/broadcast-panel/e2e/puppeteer_browserless_e2e.cjs
Normal 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)
|
||||
}
|
||||
})()
|
||||
69
packages/broadcast-panel/e2e/puppeteer_browserless_e2e.js
Normal file
69
packages/broadcast-panel/e2e/puppeteer_browserless_e2e.js
Normal 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)
|
||||
}
|
||||
})()
|
||||
|
||||
116
packages/broadcast-panel/e2e/puppeteer_connect_debug.mjs
Normal file
116
packages/broadcast-panel/e2e/puppeteer_connect_debug.mjs
Normal 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);
|
||||
});
|
||||
|
||||
49
packages/broadcast-panel/e2e/puppeteer_local_debug.cjs
Normal file
49
packages/broadcast-panel/e2e/puppeteer_local_debug.cjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
211
packages/broadcast-panel/e2e/run_browserless_e2e.js
Normal file
211
packages/broadcast-panel/e2e/run_browserless_e2e.js
Normal 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); });
|
||||
363
packages/broadcast-panel/e2e/run_local_e2e.js
Normal file
363
packages/broadcast-panel/e2e/run_local_e2e.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
52
packages/broadcast-panel/e2e/run_studio_integration.sh
Normal file
52
packages/broadcast-panel/e2e/run_studio_integration.sh
Normal 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
|
||||
1
packages/broadcast-panel/e2e/smoke_test.py
Normal file
1
packages/broadcast-panel/e2e/smoke_test.py
Normal file
@ -0,0 +1 @@
|
||||
|
||||
28
packages/broadcast-panel/e2e/start-chrome-remote.sh
Normal file
28
packages/broadcast-panel/e2e/start-chrome-remote.sh
Normal 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
|
||||
|
||||
57
packages/broadcast-panel/e2e/ws_connect.mjs
Normal file
57
packages/broadcast-panel/e2e/ws_connect.mjs
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
88
packages/broadcast-panel/scripts/browser_e2e_local.cjs
Normal file
88
packages/broadcast-panel/scripts/browser_e2e_local.cjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
126
packages/broadcast-panel/scripts/browserless_e2e.cjs
Normal file
126
packages/broadcast-panel/scripts/browserless_e2e.cjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
18
packages/broadcast-panel/scripts/browserless_test.cjs
Normal file
18
packages/broadcast-panel/scripts/browserless_test.cjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
|
||||
120
packages/broadcast-panel/scripts/e2e_mock_ui.cjs
Normal file
120
packages/broadcast-panel/scripts/e2e_mock_ui.cjs
Normal 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);
|
||||
}
|
||||
})();
|
||||
@ -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);
|
||||
}
|
||||
})();
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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>
|
||||
|
||||
162
packages/broadcast-panel/src/components/StudioConnector.tsx
Normal file
162
packages/broadcast-panel/src/components/StudioConnector.tsx
Normal 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
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
171
packages/broadcast-panel/src/features/studio/BottomControls.tsx
Normal file
171
packages/broadcast-panel/src/features/studio/BottomControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
219
packages/broadcast-panel/src/features/studio/StudioPortal.tsx
Normal file
219
packages/broadcast-panel/src/features/studio/StudioPortal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
373
packages/broadcast-panel/src/features/studio/StudioRoom.tsx
Normal file
373
packages/broadcast-panel/src/features/studio/StudioRoom.tsx
Normal 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
Loading…
x
Reference in New Issue
Block a user