feat: update studio-panel and broadcast-panel configurations

- Changed development port for studio-panel from 3001 to 3020 in package.json and vite.config.ts.
- Refactored Studio component in studio-panel to improve token handling and error diagnostics.
- Added utility functions for token validation and JWT decoding in Studio component.
- Enhanced error handling and user feedback in Studio component when token is invalid.
- Implemented sessionStorage management for token and server URL in Studio component.
- Created Docker setup for broadcast-panel including Dockerfile, Dockerfile.dev, and docker-compose.yml.
- Added Nginx configuration for serving the broadcast-panel as a Single Page Application.
- Introduced Banner component in broadcast-panel for displaying messages and actions.
- Added start-docker.sh script for easy Docker management of broadcast-panel.
- Implemented Playwright E2E tests for token handling and UI interactions between broadcast-panel and studio-panel.
- Included SSL certificates for local development in studio-panel.
This commit is contained in:
Cesar Mendivil 2025-11-06 23:15:23 -07:00
parent 0ca2b36b5c
commit 543d6bc6af
24 changed files with 955 additions and 65 deletions

72
package-lock.json generated
View File

@ -16,6 +16,7 @@
},
"devDependencies": {
"concurrently": "^8.2.2",
"playwright": "^1.38.0",
"typescript": "^5.2.2"
},
"engines": {
@ -1285,6 +1286,50 @@
"node": ">=18"
}
},
"node_modules/@playwright/test/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@playwright/test/node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"dependencies": {
"playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/@playwright/test/node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@prisma/client": {
"version": "5.22.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
@ -7538,33 +7583,33 @@
}
},
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"version": "1.38.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.38.0.tgz",
"integrity": "sha512-fJGw+HO0YY+fU/F1N57DMO+TmXHTrmr905J05zwAQE9xkuwP/QLDk63rVhmyxh03dYnEhnRbsdbH9B0UVVRB3A==",
"dev": true,
"dependencies": {
"playwright-core": "1.56.1"
"playwright-core": "1.38.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"version": "1.38.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.38.0.tgz",
"integrity": "sha512-f8z1y8J9zvmHoEhKgspmCvOExF2XdcxMW8jNRuX4vkQFrzV4MlZ55iwb5QeyiFQgOFCUolXiRHgpjSEnqvO48g==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
"node": ">=16"
}
},
"node_modules/playwright/node_modules/fsevents": {
@ -11852,10 +11897,17 @@
"@livekit/components-styles": "^1.1.6",
"livekit-client": "^2.15.14",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"tailwindcss": "^3.4.16",
"typescript": "^5.0.2",
"vite": "^7.2.0"
}
},

View File

@ -29,6 +29,7 @@
},
"devDependencies": {
"concurrently": "^8.2.2",
"playwright": "^1.38.0",
"typescript": "^5.2.2"
},
"engines": {

View File

@ -0,0 +1,11 @@
node_modules
dist
.git
.gitignore
*.md
.env.local
.env.*.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store

View File

@ -0,0 +1,7 @@
# LiveKit Configuration
VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host
VITE_TOKEN_SERVER_URL=http://localhost:3010
# Application Configuration
VITE_APP_NAME=AvanzaCast Broadcast Panel
VITE_APP_VERSION=1.0.0

View File

@ -0,0 +1,201 @@
# 🐳 Docker Setup - Broadcast Panel
## Archivos Docker Creados
- **Dockerfile**: Imagen de producción con Nginx
- **Dockerfile.dev**: Imagen de desarrollo con Vite HMR
- **docker-compose.yml**: Orquestación de contenedores
- **nginx.conf**: Configuración de Nginx para SPA
- **.dockerignore**: Archivos excluidos del build
- **start-docker.sh**: Script de inicio rápido
## 🚀 Inicio Rápido
### Modo Desarrollo (Recomendado para trabajar)
```bash
cd packages/broadcast-panel
chmod +x start-docker.sh
./start-docker.sh dev
```
Accede a: **http://localhost:5173**
### Modo Producción
```bash
./start-docker.sh prod
```
Accede a: **http://localhost:8080**
## 📦 Comandos Manuales
### Desarrollo
```bash
# Build
docker-compose build broadcast-panel-dev
# Iniciar
docker-compose up broadcast-panel-dev
# Iniciar en background
docker-compose up -d broadcast-panel-dev
# Ver logs
docker-compose logs -f broadcast-panel-dev
# Detener
docker-compose down
```
### Producción
```bash
# Build
docker-compose --profile production build broadcast-panel-prod
# Iniciar
docker-compose --profile production up broadcast-panel-prod
# Iniciar en background
docker-compose --profile production up -d broadcast-panel-prod
```
## 🔧 Configuración
### Variables de Entorno
Copia `.env.example` a `.env` y ajusta las variables:
```env
VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host
VITE_TOKEN_SERVER_URL=http://localhost:3010
```
### Puertos
- **5173**: Vite Dev Server (desarrollo)
- **8080**: Nginx (producción)
## 🌐 Conexión con LiveKit
El broadcast-panel se conectará a:
1. **Servidor LiveKit**: `wss://livekit-server.bfzqqk.easypanel.host`
2. **Token Server**: `http://localhost:3010` (Docker en tu máquina)
### Token Server
Asegúrate de que el token server esté corriendo:
```bash
cd packages/backend-api
docker-compose up -d
```
O si ya está corriendo con Docker, verifica:
```bash
docker ps | grep token-server
curl http://localhost:3010/api/token?room=test&username=demo
```
## 🔄 Hot Reload (Modo Desarrollo)
Los cambios en estos archivos se reflejan automáticamente:
- `src/**/*`
- `public/**/*`
- `index.html`
- `vite.config.ts`
- `tsconfig.json`
## 🐛 Troubleshooting
### Error: No se puede conectar al token server
```bash
# Verifica que el token server esté corriendo
docker ps
# Verifica la URL en .env
cat .env | grep TOKEN_SERVER
```
### Error: No se puede conectar a LiveKit
```bash
# Verifica la URL de LiveKit en .env
cat .env | grep LIVEKIT
# Prueba la conexión
curl -I https://livekit-server.bfzqqk.easypanel.host
```
### Rebuild completo
```bash
# Detener y eliminar contenedores
docker-compose down
# Eliminar imágenes
docker-compose rm -f
# Rebuild desde cero
docker-compose build --no-cache broadcast-panel-dev
docker-compose up broadcast-panel-dev
```
## 📊 Logs
```bash
# Ver logs en tiempo real
docker-compose logs -f broadcast-panel-dev
# Ver últimas 100 líneas
docker-compose logs --tail=100 broadcast-panel-dev
```
## 🧹 Limpieza
```bash
# Detener contenedores
docker-compose down
# Eliminar volúmenes (cuidado, borra datos)
docker-compose down -v
# Eliminar imágenes
docker rmi avanzacast-broadcast-panel-dev
docker rmi avanzacast-broadcast-panel-prod
```
## 🔗 Integración con Studio-Panel
Para que **studio-panel** se conecte con **broadcast-panel**:
1. Inicia broadcast-panel en Docker:
```bash
./start-docker.sh dev
```
2. En studio-panel, asegúrate de que esté en modo real (no demo):
- El token server debe estar accesible en `localhost:3010`
- Studio-panel intentará obtener un token real
- Se conectará a LiveKit
3. Abre ambos:
- Broadcast Panel: http://localhost:5173
- Studio Panel: http://localhost:3001
4. Desde broadcast-panel, crea una sala y genera un link de invitación
5. Abre ese link en studio-panel para unirte a la sala
## 🎯 Siguiente Paso
Una vez que broadcast-panel esté corriendo en Docker, podrás:
1. ✅ Crear salas en broadcast-panel
2. ✅ Generar tokens de invitación
3. ✅ Conectar studio-panel a esas salas
4. ✅ Ver video/audio en tiempo real entre ambos
5. ✅ Probar todas las funcionalidades de LiveKit
¡Disfruta tu streaming en vivo! 🎥

View File

@ -0,0 +1,30 @@
# Dockerfile para Broadcast Panel
FROM node:20-alpine AS builder
WORKDIR /app
# Copiar package files
COPY package*.json ./
# Instalar dependencias
RUN npm install
# Copiar código fuente
COPY . .
# Build
RUN npm run build
# Etapa de producción con nginx
FROM nginx:alpine
# Copiar build a nginx
COPY --from=builder /app/dist /usr/share/nginx/html
# Copiar configuración de nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Exponer puerto
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,25 @@
# Dockerfile de desarrollo para Broadcast Panel
FROM node:20-alpine
WORKDIR /app
# Instalar dependencias del sistema
RUN apk add --no-cache git
# Copiar tsconfig.json base desde la raíz del monorepo
COPY tsconfig.json /tsconfig.json
# Copiar shared folder (necesario para @shared alias)
COPY shared /shared
# Copiar package files
COPY packages/broadcast-panel/package*.json ./
# Instalar dependencias
RUN npm install
# Exponer puerto de Vite
EXPOSE 5173
# Comando para desarrollo
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@ -0,0 +1,48 @@
services:
# Broadcast Panel en modo desarrollo
broadcast-panel-dev:
build:
context: ../..
dockerfile: packages/broadcast-panel/Dockerfile.dev
container_name: avanzacast-broadcast-panel-dev
ports:
- "5173:5173"
volumes:
# Montar código fuente para hot reload
- ../../packages/broadcast-panel/src:/app/src:delegated
- ../../packages/broadcast-panel/public:/app/public:delegated
- ../../packages/broadcast-panel/index.html:/app/index.html:delegated
- ../../packages/broadcast-panel/vite.config.ts:/app/vite.config.ts:delegated
- ../../packages/broadcast-panel/tsconfig.json:/app/tsconfig.json:delegated
# Montar shared folder
- ../../shared:/shared:delegated
# (NO montar node_modules aquí; usar los node_modules instalados en la imagen)
environment:
- NODE_ENV=development
- DOCKER_ENV=true
- VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host
- VITE_TOKEN_SERVER_URL=http://localhost:3010
networks:
- avanzacast-network
restart: unless-stopped
# Broadcast Panel en modo producción
broadcast-panel-prod:
build:
context: .
dockerfile: Dockerfile
container_name: avanzacast-broadcast-panel-prod
ports:
- "8080:80"
environment:
- VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host
- VITE_TOKEN_SERVER_URL=http://localhost:3010
networks:
- avanzacast-network
restart: unless-stopped
profiles:
- production
networks:
avanzacast-network:
driver: bridge

View File

@ -0,0 +1,34 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Configuración para SPA (Single Page Application)
location / {
try_files $uri $uri/ /index.html;
}
# Headers de seguridad
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# Cache para assets estáticos
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# No cache para HTML
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
}
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
}

View File

@ -13,10 +13,17 @@
"@livekit/components-styles": "^1.1.6",
"livekit-client": "^2.15.14",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.24",
"tailwindcss": "^3.4.16",
"typescript": "^5.0.2",
"vite": "^7.2.0"
}
}

View File

@ -0,0 +1,42 @@
import React from 'react'
interface BannerProps {
children: React.ReactNode
type?: 'info' | 'error' | 'warning'
onClose?: () => void
actionLabel?: string
onAction?: () => void
actionDisabled?: boolean
}
const Banner: React.FC<BannerProps> = ({ children, type = 'info', onClose, actionLabel, onAction, actionDisabled = false }) => {
const bg = type === 'error' ? 'bg-red-700' : type === 'warning' ? 'bg-amber-500' : 'bg-sky-600'
const icon = type === 'error' ? (
<svg className="w-5 h-5 mr-3 text-white/90" viewBox="0 0 24 24" fill="none" stroke="currentColor"><path strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"></path><path strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01"></path></svg>
) : null
return (
<div className={`${bg} text-white px-4 py-3 rounded-md mb-4 shadow-sm flex items-center justify-between`}>
<div className="flex items-center">
{icon}
<div className="text-sm font-medium">{children}</div>
</div>
<div className="flex items-center gap-3">
{actionLabel && (
<button
onClick={onAction}
disabled={actionDisabled}
className={`px-3 py-1 rounded-md text-sm font-semibold ${actionDisabled ? 'bg-white/20 text-white/60 cursor-not-allowed' : 'bg-white/10 hover:bg-white/20 text-white'}`}
>
{actionLabel}
</button>
)}
{onClose && (
<button onClick={onClose} className="text-white/80 underline ml-2 text-sm">Cerrar</button>
)}
</div>
</div>
)
}
export default Banner

View File

@ -201,3 +201,23 @@
font-size: 13px;
}
}
/* Spinner used in the Entrar button */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
background: rgba(0,0,0,0.8);
color: #fff;
padding: 10px 14px;
border-radius: 8px;
z-index: 9999;
box-shadow: 0 6px 20px rgba(2,6,23,0.2);
}
.toast.error { background: rgba(160, 40, 40, 0.95); }
.toast.info { background: rgba(30, 120, 255, 0.95); }

View File

@ -29,6 +29,7 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
const [inviteLink, setInviteLink] = useState<string | undefined>(undefined)
const [editOpen, setEditOpen] = useState(false)
const [editTransmission, setEditTransmission] = useState<Transmission | undefined>(undefined)
const [loadingId, setLoadingId] = useState<string | null>(null)
const handleEdit = (t: Transmission) => {
setEditTransmission(t)
@ -50,6 +51,33 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
}
})
const openStudioForTransmission = async (t: Transmission) => {
if (loadingId) return
setLoadingId(t.id)
try {
const userRaw = localStorage.getItem('avanzacast_user') || 'Demo User'
const user = encodeURIComponent(userRaw)
const room = encodeURIComponent(t.id || 'avanzacast-studio')
const tokenRes = await fetch(`http://localhost:3010/api/token?room=${room}&username=${user}`)
if (!tokenRes.ok) throw new Error('No se pudo obtener token')
const tokenData = await tokenRes.json()
// Guardar token, serverUrl, room, user en sessionStorage
sessionStorage.setItem('avanzacast_studio_token', tokenData.token)
sessionStorage.setItem('avanzacast_studio_serverUrl', tokenData.serverUrl)
sessionStorage.setItem('avanzacast_studio_room', decodeURIComponent(room))
sessionStorage.setItem('avanzacast_studio_user', decodeURIComponent(user))
// Redirigir a studio-panel en la misma pestaña
const shortId = Math.random().toString(36).slice(2, 10)
window.location.href = `http://localhost:3020/${shortId}`
} catch (err) {
console.error('Error entrando al estudio', err)
alert('No fue posible entrar al estudio. Revisa el servidor de tokens.')
setLoadingId(null)
}
}
if (isLoading) {
return (
<div className={styles.transmissionsSection}>
@ -131,8 +159,22 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
</td>
<td className={styles.tableCell} style={{ textAlign: 'right' }}>
<div className={styles.actionsCell}>
<button aria-label={`Entrar al estudio ${t.title}`} className={styles.enterStudioButton} onClick={() => {/* enter studio logic placeholder */}}>
Entrar al estudio
<button
aria-label={`Entrar al estudio ${t.title}`}
className={styles.enterStudioButton}
disabled={loadingId !== null}
onClick={() => openStudioForTransmission(t)}
>
{loadingId === t.id ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<svg width="16" height="16" viewBox="0 0 50 50" style={{ animation: 'spin 1s linear infinite' }}>
<circle cx="25" cy="25" r="20" fill="none" stroke="#fff" strokeWidth="5" strokeLinecap="round" strokeDasharray="31.4 31.4" />
</svg>
Entrando...
</span>
) : (
'Entrar al estudio'
)}
</button>
<Dropdown
@ -146,7 +188,7 @@ const TransmissionsTable: React.FC<Props> = ({ transmissions, onDelete, onUpdate
{ label: 'Eliminar transmisión', icon: <MdDelete size={16} />, onClick: () => onDelete(t.id), containerProps: { className: styles.deleteItem }, labelProps: { className: styles.dangerLabel } }
]}
/>
<InviteGuestsModal open={inviteOpen} onClose={() => setInviteOpen(false)} link={inviteLink} />
<InviteGuestsModal open={inviteOpen} onClose={() => setInviteOpen(false)} link={inviteLink || ''} />
</div>
</td>
</tr>

View File

@ -0,0 +1,35 @@
#!/bin/bash
# Script para iniciar Broadcast Panel con Docker
set -e
echo "🚀 Iniciando AvanzaCast Broadcast Panel..."
# Verificar si existe .env
if [ ! -f .env ]; then
echo "📝 Creando archivo .env desde .env.example..."
cp .env.example .env
fi
# Modo de ejecución (dev o prod)
MODE=${1:-dev}
if [ "$MODE" = "dev" ]; then
echo "🔧 Modo: DESARROLLO"
echo "📦 Construyendo imagen de desarrollo..."
docker-compose build broadcast-panel-dev
echo "🎬 Iniciando contenedor en modo desarrollo..."
docker-compose up broadcast-panel-dev
elif [ "$MODE" = "prod" ]; then
echo "🏭 Modo: PRODUCCIÓN"
echo "📦 Construyendo imagen de producción..."
docker-compose --profile production build broadcast-panel-prod
echo "🎬 Iniciando contenedor en modo producción..."
docker-compose --profile production up broadcast-panel-prod
else
echo "❌ Modo no válido. Usa: ./start-docker.sh [dev|prod]"
exit 1
fi

View File

@ -0,0 +1,15 @@
> broadcast-panel@0.1.0 dev
> vite
Port 5173 is in use, trying another one...
Port 5174 is in use, trying another one...
VITE v7.2.0 ready in 1978 ms
➜ Local: http://localhost:5175/
➜ Network: http://192.168.1.19:5175/
➜ Network: http://192.168.1.17:5175/
➜ Network: http://172.18.0.1:5175/
➜ Network: http://172.19.0.1:5175/
➜ press h + enter to show help

View File

@ -5,13 +5,50 @@ import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
include: [
'react-icons',
'react-icons/si',
'react-icons/md',
'react-icons/fa',
'react-icons/fa6',
'react-icons/bs'
]
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../../shared')
'@shared': process.env.DOCKER_ENV ? '/shared' : path.resolve(__dirname, '../../shared'),
'@avanzacast/shared-hooks': process.env.DOCKER_ENV ? '/shared/hooks' : path.resolve(__dirname, '../../shared/hooks'),
'@avanzacast/shared-utils': process.env.DOCKER_ENV ? '/shared/utils' : path.resolve(__dirname, '../../shared/utils'),
'@avanzacast/shared-types': process.env.DOCKER_ENV ? '/shared/types' : path.resolve(__dirname, '../../shared/types'),
'@avanzacast/shared-config': process.env.DOCKER_ENV ? '/shared/config' : path.resolve(__dirname, '../../shared/config')
,
// Ensure react-icons subpackages imported from /shared resolve to the
// node_modules installed in the image.
'react-icons': process.env.DOCKER_ENV ? '/app/node_modules/react-icons' : 'react-icons',
'react-icons/si': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/si' : 'react-icons/si',
'react-icons/md': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/md' : 'react-icons/md',
'react-icons/fa': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/fa' : 'react-icons/fa',
'react-icons/fa6': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/fa6' : 'react-icons/fa6',
'react-icons/bs': process.env.DOCKER_ENV ? '/app/node_modules/react-icons/bs' : 'react-icons/bs'
}
},
server: {
port: 5173
port: 5173,
host: true,
fs: {
// Allow serving files from the shared folder when mounted in Docker
allow: [
path.resolve(__dirname),
process.env.DOCKER_ENV ? '/shared' : path.resolve(__dirname, '../../shared')
]
,
// Disable strict fs checking so imports from outside project root work
strict: false
},
watch: {
usePolling: true
}
}
})

View File

@ -5,8 +5,8 @@
"description": "AvanzaCast - Broadcast Studio",
"type": "module",
"scripts": {
"dev": "vite --port 3001",
"dev:vite": "vite --port 3001",
"dev": "vite --port 3020",
"dev:vite": "vite --port 3020",
"dev:server": "node server.js",
"dev:full": "concurrently \"npm run dev:vite\" \"npm run dev:server\"",
"build": "vite build",

View File

@ -6,7 +6,7 @@ import StudioLeftSidebar from './StudioLeftSidebar'
import StudioVideoArea from './StudioVideoArea'
import StudioRightPanel from './StudioRightPanel'
import StudioControls from './StudioControls'
import { DEMO_MODE, DEMO_TOKEN } from '../config/demo'
import { DEMO_TOKEN } from '../config/demo'
interface StudioProps {
userName: string
@ -16,39 +16,126 @@ interface StudioProps {
const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
const [token, setToken] = useState<string>(DEMO_TOKEN)
const [isConnecting, setIsConnecting] = useState(true)
const [error, setError] = useState<string>('')
const [isDemoMode, setIsDemoMode] = useState(true) // Iniciar en modo demo por defecto
useEffect(() => {
const fetchToken = async () => {
try {
const response = await fetch(`http://localhost:3010/api/token?room=${roomName}&username=${userName}`, {
signal: AbortSignal.timeout(2000) // Timeout de 2 segundos
})
if (!response.ok) {
throw new Error('Error al obtener el token')
// Utility: heurística ligera para validar token en cliente
const isTokenLikelyValid = (t?: string) => {
if (!t) return false
// los tokens de LiveKit suelen ser JWT (contienen '.') o bastante largos
if (t.includes('.')) return true
if (t.length > 80) return true
return false
}
const data = await response.json()
setToken(data.token)
setIsDemoMode(false)
} catch (err) {
console.error('Error getting token:', err)
const decodeJwt = (t: string) => {
try {
const parts = t.split('.')
if (parts.length < 2) return null
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=')
const json = JSON.parse(atob(padded))
return json
} catch (e) {
return null
}
}
// Mantener modo demo si no se puede conectar al servidor
console.log('⚠️ No se pudo conectar al servidor de tokens. Usando modo DEMO...')
// Log decoded token claims for diagnostic when token changes
useEffect(() => {
if (!token) return
const claims = decodeJwt(token)
if (claims) {
console.log('[Studio] token claims:', claims)
} else {
console.log('[Studio] token does not look like JWT or could not be decoded')
}
}, [token])
useEffect(() => {
// Leer token y datos desde sessionStorage (guardado por broadcast-panel)
try {
const storedToken = sessionStorage.getItem('avanzacast_studio_token')
const storedServerUrl = sessionStorage.getItem('avanzacast_studio_serverUrl')
const storedRoom = sessionStorage.getItem('avanzacast_studio_room')
const storedUser = sessionStorage.getItem('avanzacast_studio_user')
console.log('[Studio] sessionStorage:', { storedToken: storedToken ? `${storedToken.length} chars` : 'none', storedServerUrl, storedRoom, storedUser })
if (storedToken && storedServerUrl) {
setToken(storedToken)
setIsDemoMode(false)
} else {
console.log('⚠️ No se encontró token en sessionStorage. Usando modo DEMO...')
setToken(DEMO_TOKEN)
setIsDemoMode(true)
}
} catch (err) {
console.error('Error leyendo sessionStorage:', err)
setToken(DEMO_TOKEN)
setIsDemoMode(true)
setError('')
} finally {
setIsConnecting(false)
}
}
fetchToken()
}, [roomName, userName])
// Dev-only: wrap window.WebSocket once to capture close codes/reasons/messages
useEffect(() => {
try {
const w = window as any
if (w && !w.__AVZ_WS_WRAPPED) {
const OriginalWS = w.WebSocket
function WrappedWebSocket(url: string, protocols?: string | string[]) {
// @ts-ignore
const sock = protocols ? new OriginalWS(url, protocols) : new OriginalWS(url)
try {
sock.addEventListener('open', (ev: Event) => console.log('[WS Monitor] open', { url, type: (ev && (ev as any).type) || 'open' }))
sock.addEventListener('close', (ev: any) => console.log('[WS Monitor] close', { url, code: ev.code, reason: ev.reason, wasClean: ev.wasClean }))
sock.addEventListener('error', (ev: any) => console.error('[WS Monitor] error', { url, ev }))
sock.addEventListener('message', (ev: any) => {
// don't log binary payloads
try {
const data = typeof ev.data === 'string' ? ev.data : '[binary]'
// limit length
console.log('[WS Monitor] message', { url, data: data && data.slice ? data.slice(0, 200) : data })
} catch (e) {
// ignore
}
})
} catch (e) {
// ignore
}
return sock
}
WrappedWebSocket.prototype = OriginalWS.prototype
w.WebSocket = WrappedWebSocket
w.__AVZ_WS_WRAPPED = true
console.log('[Studio] WebSocket wrapped for debug: will log open/close/error/messages')
}
} catch (e) {
// ignore failures in exotic environments
}
}, [])
// Global error capture to help debugging connection failures
useEffect(() => {
const onErr = (ev: ErrorEvent) => console.error('[Studio][global error]', ev.message, ev.error)
const onReject = (ev: PromiseRejectionEvent) => console.error('[Studio][unhandledrejection]', ev.reason)
window.addEventListener('error', onErr)
window.addEventListener('unhandledrejection', onReject)
return () => {
window.removeEventListener('error', onErr)
window.removeEventListener('unhandledrejection', onReject)
}
}, [])
// Development-only WebSocket monitor: wrap window.WebSocket to log events for debugging
// NOTE: development WebSocket monitor removed to keep hooks stable during HMR
useEffect(() => {
console.log('[Studio] rendering LiveKitRoom, token_len=', token ? token.length : 0)
return () => console.log('[Studio] unmount LiveKitRoom')
}, [token])
if (isConnecting) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
@ -60,38 +147,66 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
)
}
if (error) {
// Heurística para no montar LiveKitRoom con tokens de demo o cortos
const serverUrl = import.meta.env.VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
const hasValidToken = isTokenLikelyValid(token)
if (!hasValidToken) {
// Mostrar diagnóstico más útil cuando el token es claramente inválido/corto
const preview = token ? `${token.slice(0, 6)}...${token.slice(-6)}` : 'none'
console.log('[Studio] token not valid, will not mount LiveKitRoom. preview=', preview, ' len=', token ? token.length : 0)
const broadcastUrl = import.meta.env.VITE_BROADCAST_URL || 'http://localhost:5175'
const goBackToBroadcast = () => {
// Limpiar sessionStorage
try {
sessionStorage.removeItem('avanzacast_studio_token')
sessionStorage.removeItem('avanzacast_studio_serverUrl')
sessionStorage.removeItem('avanzacast_studio_room')
sessionStorage.removeItem('avanzacast_studio_user')
} catch (e) {}
// Intentar volver atrás en el historial si hay entrada previa
if (window.history.length > 1) {
window.history.back()
} else {
// Redirigir al broadcast
window.location.href = broadcastUrl
}
}
// Modal bloqueante: overlay completo que no permite interacción con UI
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center p-4">
<div className="bg-red-900/20 border border-red-500 rounded-lg p-6 max-w-md">
<div className="fixed inset-0 z-[9999] bg-gray-900 flex items-center justify-center">
{/* Modal */}
<div role="dialog" aria-modal="true" className="bg-gray-800 p-8 rounded-lg max-w-md w-full mx-4 shadow-2xl">
<div className="flex items-center mb-4">
<svg className="w-6 h-6 text-red-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-8 h-8 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 className="text-red-400 font-semibold">Error de conexión</h3>
<h3 className="text-xl text-white font-semibold">No se pudo conectar al estudio</h3>
</div>
<p className="text-red-300 text-sm mb-4">{error}</p>
<p className="text-gray-300 mb-3">No se obtuvo un token válido para autenticarse con el servidor de LiveKit.</p>
<p className="text-gray-400 text-sm mb-6">Token obtenido: {token ? `${token.length} chars` : 'ninguno'}</p>
<div className="flex gap-3 justify-end">
<button
onClick={() => window.location.reload()}
className="w-full py-2 px-4 bg-red-600 hover:bg-red-700 text-white rounded-lg transition"
className="py-2 px-4 bg-slate-600 hover:bg-slate-700 text-white rounded-lg font-medium"
>
Reintentar
</button>
<button
onClick={goBackToBroadcast}
className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white rounded-lg font-medium"
>
Volver al Broadcast
</button>
</div>
</div>
</div>
)
}
if (!token) {
return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center">
<p className="text-white">No se pudo obtener el token de acceso</p>
</div>
)
}
const serverUrl = import.meta.env.VITE_LIVEKIT_URL || 'wss://livekit-server.bfzqqk.easypanel.host'
// Renderizar interfaz en modo demo (sin LiveKit)
if (isDemoMode) {
return (
@ -123,6 +238,8 @@ const Studio: React.FC<StudioProps> = ({ userName, roomName }) => {
}
// Renderizar con LiveKit (modo normal)
return (
<LiveKitRoom
video={true}

View File

@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDODCCAiCgAwIBAgIUcNTZqPhOwoYkYilwsQR3YTZ6YK0wDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MTEwNzA0MjMxMloXDTI2MTEw
NzA0MjMxMlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAlUMUTx43JyWIOXYSC1HG5RJ/TpIouuym0aRThuIBogqR
LCQgHF+L3mawpiGTpdAFWnPsttArf8UUkROJeSt37NFqiA8Kmogyx1Xb6At+uiyt
KTOG/br+PeGnN/A644lmqP9AAmG2TnVfsgFqKm4FUs8LFTEVDV8zxXx1oMCSTE9G
a6h7IQ4VpFZLU3no0eNppvbCXmVpwZC25WI4R0ee51Ii2DPSguA/og/urIhsxQ/6
0yrIUqRQbhYcVTO8DLyNfMzRjrejpDM8ii2L+j6mslaIzeqiAEjrhX0O5XzowP/j
/HPuZ4fltHdMMTOR/oSZkomv1bOQCz8DiIo04OJOtwIDAQABo4GBMH8wHQYDVR0O
BBYEFD/+kW7z0masF/hvZTBEjQ2VaPNcMB8GA1UdIwQYMBaAFD/+kW7z0masF/hv
ZTBEjQ2VaPNcMA8GA1UdEwEB/wQFMAMBAf8wLAYDVR0RBCUwI4IJbG9jYWxob3N0
hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IBAQB21ONB
HsBIUaf7ZhvGndWo6EEavscvSD2OOItHpzz0ThyuUZEAwjTpor+oETWoPDEFI/il
BBny/5bB7ajYrmZ/+OdxaL2nTzO+0oQtHjIwUDUmCh7YvrXYhCHxzmA9j+300Gel
rIqyAIhV6gJvmq93CmmWPf/C4deDyiU2ZvqfdQxfc2iM3jvOYig5qaqT5vyBV9Jk
3HuxgPsQwID79USJRxxYlvI09wFBhMwNBlQA02t+zpi/BcC8/tUp4+rrf3g4o9Qb
AEJDC69dwLi0EAACljD8cFeK768PhDQ5uyAxB5szFJWQPJkynRZ3JB/us86TuQVc
Cqde9MNl5SWEcU6O
-----END CERTIFICATE-----

View File

@ -0,0 +1,15 @@
-----BEGIN CERTIFICATE REQUEST-----
MIICWTCCAUECAQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B
AQEFAAOCAQ8AMIIBCgKCAQEAxo7rYtforBLUsyoYo+hnsL0dK0FU72gFJF25M1lJ
Z/jFfJYdNGuoh6d5lQm1X9pvA4AKbdSEU+7y/AEb8xZgAibGrecnusJOIrIr1fPW
Xe7NUp5e65OtCQhlYGHK3vnMUY50oLReLPiMoR/SsX9jPLFlIkpLy2BRNTL933SC
eaTxPfxVIg4HME/Zs/cqbIynJWN6f0SbhQQVPMzLua6WXzEt4bnCW4RsgJpWY6tS
8zrROMSo3zzTJ8qjV/EjGm2aGmhuIHkIMo50E5+MdjWkWIm9F6xErriyVmeWETyR
n17DyqfRRCD0q76I75xKwLAFpnRO03lOygdd4QtY3XtKTQIDAQABoAAwDQYJKoZI
hvcNAQELBQADggEBAF7h8ER0kBK60QOsDQGI70isBDTMci0RRCLSTa82KVNMZ6NY
0Y0eOG1iP0KwnMup8A/9roe0RUlG2DYwaxEd1hskYhxtHvakrxI+oypQyW6USrz8
120vce3GY9jLhcqz/Ws5YTZ1aG+gBi7ps1GyzfLIMG4BhT7tiTvQSLbP3kX08CgF
L+Zz7ukoW1pUTMYa/sWVy1+a6Gelf/V6wIekNXzrT0/xkj2VmxlAVS0Do7E7y/Z1
t9MjYVlxIvw8lDvUyrVwm9S5tPIni1mVQCgiFej9qFcJIzn2Hhgt0TnQi8piQFDe
JiEc0xpxVyZeeMR7ds2KK2FVy+Afo5+4py7Ib3w=
-----END CERTIFICATE REQUEST-----

View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDGjuti1+isEtSz
Khij6GewvR0rQVTvaAUkXbkzWUln+MV8lh00a6iHp3mVCbVf2m8DgApt1IRT7vL8
ARvzFmACJsat5ye6wk4isivV89Zd7s1Snl7rk60JCGVgYcre+cxRjnSgtF4s+Iyh
H9Kxf2M8sWUiSkvLYFE1Mv3fdIJ5pPE9/FUiDgcwT9mz9ypsjKclY3p/RJuFBBU8
zMu5rpZfMS3hucJbhGyAmlZjq1LzOtE4xKjfPNMnyqNX8SMabZoaaG4geQgyjnQT
n4x2NaRYib0XrESuuLJWZ5YRPJGfXsPKp9FEIPSrvojvnErAsAWmdE7TeU7KB13h
C1jde0pNAgMBAAECgf8roLuXvFkjdf/GXjmLykT+UI9YMcK31+NJWk6XOccnUUIT
XeiM3Brrs6DDXp67sQMzga7I8ykgSCCbvqKlhwURc/Ozwla4cnk4pm17VViEyzPS
M3onyQr8MRwVUWeCFuEOCn8V0Ivg1bwQqy1gUt07OL9ACZMd3Mv7JHkj4DXLBE/J
EmupZZJZqfcJWzQmOrA7T278MmvySUFUXY7wCMwL/y2/3FywpKUzuciwwY2j+J1g
3HRDJwT1uLxQ/2m2Kv1YYKC6BZatyO0rVJAiMQQOvbDEX918eVGNmJ3nr3xjzIOe
AUOAjWAlt9OVxHCw3Ztu183FoOrMnWfWTApHTfkCgYEA7SyAf60+NLiHbVfr4+41
jqg7CPvi3KiWTuvb7EElhJNba7DoCApPJ3UpZI9sLHEcnhcKXrnRs9GWqu/L53Ov
IXwJYQkvAdRSVEvlaaOxKtePLWuA872EPDSDJ1slDk9STvsjyA38H7Wck7kE6ve+
rIo2FIYlJ0SBRJgH3wjJVVUCgYEA1lG6nKRKxxqM9kGNBsCi7W5fcU5zPIB19ZOF
S1Ft1R/Uu2GQXe0PVa2XsVt1ytGmtYX2N2E8VE0kSideN0kUBsfvq+f1iEybN+Hd
fN8DOcrnOk1wHmBNPkIjX+oxCl2LL8m9d4stWk+XRJ2ztCus3N8HrLHCy7CdD0OK
StaKIRkCgYEAv8KHcrOj5AR+ms3Hj9Z9vwYOFUlzN22nedABVJenufVaqUuzjyym
qwRznzbHA3fA56FBZS1ge78tzq9rcYt7QduDyc0fEJ+WoUlsB2muSTFYNiUBchD8
5aCfmiZ7Y4KGzg0H9Sw5eXnhSx8A9umwZNiquRVs3L7qtYcmdhIolrECgYAagHC6
/fXhOP9FVEXEF/4NriBPOow1Zw0vGNbawW77c7wyj5Xyh2XmClk/rTebpOEggTg5
EOUM550dLlEQNREs5XxVnZFXEWIAPwXMcydK9jQxmXHLz8y9biBBtAvsZDTZ6/Bp
3+PzzvO9oGKgXOY7SbkBOdoEpgpF4Oww5OafwQKBgCDrafoYJqCx7wiayAu3AhHg
FN4HsDA1PlCziqXmUd2L16O2fFpZ5/MfSXKqAXREe1p2ADX3HHJvzMzQksvju7n+
H2Vmi5ejuhvVKxhvGuxi+T4XsCv9B8c8K4msVhdCIycKRFKVMn2NFfJPQRMT0VI1
FpYz/TshNdaFBxDn7CUt
-----END PRIVATE KEY-----

View File

@ -0,0 +1,25 @@
nohup: no se tendrá en cuenta la entrada
> @avanzacast/broadcast-studio@1.0.0 dev
> vite --port 3020
VITE v4.3.9 ready in 1346 ms
➜ Local: http://localhost:3020/
➜ Network: http://192.168.1.19:3020/
➜ Network: http://192.168.1.17:3020/
➜ Network: http://172.18.0.1:3020/
➜ Network: http://172.19.0.1:3020/
10:40:42 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
10:42:54 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
10:43:26 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
10:44:47 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
10:45:20 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
10:50:16 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:04:49 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:05:21 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:05:47 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:06:22 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:07:13 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css
11:11:12 p.m. [vite] hmr update /src/components/Studio.tsx, /src/index.css

View File

@ -8,7 +8,10 @@ export default defineConfig(({ mode }) => {
return {
plugins: [react()],
server: {
port: 3001,
port: 3020,
host: true,
// Usar HTTP en dev para pruebas E2E locales (localhost permite getUserMedia sin HTTPS en muchos navegadores)
watch: { usePolling: true }
},
define: {
// Exponer variables de entorno al cliente

View File

@ -0,0 +1,75 @@
const { chromium } = require('playwright')
;(async () => {
console.log('E2E: solicitando token al token-server...')
const tokenRes = await fetch('http://localhost:3010/api/token?room=test&username=Playwright')
if (!tokenRes.ok) throw new Error('No se pudo obtener token desde http://localhost:3010')
const tokenData = await tokenRes.json()
console.log('E2E: token recibido (longitud):', tokenData.token ? tokenData.token.length : 'n/a')
const shortId = Math.random().toString(36).slice(2, 10)
const studioUrl = `http://localhost:3020/${shortId}`
console.log('E2E: lanzando navegador headless...')
const browser = await chromium.launch({ headless: true })
const context = await browser.newContext()
// Abrir una 'broadcast page' (simula origen que realiza window.open y postMessage)
const broadcastPage = await context.newPage()
try {
await broadcastPage.goto('http://localhost:5175', { waitUntil: 'domcontentloaded', timeout: 8000 })
console.log('E2E: broadcast-page abierta')
} catch (err) {
console.warn('E2E: no se pudo cargar broadcast UI, continuamos (no es obligatorio):', err.message)
}
// Preparar espera de la nueva página (studio)
const newPagePromise = context.waitForEvent('page')
// Desde la broadcastPage crear la ventana y enviar postMessage (imitando el comportamiento de la app)
await broadcastPage.evaluate(({ studioUrl, tokenData }) => {
const win = window.open(studioUrl, '_blank')
// Intentar enviar token repetidamente hasta que la ventana esté lista
let attempts = 0
const tryPost = () => {
attempts++
try {
const origin = new URL(studioUrl).origin
win.postMessage({ type: 'AVANZACAST_TOKEN', token: tokenData.token, serverUrl: tokenData.serverUrl, room: 'test', user: 'Playwright' }, origin)
// eslint-disable-next-line no-console
console.log('E2E: postMessage enviado desde broadcast page')
} catch (e) {
if (attempts < 15) setTimeout(tryPost, 200)
else console.error('E2E: no se pudo enviar postMessage a la ventana del studio')
}
}
tryPost()
}, { studioUrl, tokenData })
const studioPage = await newPagePromise
await studioPage.waitForLoadState('domcontentloaded')
console.log('E2E: studio page abierta, esperando que el banner de DEMO desaparezca si llega...')
// El componente muestra un banner de modo demo; si recibimos token, ese banner debería desaparecer.
try {
// Si existe el banner, esperamos que desaparezca
const demoLocator = studioPage.locator('text=⚠️ MODO DEMO')
// Esperar a que sea visible (si no aparece, skip)
await demoLocator.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {})
// Ahora esperar a que se oculte tras recibir el token
await demoLocator.waitFor({ state: 'hidden', timeout: 8000 })
console.log('E2E: banner de DEMO desapareció -> token aplicado')
} catch (err) {
console.warn('E2E: no se pudo confirmar desaparición del banner DEMO (puede que la página ya estuviera en modo no-demo):', err.message)
}
// Tomar un snapshot de la página (longitud del HTML)
const html = await studioPage.content()
console.log('E2E: longitud del HTML del studio:', html.length)
await browser.close()
console.log('E2E: prueba finalizada correctamente')
})().catch(err => {
console.error('E2E: error en la prueba', err)
process.exit(1)
})