Compare commits
10 Commits
8b458a3ddf
...
a2a2da6586
| Author | SHA1 | Date | |
|---|---|---|---|
| a2a2da6586 | |||
| 2a242b35f2 | |||
| 08aca81ab1 | |||
| adbec08f5e | |||
| f8516a5330 | |||
| 1e67f1ca36 | |||
| 3780d75386 | |||
| fd5c9f05e0 | |||
| c408c28185 | |||
| d162014030 |
110
app/api/session/README.md
Normal file
@ -0,0 +1,110 @@
|
||||
# API /api/session
|
||||
|
||||
Este README documenta la API de sesión incluida en `app/api/session` del proyecto.
|
||||
|
||||
Objetivo
|
||||
- Proveer endpoints simples para crear y recuperar sesiones E2E que devuelven un token LiveKit y una `studioUrl` construida con la convención `/rooms/<roomName>`.
|
||||
|
||||
Endpoints
|
||||
|
||||
1) POST /api/session
|
||||
- Uso: crea una sesión (genera token JWT con `livekit-server-sdk`) y guarda una entrada en el store en memoria.
|
||||
- Request body (JSON):
|
||||
- room (string) — nombre de la sala (opcional, por defecto `e2e-room`).
|
||||
- username (string) — identidad del participante (opcional).
|
||||
- ttl (number) — tiempo de vida en segundos del token (opcional, por defecto 300).
|
||||
- Response (200):
|
||||
```json
|
||||
{ "id": "sess_<ts>_<rand>", "token": "<JWT>", "studioUrl": "https://mi-dominio/rooms/<room>" }
|
||||
```
|
||||
|
||||
2) GET /api/session/:id
|
||||
- Uso: recuperar la sesión previamente creada por `POST /api/session`.
|
||||
- Response (200): retorna el objeto de sesión guardado en memoria, por ejemplo:
|
||||
```json
|
||||
{
|
||||
"id": "sess_...",
|
||||
"token": "...",
|
||||
"room": "mi-room",
|
||||
"username": "visual-runner",
|
||||
"studioUrl": "https://mi-dominio/rooms/mi-room",
|
||||
"createdAt": 169...
|
||||
}
|
||||
```
|
||||
|
||||
Variables de entorno relevantes
|
||||
- LIVEKIT_API_KEY (required): clave pública para LiveKit.
|
||||
- LIVEKIT_API_SECRET (required): secreto para firmar tokens.
|
||||
- STUDIO_BASE (optional): base URL del frontend; si está definida, `studioUrl` se construye como `${STUDIO_BASE}/rooms/${room}`.
|
||||
- Alternativas atendidas: `STUDIO_URL` o `BROADCAST_URL` si `STUDIO_BASE` no existe.
|
||||
|
||||
Notas técnicas
|
||||
- El store es un Map en memoria (`app/api/session/store.ts`). Es volátil: si el servidor se reinicia, las sesiones se perderán. Para entornos reales usa Redis o una DB.
|
||||
- El token se genera con `AccessToken` del paquete `livekit-server-sdk` y contiene los grants para `roomJoin`, `canPublish` y `canSubscribe`.
|
||||
|
||||
Comandos de ejemplo
|
||||
|
||||
1) Crear sesión (POST):
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://localhost:3000/api/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"room":"mi-room-e2e","username":"tester","ttl":300}' | jq .
|
||||
```
|
||||
|
||||
2) Recuperar sesión (GET):
|
||||
|
||||
```bash
|
||||
curl -sS http://localhost:3000/api/session/sess_169... | jq .
|
||||
```
|
||||
|
||||
3) Decodificar el token (inspección del payload JWT):
|
||||
|
||||
```bash
|
||||
TOKEN="eyJhbGciOiJ..."
|
||||
echo $TOKEN | cut -d '.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
|
||||
```
|
||||
|
||||
4) Probar manualmente en el frontend (DevTools)
|
||||
- Abre: `https://mi-dominio/rooms/mi-room-e2e`.
|
||||
- En la consola ejecuta:
|
||||
|
||||
```js
|
||||
window.postMessage({ type: 'LIVEKIT_TOKEN', token: 'TOKEN_AQUI', room: '' }, window.location.origin);
|
||||
```
|
||||
|
||||
El frontend debe escuchar `message` y conectar usando el token.
|
||||
|
||||
Probar con el E2E script
|
||||
- Arrancar Chrome con remote-debugging (usa `packages/broadcast-panel/e2e/start-chrome-remote.sh`).
|
||||
- Ejecutar el script:
|
||||
|
||||
```bash
|
||||
REMOTE_DEBUG_ADDRESS=127.0.0.1 REMOTE_DEBUG_PORT=9222 \
|
||||
TOKEN_SERVER="http://localhost:3000" \
|
||||
ROOM="mi-room-e2e" \
|
||||
BROADCAST_BASE="https://mi-dominio" \
|
||||
node packages/broadcast-panel/e2e/generate_visual_baseline.js
|
||||
```
|
||||
|
||||
Artefactos generados por el E2E script
|
||||
- Logs: `packages/broadcast-panel/e2e/e2e/out/generate_visual_baseline.log` (o `e2e/out/...` en la raíz del repo)
|
||||
- Captura: `packages/broadcast-panel/e2e/e2e/out/visual_<timestamp>/studio.png`
|
||||
- Baseline: `packages/broadcast-panel/e2e/e2e/baseline/studio.png`
|
||||
|
||||
Depuración rápida
|
||||
- Si `POST /api/session` devuelve 500: revisa `LIVEKIT_API_KEY`/`LIVEKIT_API_SECRET` en el entorno del backend.
|
||||
- Si `studioUrl` es vacío: exporta `STUDIO_BASE` antes de hacer POST.
|
||||
- Si la página no reacciona al `postMessage`: revisa el listener `window.addEventListener('message', ...)` en el frontend.
|
||||
|
||||
Siguientes pasos recomendados
|
||||
- Reemplazar el store en memoria por Redis para persistencia en entornos multi-instanacia.
|
||||
- Añadir autenticación y control de acceso para la creación/consulta de sesiones.
|
||||
|
||||
Si quieres, puedo:
|
||||
- Añadir un mock token server con `tools/mock-token-server.js` para pruebas locales sin credenciales reales.
|
||||
- Añadir un endpoint para listar sesiones (solo para debugging).
|
||||
|
||||
---
|
||||
**Fin del README de `app/api/session`**
|
||||
|
||||
18
app/api/session/[id]/route.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { getSession } from '../store';
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split('/');
|
||||
const id = parts[parts.length - 1] || '';
|
||||
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const sess = getSession(id);
|
||||
if (!sess) return new Response(JSON.stringify({ error: 'not found' }), { status: 404, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
return new Response(JSON.stringify(sess), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
} catch (err) {
|
||||
console.error('Error in GET /api/session/:id', err);
|
||||
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
21
app/api/session/proxy/[id]/route.ts
Normal file
@ -0,0 +1,21 @@
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const url = new URL(req.url);
|
||||
const parts = url.pathname.split('/');
|
||||
const id = parts[parts.length - 1] || '';
|
||||
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } });
|
||||
|
||||
const backend = process.env.VITE_BACKEND_API_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.BACKEND_URL || process.env.BACKEND || '';
|
||||
if (!backend) {
|
||||
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
const resp = await fetch(`${backend.replace(/\/$/, '')}/api/session/${encodeURIComponent(id)}`);
|
||||
const text = await resp.text();
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
return new Response(text, { status: resp.status, headers });
|
||||
} catch (err) {
|
||||
console.error('proxy GET /api/session/:id error', err);
|
||||
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
19
app/api/session/proxy/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
// Prefer VITE_BACKEND_API_URL (frontend env) then VITE_TOKEN_SERVER_URL then BACKEND_URL / BACKEND
|
||||
const backend = process.env.VITE_BACKEND_API_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.BACKEND_URL || process.env.BACKEND || '';
|
||||
if (!backend) {
|
||||
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
const url = `${backend.replace(/\/$/, '')}/api/session`;
|
||||
const resp = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const text = await resp.text();
|
||||
const headers = { 'content-type': 'application/json' };
|
||||
return new Response(text, { status: resp.status, headers });
|
||||
} catch (err) {
|
||||
console.error('proxy /api/session error', err);
|
||||
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
44
app/api/session/route.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { saveSession } from './store';
|
||||
|
||||
// API Route (POST) que crea una sesión y devuelve token + studioUrl + id
|
||||
// Env required: LIVEKIT_API_KEY, LIVEKIT_API_SECRET, optionally STUDIO_URL or STUDIO_BASE_URL
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const room = body.room || 'e2e-room';
|
||||
const username = body.username || 'visual-runner';
|
||||
const ttl = body.ttl || 300; // segundos
|
||||
|
||||
const apiKey = process.env.LIVEKIT_API_KEY;
|
||||
const apiSecret = process.env.LIVEKIT_API_SECRET;
|
||||
if (!apiKey || !apiSecret) {
|
||||
return new Response(JSON.stringify({ error: 'LIVEKIT_API_KEY and LIVEKIT_API_SECRET required' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
|
||||
const at = new AccessToken(apiKey, apiSecret, { identity: username, name: username, ttl: `${ttl}s` });
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: room,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishSources: ['camera', 'microphone', 'screen'],
|
||||
} as any);
|
||||
|
||||
const token = at.toJwt();
|
||||
const id = `sess_${Date.now()}_${Math.random().toString(36).slice(2,8)}`;
|
||||
|
||||
// Construir studioUrl usando la estructura rooms/[roomName] por defecto
|
||||
const studioBase = (process.env.STUDIO_BASE_URL || process.env.STUDIO_URL || process.env.BROADCAST_URL || '').trim();
|
||||
const studioUrl = studioBase ? `${studioBase.replace(/\/$/, '')}/rooms/${encodeURIComponent(room)}` : '';
|
||||
|
||||
// Guardar sesión en store en memoria
|
||||
saveSession(id, { token, room, username, studioUrl });
|
||||
|
||||
return new Response(JSON.stringify({ id, token, studioUrl }), { status: 200, headers: { 'content-type': 'application/json' } });
|
||||
} catch (err) {
|
||||
console.error('Error in /api/session', err);
|
||||
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
}
|
||||
36
app/api/session/store.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export type SessionData = {
|
||||
id: string;
|
||||
token?: string | null;
|
||||
room?: string | null;
|
||||
username?: string | null;
|
||||
studioUrl?: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
const sessionStore = new Map<string, SessionData>();
|
||||
|
||||
export function saveSession(id: string, data: Omit<Partial<SessionData>, 'id'>) {
|
||||
const payload: SessionData = {
|
||||
id,
|
||||
token: data.token ?? null,
|
||||
room: data.room ?? null,
|
||||
username: data.username ?? null,
|
||||
studioUrl: data.studioUrl ?? null,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
sessionStore.set(id, payload);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function getSession(id: string): SessionData | null {
|
||||
return sessionStore.get(id) || null;
|
||||
}
|
||||
|
||||
export function deleteSession(id: string): boolean {
|
||||
return sessionStore.delete(id);
|
||||
}
|
||||
|
||||
export function listSessions() {
|
||||
return Array.from(sessionStore.values());
|
||||
}
|
||||
|
||||
97
app/rooms/[roomName]/AutoRequestAndInject.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type SessionResp = { id?: string; token?: string; studioUrl?: string; username?: string; [k: string]: any };
|
||||
|
||||
async function resolveIdentity(): Promise<string | null> {
|
||||
// 1) try NextAuth standard endpoint
|
||||
try {
|
||||
const r = await fetch('/api/auth/session');
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
// NextAuth returns { user: { name, email }, expires }
|
||||
if (j && j.user) {
|
||||
return j.user.name || j.user.email || null;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 2) try a global set by the app
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (typeof window !== 'undefined' && (window as any).__AVANZACAST_USER) {
|
||||
// @ts-ignore
|
||||
const u = (window as any).__AVANZACAST_USER;
|
||||
if (typeof u === 'string') return u;
|
||||
if (u && (u.name || u.id || u.email)) return u.name || u.id || u.email;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
// 3) fallback: prompt the user (only in dev/testing)
|
||||
try {
|
||||
const answer = window.prompt('Identifícate (nombre de usuario) para ligar con la room', 'visual-runner');
|
||||
if (answer && answer.trim()) return answer.trim();
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function AutoRequestAndInject({ roomName }: { roomName: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const identity = await resolveIdentity();
|
||||
if (!identity) throw new Error('No identity resolved; please login or set window.__AVANZACAST_USER');
|
||||
if (mounted) setUsername(identity);
|
||||
|
||||
const resp = await fetch('/api/session/proxy', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room: roomName, username: identity, ttl: 300 }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const json: SessionResp = await resp.json();
|
||||
|
||||
// Try to get token if not in POST response
|
||||
if (!json.token) {
|
||||
if (json.id) {
|
||||
const getResp = await fetch(`/api/session/proxy/${encodeURIComponent(json.id)}`);
|
||||
if (getResp.ok) {
|
||||
const getJson = await getResp.json();
|
||||
if (getJson && getJson.token) {
|
||||
json.token = getJson.token;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!json.token) throw new Error('No token returned from session proxy');
|
||||
|
||||
// Post message with token to same-origin listeners
|
||||
window.postMessage({ type: 'LIVEKIT_TOKEN', token: json.token, room: roomName, username: identity }, window.location.origin);
|
||||
console.log('AutoRequestAndInject: posted LIVEKIT_TOKEN to window', { room: roomName, username: identity });
|
||||
} catch (err: any) {
|
||||
console.error('AutoRequestAndInject error', err);
|
||||
if (mounted) setError(String(err));
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => { mounted = false; };
|
||||
}, [roomName]);
|
||||
|
||||
if (loading) return <div aria-live="polite">Solicitando token de sesión para room "{roomName}"...</div>;
|
||||
if (error) return <div role="alert">Error solicitando token: {error}</div>;
|
||||
return (
|
||||
<div>
|
||||
{username ? <div>Identidad: <strong>{username}</strong></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
app/rooms/[roomName]/StudioReceiver.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
// file removed - StudioReceiver replaced by real studio flow
|
||||
// This file was intentionally removed when reverting mock changes.
|
||||
|
||||
17
app/rooms/[roomName]/page.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import AutoRequestAndInject from './AutoRequestAndInject';
|
||||
|
||||
export default function RoomPage({ params }: { params: { roomName: string } }) {
|
||||
const { roomName } = params;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Room: {roomName}</h1>
|
||||
<AutoRequestAndInject roomName={roomName} />
|
||||
{/* Aquí va el componente del estudio real (VideoConference) */}
|
||||
<div id="studio-root">
|
||||
{/* El estudio debe estar preparado para escuchar postMessage LIVEKIT_TOKEN */}
|
||||
<p>Esperando token para conectar al studio...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
BIN
docs/img_3.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
docs/img_4.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
docs/img_5.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
docs/img_6.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
docs/img_7.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
docs/img_8.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
docs/img_9.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
130
docs/mock-studio.html
Normal file
@ -0,0 +1,130 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Mock Studio — AvanzaCast</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#0f172a;color:#e6eef8}
|
||||
.card{width:760px;max-width:95%;background:#0b1220;border-radius:12px;padding:20px;box-shadow:0 10px 30px rgba(2,6,23,0.6)}
|
||||
h1{margin:0 0 8px;font-size:18px}
|
||||
p{margin:0 0 12px;color:#9fb0d1}
|
||||
.row{display:flex;gap:8px;margin-bottom:8px}
|
||||
button{background:#0ea5a3;border:none;padding:8px 12px;border-radius:8px;color:#042024;cursor:pointer}
|
||||
pre{background:#041025;padding:12px;border-radius:8px;color:#cfe8ff;overflow:auto;max-height:220px}
|
||||
input{background:#031423;border:1px solid #103247;color:#cfe8ff;padding:8px;border-radius:6px;flex:1}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Mock Studio — Emulación popup</h1>
|
||||
<p>Esta página simula el popup que responde a mensajes postMessage desde la app (LIVEKIT_PING, LIVEKIT_TOKEN). Úsala en local si el host remoto no es accesible.</p>
|
||||
|
||||
<div class="row">
|
||||
<input id="roomInput" placeholder="room (mock-room)" value="mock-room" />
|
||||
<input id="tokenInput" placeholder="token (mock-token-<room>)" value="mock-token-mock-room" />
|
||||
<button id="sendToken">Enviar LIVEKIT_TOKEN</button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button id="sendPing">Enviar LIVEKIT_PING</button>
|
||||
<button id="openOpener" title="intenta comunicarte con window.opener">Ping opener</button>
|
||||
<button id="clearLog">Limpiar</button>
|
||||
</div>
|
||||
|
||||
<pre id="log">Log de mensajes:
|
||||
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const logEl = document.getElementById('log');
|
||||
function log(...args){
|
||||
const text = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||||
logEl.textContent = logEl.textContent + '\n' + new Date().toISOString().slice(11,23) + ' ' + text;
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
// Mensajes entrantes (desde opener)
|
||||
window.addEventListener('message', ev => {
|
||||
try {
|
||||
const msg = ev.data;
|
||||
log('RECV', msg);
|
||||
// Normalizar formato: puede ser string o objeto
|
||||
if (typeof msg === 'string') {
|
||||
if (msg === 'LIVEKIT_PING') {
|
||||
// responder inmediatamente
|
||||
ev.source.postMessage('LIVEKIT_READY', '*');
|
||||
log('SENT', 'LIVEKIT_READY');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typeof msg === 'object' && msg !== null) {
|
||||
if (msg.type === 'LIVEKIT_TOKEN' || msg.type === 'LIVEKIT_GET_TOKEN') {
|
||||
const room = msg.room || document.getElementById('roomInput').value || 'mock-room';
|
||||
const token = document.getElementById('tokenInput').value || ('mock-token-' + room);
|
||||
// enviar ACK con payload
|
||||
const ack = { type: 'LIVEKIT_ACK', token, room };
|
||||
// simular pequeña latencia
|
||||
setTimeout(() => {
|
||||
ev.source.postMessage(ack, '*');
|
||||
log('SENT', ack);
|
||||
// también enviar READY después
|
||||
setTimeout(() => {
|
||||
ev.source.postMessage('LIVEKIT_READY', '*');
|
||||
log('SENT', 'LIVEKIT_READY');
|
||||
}, 120);
|
||||
}, 120);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log('ERR', err && err.message ? err.message : err);
|
||||
}
|
||||
});
|
||||
|
||||
// Botones UI para debug / manual
|
||||
document.getElementById('sendPing').addEventListener('click', () => {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage('LIVEKIT_PING', '*');
|
||||
log('SENT (opener)', 'LIVEKIT_PING');
|
||||
} else {
|
||||
log('NOP', 'No hay opener disponible');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('sendToken').addEventListener('click', () => {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
const room = document.getElementById('roomInput').value || 'mock-room';
|
||||
const token = document.getElementById('tokenInput').value || ('mock-token-' + room);
|
||||
const msg = { type: 'LIVEKIT_TOKEN', token, room };
|
||||
window.opener.postMessage(msg, '*');
|
||||
log('SENT (opener)', msg);
|
||||
} else {
|
||||
log('NOP', 'No hay opener disponible');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('openOpener').addEventListener('click', () => {
|
||||
try {
|
||||
if (window.opener && !window.opener.closed) {
|
||||
window.opener.postMessage('MOCK_HELLO', '*');
|
||||
log('SENT (opener)', 'MOCK_HELLO');
|
||||
} else {
|
||||
log('NOP', 'Opener no encontrado');
|
||||
}
|
||||
} catch (e) {
|
||||
log('ERR', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('clearLog').addEventListener('click', () => logEl.textContent = 'Log de mensajes:\n');
|
||||
|
||||
// Si quieres abrir esta página desde la app usando window.open(url) y el popup fue bloqueado,
|
||||
// puedes abrir manualmente y usar el botón 'Enviar LIVEKIT_TOKEN' para simular el handshake.
|
||||
|
||||
log('Mock studio listo');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
215
docs/portal_livekit.md
Normal file
@ -0,0 +1,215 @@
|
||||
## 🛠️ Integración de LiveKit en Next.js con Componentes y Estilo Similar a StreamYard
|
||||
|
||||
Para lograr una interfaz similar a la captura de StreamYard en tu proyecto **Next.js** con **Vite** (aunque Next.js ya tiene su propio *bundler*, si usas Vite para otras partes o pruebas, los componentes de LiveKit para React son la clave), la mejor estrategia es utilizar la librería oficial de **LiveKit React Components** y luego aplicar estilos para simular la disposición.
|
||||
|
||||
### 1\. ⚙️ Configuración e Instalación
|
||||
|
||||
Aunque Next.js no usa Vite para el *bundling* principal, la integración de las librerías sigue los mismos pasos que cualquier aplicación React:
|
||||
|
||||
* **Instala el SDK y los Componentes de React:**
|
||||
|
||||
```bash
|
||||
npm install livekit-client @livekit/components-react @livekit/components-styles livekit-server-sdk
|
||||
# O usa yarn/pnpm:
|
||||
# yarn add livekit-client @livekit/components-react @livekit/components-styles livekit-server-sdk
|
||||
```
|
||||
|
||||
* `livekit-client`: El SDK principal para la conectividad WebRTC.
|
||||
* `@livekit/components-react`: Componentes de UI preconstruidos (la base para la interfaz).
|
||||
* `@livekit/components-styles`: Estilos base para los componentes.
|
||||
* `livekit-server-sdk`: Necesario para generar los **tokens de acceso** en tu API de Next.js.
|
||||
|
||||
* **Generación de Tokens (Backend):**
|
||||
Crea una **API Route** en Next.js (por ejemplo, en `/app/api/token/route.ts` para Next.js 13+) que use `livekit-server-sdk` para generar un token de acceso (JWT). Este token es necesario para que el cliente se conecte a la sala.
|
||||
|
||||
> **Nota:** Necesitarás tu `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET` y `LIVEKIT_URL` configurados en tu archivo `.env.local`.
|
||||
|
||||
### 2\. 🏗️ Estructura de Componentes de LiveKit
|
||||
|
||||
La interfaz de StreamYard que muestras se puede replicar usando una combinación de componentes de LiveKit y **estilos CSS/Tailwind** para el diseño de la cuadrícula.
|
||||
|
||||
1. **Contenedor Principal de la Sala:**
|
||||
Envuelve tu aplicación de video en el componente `<LiveKitRoom>`.
|
||||
|
||||
```tsx
|
||||
import '@livekit/components-styles';
|
||||
import { LiveKitRoom, VideoConference } from '@livekit/components-react';
|
||||
|
||||
export default function MiSala({ token, serverUrl }) {
|
||||
return (
|
||||
<LiveKitRoom
|
||||
token={token}
|
||||
serverUrl={serverUrl}
|
||||
connect={true}
|
||||
data-lk-theme="default" // Aplica el tema base
|
||||
>
|
||||
{/* Aquí van tus componentes personalizados */}
|
||||
<VideoConference />
|
||||
</LiveKitRoom>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Paneles y Diseño (Simulación de la Interfaz):**
|
||||
|
||||
La clave para simular la interfaz es usar CSS Grid o Flexbox en tu página de Next.js para colocar los componentes en las áreas que se ven en la captura:
|
||||
|
||||
| Área en StreamYard (Captura) | Componente de LiveKit o Custom | Descripción |
|
||||
| :--- | :--- | :--- |
|
||||
| **Escenas/Mis Escenas (Izquierda)** | **Custom React Component** | Un componente personalizado de Next.js para gestionar escenas, **fuera** de LiveKitRoom, pero que interactúa con el estado de la sala (por ejemplo, publicando/despublicando pistas o cambiando el *layout*). |
|
||||
| **Vídeo Central (720p)** | **GridLayout / FocusLayout** | Usa componentes de *Layout* de LiveKit, como `<GridLayout>` o `<FocusLayout>` para renderizar la vista principal de los participantes. |
|
||||
| **Controles (Micrófono, Cámara, etc.)** | **ControlBar / TrackToggle** | El componente `<ControlBar>` de LiveKit es ideal para la barra de control inferior, ya que incluye botones listos para Mute/Cámara/Pantalla Compartida. |
|
||||
| **Participantes (Cesar Mendivil)** | **ParticipantTile** | Puedes usar `<ParticipantLoop>` para iterar sobre los participantes y renderizar un `<ParticipantTile>` para cada uno en la vista inferior o principal. |
|
||||
| **Comentarios (Derecha)** | **Chat** | El componente `<Chat>` de LiveKit proporciona una funcionalidad de chat lista para usar que se ajusta a la sección de "Comentarios". |
|
||||
|
||||
### 3\. ✨ Estilizado y Componentes Personalizados
|
||||
|
||||
* **Estilos:** Importa los estilos base de LiveKit (`import '@livekit/components-styles';`) y utiliza **CSS Modules** o **Tailwind CSS** (muy común en Next.js) para crear el diseño de cuadrícula principal y los estilos específicos (bordes, fondos, etc.) que se asemejan a StreamYard. Utiliza *slots* o envoltorios personalizados alrededor de los componentes de LiveKit si necesitas un estilo muy específico que no se puede lograr con la personalización de LiveKit.
|
||||
* **Vite:** Si estás usando Vite en paralelo para el frontend, la instalación y el uso de los componentes de React son idénticos.
|
||||
|
||||
Aquí tienes un video que muestra cómo hacer una aplicación de videollamadas con Next.js y LiveKit.
|
||||
[Video Call Application with Next js and LiveKit | 2024](https://www.youtube.com/watch?v=-dfLbXOAoa8) es un tutorial que te guiará a través de la creación de una aplicación de videollamadas con Next.js y LiveKit.
|
||||
|
||||
http://googleusercontent.com/youtube_content/0
|
||||
|
||||
|
||||
## 🔑 Generación de Tokens con `livekit-server-sdk`
|
||||
|
||||
Para generar un token de acceso con `livekit-server-sdk` en tu backend (como en una API Route de Next.js), necesitas seguir tres pasos clave: configurar el SDK, definir la identidad del usuario y generar el token con los permisos (roles) adecuados.
|
||||
|
||||
Aquí te muestro un ejemplo completo en TypeScript/JavaScript:
|
||||
|
||||
### 1\. 🛠️ Instalación y Configuración
|
||||
|
||||
Asegúrate de tener el SDK instalado:
|
||||
|
||||
```bash
|
||||
npm install livekit-server-sdk
|
||||
```
|
||||
|
||||
Y que tus credenciales estén configuradas (usualmente en variables de entorno):
|
||||
|
||||
* **`LIVEKIT_API_KEY`**
|
||||
* **`LIVEKIT_API_SECRET`**
|
||||
* **`LIVEKIT_URL`** (la URL de tu servidor LiveKit, e.g., `wss://misuperapp.livekit.cloud`)
|
||||
|
||||
-----
|
||||
|
||||
### 2\. 📝 Ejemplo de Código para Generar un Token
|
||||
|
||||
Utiliza la clase **`AccessToken`** para crear un nuevo token, incluyendo los permisos para la sala.
|
||||
|
||||
```typescript
|
||||
import { AccessToken, RoomServiceClient } from 'livekit-server-sdk';
|
||||
import type { VideoGrant } from 'livekit-server-sdk';
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 1. Obtén las credenciales de tus variables de entorno
|
||||
// ----------------------------------------------------
|
||||
const apiKey = process.env.LIVEKIT_API_KEY;
|
||||
const apiSecret = process.env.LIVEKIT_API_SECRET;
|
||||
// const livekitUrl = process.env.LIVEKIT_URL; // Solo es necesaria si usas RoomServiceClient
|
||||
|
||||
if (!apiKey || !apiSecret) {
|
||||
throw new Error('LIVEKIT_API_KEY y LIVEKIT_API_SECRET deben estar configuradas.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera un token de acceso para un usuario específico en una sala.
|
||||
* @param roomName El nombre de la sala a la que se conectará el usuario.
|
||||
* @param identity La identidad única del usuario (e.g., ID de usuario o nombre de usuario).
|
||||
* @param canPublish Permiso para publicar video/audio/pantalla (típico de un participante).
|
||||
* @param canSubscribe Permiso para ver/escuchar a otros participantes (siempre debe ser true).
|
||||
* @param roomJoin Permiso para unirse a la sala.
|
||||
* @returns El token JWT generado (string).
|
||||
*/
|
||||
export function generateLiveKitToken(
|
||||
roomName: string,
|
||||
identity: string,
|
||||
canPublish: boolean,
|
||||
isModerator: boolean = false,
|
||||
): string {
|
||||
// ----------------------------------------------------
|
||||
// 2. Crea una instancia de AccessToken
|
||||
// ----------------------------------------------------
|
||||
const at = new AccessToken(apiKey!, apiSecret!, {
|
||||
identity: identity,
|
||||
// (Opcional) Nombre del usuario que se mostrará en la UI
|
||||
name: identity,
|
||||
// (Opcional) Define un tiempo de expiración
|
||||
ttl: '10m',
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 3. Define los permisos (VideoGrant)
|
||||
// ----------------------------------------------------
|
||||
const grant: VideoGrant = {
|
||||
roomJoin: true, // Permitir unirse a la sala
|
||||
room: roomName, // El nombre de la sala específica
|
||||
|
||||
// Permisos de publicación/suscripción
|
||||
canPublish: canPublish, // Permite publicar audio/video/pantalla
|
||||
canSubscribe: true, // Permite recibir pistas de otros
|
||||
|
||||
// (Opcional) Permisos avanzados para moderadores
|
||||
canUpdate: isModerator, // Permite actualizar la sala (mutear a otros, etc.)
|
||||
roomAdmin: isModerator, // Permisos de administrador de sala
|
||||
canPublishSources: ['camera', 'microphone', 'screen'], // Fuentes permitidas
|
||||
};
|
||||
|
||||
at.addGrant(grant);
|
||||
|
||||
// ----------------------------------------------------
|
||||
// 4. To string: genera el token JWT
|
||||
// ----------------------------------------------------
|
||||
const token = at.toJwt();
|
||||
|
||||
console.log(`Token generado para ${identity} en sala ${roomName}:`, token);
|
||||
|
||||
return token;
|
||||
}
|
||||
```
|
||||
|
||||
-----
|
||||
|
||||
### 3\. 🌐 Uso Típico en Next.js (API Route)
|
||||
|
||||
En una API Route de Next.js (`/api/livekit/token`):
|
||||
|
||||
```typescript
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateLiveKitToken } from './utils'; // Asume que el código anterior está en 'utils'
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { roomName, identity, isPublisher } = await req.json();
|
||||
|
||||
if (!roomName || !identity) {
|
||||
return NextResponse.json({ error: 'Faltan parámetros: roomName e identity' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Un publicador tiene 'canPublish: true', un espectador tendría 'canPublish: false'
|
||||
const token = generateLiveKitToken(roomName, identity, isPublisher);
|
||||
|
||||
return NextResponse.json({ token }, { status: 200 });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generando token:', error);
|
||||
return NextResponse.json({ error: 'Error interno del servidor' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Variables de entorno útiles
|
||||
|
||||
Además de las variables necesarias para la generación de tokens (`LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`, `LIVEKIT_URL`), el paquete del Broadcast Panel expone una variable específica para controlar un "preflight" que provoca el prompt de permisos de cámara/micrófono antes de intentar conectar al estudio.
|
||||
|
||||
- `VITE_STUDIO_PREFLIGHT` (opcional): controla si se ejecuta un preflight que llama a `navigator.mediaDevices.getUserMedia` para forzar el prompt de permisos antes de la negociación WebRTC.
|
||||
- Valores válidos: `1` / `true` (habilitado por defecto), `0` / `false` (deshabilitado).
|
||||
- Uso típico: en desarrollo puedes dejarlo habilitado para asegurar que el navegador solicite permisos al entrar al estudio; en entornos de pruebas automáticas o cuando no quieres el prompt automático, pon `VITE_STUDIO_PREFLIGHT=0`.
|
||||
|
||||
Hemos añadido un ejemplo de archivo de entorno en:
|
||||
|
||||
- `packages/broadcast-panel/.env.example`
|
||||
|
||||
que incluye `VITE_STUDIO_PREFLIGHT=1` y otras variables de ejemplo que el paquete puede leer (p. ej. `VITE_BACKEND_API_URL`, `VITE_BACKEND_TOKENS_URL`, `VITE_BROADCASTPANEL_URL`, `VITE_LIVEKIT_WS_URL`).
|
||||
544
docs/prejoin_template.html
Normal file
863
docs/prejoin_ui.md
Normal file
BIN
docs/sequence_livekit.png
Normal file
|
After Width: | Height: | Size: 237 KiB |
979
docs/streamyard_interface (1).html
Normal file
1000
docs/streamyard_interface (2).html
Normal file
1187
docs/streamyard_interface (3).html
Normal file
963
docs/streamyard_interface.html
Normal file
@ -76,6 +76,35 @@ node validate-flow-remote-chrome.js
|
||||
```
|
||||
Salida: `e2e/validate-flow-remote-chrome-result.json` + screenshot
|
||||
|
||||
## Ejecutar contra Chrome Remoto (remote debugging port 9222)
|
||||
|
||||
Si tienes un Chrome con `--remote-debugging-port=9222` (local o remoto), puedes ejecutar el validador que se conecta a ese navegador.
|
||||
|
||||
1) Si conoces la websocket URL completa (ej: `ws://1.2.3.4:9222/devtools/browser/xxxx`):
|
||||
|
||||
```bash
|
||||
export CHROME_WS="ws://1.2.3.4:9222/devtools/browser/xxxx"
|
||||
export BROADCAST_URL="https://your-broadcast.example.com"
|
||||
export STUDIO_URL="https://your-studio.example.com"
|
||||
export TOKEN="optional-token-if-you-want"
|
||||
node validate-flow-remote-chrome.js
|
||||
```
|
||||
|
||||
2) Si sólo conoces el host (ej: `1.2.3.4` o `1.2.3.4:9222`) usa el helper:
|
||||
|
||||
```bash
|
||||
# desde la raíz del repo
|
||||
CHROME_HOST="1.2.3.4" BROADCAST_URL="https://your-broadcast.example.com" STUDIO_URL="https://your-studio.example.com" TOKEN="..." \
|
||||
bash run-remote-chrome.sh
|
||||
```
|
||||
|
||||
3) Shortcut npm desde la raíz del monorepo (usa el helper creado `e2e/run-remote-chrome.sh`):
|
||||
|
||||
```bash
|
||||
CHROME_HOST="1.2.3.4" BROADCAST_URL="https://your-broadcast.example.com" STUDIO_URL="https://your-studio.example.com" TOKEN="..." \
|
||||
npm run e2e:remote-chrome
|
||||
```
|
||||
|
||||
### 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`.
|
||||
|
||||
26
e2e/_launch_chrome_puppeteer.js
Normal file
@ -0,0 +1,26 @@
|
||||
// Launch a Chromium instance via puppeteer-core and print the WebSocket endpoint to stdout
|
||||
// Also write the launcher PID to /tmp/puppeteer_chrome_launcher.pid
|
||||
const fs = require('fs');
|
||||
(async () => {
|
||||
try {
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
|
||||
});
|
||||
const ws = browser.wsEndpoint();
|
||||
const pidFile = '/tmp/puppeteer_chrome_launcher.pid';
|
||||
try { fs.writeFileSync(pidFile, String(process.pid)); } catch(e) { }
|
||||
console.log('PUPPETEER_WS=' + ws);
|
||||
console.log('LAUNCHER_PID=' + process.pid);
|
||||
// keep process alive until killed
|
||||
process.on('SIGINT', async () => { try { await browser.close(); } catch(e){}; process.exit(0); });
|
||||
process.on('SIGTERM', async () => { try { await browser.close(); } catch(e){}; process.exit(0); });
|
||||
// prevent exit
|
||||
await new Promise(() => {});
|
||||
} catch (err) {
|
||||
console.error('LAUNCH_ERROR', String(err));
|
||||
process.exit(2);
|
||||
}
|
||||
})();
|
||||
|
||||
34
e2e/run-remote-chrome.sh
Normal file
@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e/run-remote-chrome.sh
|
||||
# Resolve Chrome websocket endpoint from CHROME_HOST (host[:port]) or use CHROME_WS if provided.
|
||||
# Then run e2e/validate-flow-remote-chrome.js with the resolved CHROME_WS env var.
|
||||
set -eu
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT_DIR" || exit 1
|
||||
if [ -n "${CHROME_WS:-}" ]; then
|
||||
echo "Using provided CHROME_WS"
|
||||
export CHROME_WS="$CHROME_WS"
|
||||
else
|
||||
if [ -z "${CHROME_HOST:-}" ]; then
|
||||
echo "Error: set CHROME_HOST (host[:port]) or CHROME_WS (websocket url)" >&2
|
||||
exit 2
|
||||
fi
|
||||
HOST="$CHROME_HOST"
|
||||
# If port not present, default to 9222
|
||||
if [[ "$HOST" != *":"* ]]; then
|
||||
HOST="$HOST:9222"
|
||||
fi
|
||||
URL="http://${HOST}/json/version"
|
||||
echo "Fetching websocket endpoint from $URL"
|
||||
WS="$(curl -sS "$URL" | node -e "const s=fs.readFileSync(0,'utf8'); try{const j=JSON.parse(s); console.log(j.webSocketDebuggerUrl||'')}catch(e){console.error('failed parse json',e); process.exit(2)}")" || true
|
||||
if [ -z "$WS" ]; then
|
||||
echo "Failed to get webSocketDebuggerUrl from $URL" >&2
|
||||
curl -sS "$URL" || true
|
||||
exit 3
|
||||
fi
|
||||
export CHROME_WS="$WS"
|
||||
echo "Resolved CHROME_WS=$CHROME_WS"
|
||||
fi
|
||||
# Run the validator
|
||||
node e2e/validate-flow-remote-chrome.js
|
||||
|
||||
116
e2e/run-smoke-local.sh
Normal file
@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env bash
|
||||
# e2e/run-smoke-local.sh
|
||||
# Smoke test helper: arranca mock server, arranca Chrome (opcional) o fallback via puppeteer launcher,
|
||||
# ejecuta `validate-flow-remote-chrome.js` y limpia los procesos.
|
||||
# Usage:
|
||||
# CHROME_CMD="/usr/bin/google-chrome" ./run-smoke-local.sh
|
||||
# or set CHROME_HOST/CHROME_WS to reuse an existing remote Chrome
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
LOGDIR="$ROOT_DIR/logs"
|
||||
mkdir -p "$LOGDIR"
|
||||
|
||||
MOCK_LOG="$LOGDIR/mock_server.log"
|
||||
CHROME_LOG="$LOGDIR/chrome_launch.log"
|
||||
VALIDATOR_LOG="$LOGDIR/validator.log"
|
||||
|
||||
echo "[smoke] working dir: $ROOT_DIR"
|
||||
|
||||
# Start mock server
|
||||
echo "[smoke] starting mock server... (logs: $MOCK_LOG)"
|
||||
node mock_server.js > "$MOCK_LOG" 2>&1 &
|
||||
MOCK_PID=$!
|
||||
sleep 0.6
|
||||
if ! kill -0 "$MOCK_PID" 2>/dev/null; then
|
||||
echo "[smoke][error] failed to start mock server; see $MOCK_LOG"
|
||||
tail -n 200 "$MOCK_LOG" || true
|
||||
exit 2
|
||||
fi
|
||||
echo "[smoke] mock server pid=$MOCK_PID"
|
||||
|
||||
# Determine Chrome websocket endpoint
|
||||
WS=""
|
||||
if [ -n "${CHROME_WS:-}" ]; then
|
||||
WS="$CHROME_WS"
|
||||
echo "[smoke] using CHROME_WS from env"
|
||||
fi
|
||||
|
||||
# If user provided CHROME_HOST, resolve ws
|
||||
if [ -z "$WS" ] && [ -n "${CHROME_HOST:-}" ]; then
|
||||
echo "[smoke] resolving websocket endpoint from CHROME_HOST=$CHROME_HOST"
|
||||
URL="http://${CHROME_HOST%:*}:${CHROME_HOST##*:}/json/version"
|
||||
# if CHROME_HOST already has port include it directly
|
||||
if [[ "$CHROME_HOST" == *":"* ]]; then URL="http://$CHROME_HOST/json/version"; fi
|
||||
if ! curl -sS "$URL" > /dev/null 2>&1; then
|
||||
echo "[smoke] warning: cannot reach $URL"
|
||||
else
|
||||
WS=$(curl -sS "$URL" | python3 -c "import sys,json;print(json.load(sys.stdin).get('webSocketDebuggerUrl',''))") || true
|
||||
echo "[smoke] resolved WS=$WS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# If still no WS, try to launch Chrome via CHROME_CMD if provided
|
||||
LAUNCHED_CHROME_PID=""
|
||||
if [ -z "$WS" ]; then
|
||||
if [ -n "${CHROME_CMD:-}" ]; then
|
||||
echo "[smoke] launching Chrome via CHROME_CMD=$CHROME_CMD"
|
||||
"$CHROME_CMD" --remote-debugging-port=9222 --user-data-dir=/tmp/avz_smoke_profile --no-first-run --no-default-browser-check > "$CHROME_LOG" 2>&1 &
|
||||
LAUNCHED_CHROME_PID=$!
|
||||
sleep 1
|
||||
# try to fetch ws
|
||||
for i in 1 2 3 4 5; do
|
||||
if curl -sS http://localhost:9222/json/version >/dev/null 2>&1; then
|
||||
WS=$(curl -sS http://localhost:9222/json/version | python3 -c "import sys,json;print(json.load(sys.stdin).get('webSocketDebuggerUrl',''))") || true
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "[smoke] chrome launched pid=$LAUNCHED_CHROME_PID ws=$WS"
|
||||
else
|
||||
# fallback: use puppeteer launcher script
|
||||
echo "[smoke] no CHROME_CMD provided; launching Chromium via puppeteer (fallback)"
|
||||
node _launch_chrome_puppeteer.js > "$CHROME_LOG" 2>&1 &
|
||||
LAUNCHER_PID=$!
|
||||
sleep 0.8
|
||||
# read ws from log
|
||||
for i in 1 2 3 4 5; do
|
||||
if grep -m1 PUPPETEER_WS= "$CHROME_LOG" >/dev/null 2>&1; then
|
||||
WS_LINE=$(grep -m1 PUPPETEER_WS= "$CHROME_LOG" || true)
|
||||
WS=${WS_LINE#PUPPETEER_WS=}
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "[smoke] puppeteer-launched chrome pid=$LAUNCHER_PID ws=$WS"
|
||||
LAUNCHED_CHROME_PID=${LAUNCHER_PID:-}
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$WS" ]; then
|
||||
echo "[smoke][warn] could not resolve Chrome websocket endpoint; you can set CHROME_WS or CHROME_HOST to skip launch"
|
||||
echo "[smoke] continuing to try validator using CHROME_HOST if set"
|
||||
fi
|
||||
|
||||
# Run validator using CHROME_WS if available else CHROME_HOST
|
||||
echo "[smoke] running validator (logs: $VALIDATOR_LOG)"
|
||||
export CHROME_WS="$WS"
|
||||
export BROADCAST_URL="http://localhost:4001/broadcast"
|
||||
export STUDIO_URL="http://localhost:4001/studio"
|
||||
export TOKEN="smoketest-token-123"
|
||||
node validate-flow-remote-chrome.js > "$VALIDATOR_LOG" 2>&1 || echo "[smoke] validator exited non-zero; see $VALIDATOR_LOG"
|
||||
|
||||
# show result snippets
|
||||
echo "[smoke] validator log (tail 200):"
|
||||
tail -n 200 "$VALIDATOR_LOG" || true
|
||||
|
||||
# cleanup
|
||||
echo "[smoke] cleaning up..."
|
||||
if [ -n "${MOCK_PID:-}" ]; then kill $MOCK_PID >/dev/null 2>&1 || true; fi
|
||||
if [ -n "${LAUNCHED_CHROME_PID:-}" ]; then kill $LAUNCHED_CHROME_PID >/dev/null 2>&1 || true; fi
|
||||
if [ -n "${LAUNCHER_PID:-}" ]; then kill $LAUNCHER_PID >/dev/null 2>&1 || true; fi
|
||||
|
||||
echo "[smoke] logs stored in $LOGDIR"
|
||||
exit 0
|
||||
|
||||
44
e2e/run-tests.js
Normal file
@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env node
|
||||
// e2e/run-tests.js
|
||||
// Lee e2e/tests.json y ejecuta validate-flow-remote-chrome.js para cada caso en serie.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const testsPath = path.resolve(__dirname, 'tests.json');
|
||||
if (!fs.existsSync(testsPath)) {
|
||||
console.error('tests.json not found at', testsPath);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const tests = JSON.parse(fs.readFileSync(testsPath, 'utf8'));
|
||||
|
||||
async function runTest(t) {
|
||||
return new Promise((resolve) => {
|
||||
console.log('\n=== Running test:', t.name, '===');
|
||||
const env = Object.assign({}, process.env, t);
|
||||
// Use the helper script if CHROME_HOST provided; else require CHROME_WS
|
||||
const script = path.resolve(__dirname, 'run-remote-chrome.sh');
|
||||
const proc = spawn('bash', [script], { env, stdio: 'inherit' });
|
||||
proc.on('close', (code) => {
|
||||
console.log(`Test ${t.name} finished with code ${code}`);
|
||||
resolve(code);
|
||||
});
|
||||
proc.on('error', (err) => {
|
||||
console.error('Failed to spawn process for', t.name, err);
|
||||
resolve(3);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
for (const t of tests) {
|
||||
const code = await runTest(t);
|
||||
if (code !== 0) {
|
||||
console.warn('Test failed, stopping further tests. Last code:', code);
|
||||
process.exit(code || 1);
|
||||
}
|
||||
}
|
||||
console.log('\nAll tests passed (or completed).');
|
||||
})();
|
||||
|
||||
23
e2e/tests.json
Normal file
@ -0,0 +1,23 @@
|
||||
[
|
||||
{
|
||||
"name": "basic-flow",
|
||||
"BROADCAST_URL": "http://localhost:5176",
|
||||
"STUDIO_URL": "http://localhost:5176/studio",
|
||||
"TOKEN": "testtoken123",
|
||||
"EXPECTED_ROOM": "avanzacast-studio",
|
||||
"EXPECTED_USERNAME": "Demo User",
|
||||
"MIN_TTL_SECONDS": 30,
|
||||
"CHROME_HOST": "localhost:9222"
|
||||
},
|
||||
{
|
||||
"name": "mock-flow",
|
||||
"BROADCAST_URL": "http://localhost:4001/broadcast",
|
||||
"STUDIO_URL": "http://localhost:4001/studio",
|
||||
"TOKEN": "mocktoken",
|
||||
"EXPECTED_ROOM": "mock-room",
|
||||
"EXPECTED_USERNAME": "mock-user",
|
||||
"MIN_TTL_SECONDS": 10,
|
||||
"CHROME_HOST": "localhost:9222"
|
||||
}
|
||||
]
|
||||
|
||||
@ -11,6 +11,151 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const puppeteer = require('puppeteer-core');
|
||||
|
||||
// Helper: evaluate sessionStorage and backend token assertions
|
||||
async function evaluateSessionAssertions(broadcastPage, studioPage, results, BROADCAST_URL, TOKEN) {
|
||||
results.assertions = results.assertions || [];
|
||||
try {
|
||||
const storeKey = 'avanzacast_studio_session';
|
||||
const stored = await broadcastPage.evaluate((k) => { try { return sessionStorage.getItem(k); } catch (e) { return null; } }, storeKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
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 });
|
||||
|
||||
try {
|
||||
const parts = String(parsed.token).split('.');
|
||||
const isJwt = parts.length >= 3;
|
||||
results.assertions.push({ name: 'token_format_jwt', ok: isJwt, detail: isJwt ? 'looks like JWT' : 'not JWT-like' });
|
||||
if (isJwt) {
|
||||
const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||
const decoded = Buffer.from(payloadB64 + pad, 'base64').toString('utf8');
|
||||
try {
|
||||
const payloadObj = JSON.parse(decoded);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
if (payloadObj.exp && typeof payloadObj.exp === 'number') {
|
||||
results.assertions.push({ name: 'token_not_expired', ok: payloadObj.exp > nowSec, detail: `exp=${payloadObj.exp} now=${nowSec}` });
|
||||
} else {
|
||||
results.assertions.push({ name: 'token_has_exp', ok: false, detail: 'exp missing or not a number' });
|
||||
}
|
||||
if (payloadObj.room) results.assertions.push({ name: 'token_payload_room', ok: true, detail: String(payloadObj.room) });
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'token_payload_parse', ok: false, detail: 'failed to parse payload: ' + String(e) });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'token_format_check_failed', ok: false, detail: String(e) });
|
||||
}
|
||||
} else {
|
||||
results.assertions.push({ name: 'sessionStorage_has_token', ok: false, detail: 'sessionStorage present but token missing or malformed' });
|
||||
}
|
||||
|
||||
if (parsed && parsed.room) {
|
||||
results.assertions.push({ name: 'session_room_present', ok: true, detail: String(parsed.room) });
|
||||
} else {
|
||||
results.assertions.push({ name: 'session_room_present', ok: false, detail: 'room missing in session payload' });
|
||||
}
|
||||
if (parsed && (parsed.url || parsed.serverUrl)) {
|
||||
const serverUrl = parsed.url || parsed.serverUrl;
|
||||
const okUrl = typeof serverUrl === 'string' && (serverUrl.startsWith('ws://') || serverUrl.startsWith('wss://') || serverUrl.startsWith('http://') || serverUrl.startsWith('https://'));
|
||||
results.assertions.push({ name: 'session_serverUrl_valid', ok: okUrl, detail: okUrl ? String(serverUrl) : 'invalid or missing serverUrl' });
|
||||
} else {
|
||||
results.assertions.push({ name: 'session_serverUrl_valid', ok: false, detail: 'serverUrl missing' });
|
||||
}
|
||||
|
||||
try {
|
||||
const expectedRoom = process.env.EXPECTED_ROOM || null;
|
||||
const expectedUsername = process.env.EXPECTED_USERNAME || null;
|
||||
const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30);
|
||||
|
||||
if (expectedRoom) {
|
||||
const roomMatches = !!parsed && String(parsed.room) === String(expectedRoom);
|
||||
results.assertions.push({ name: 'session_room_matches_expected', ok: roomMatches, detail: roomMatches ? `room matches ${expectedRoom}` : `expected ${expectedRoom} got ${String(parsed.room)}` });
|
||||
}
|
||||
|
||||
if (expectedUsername) {
|
||||
const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName);
|
||||
const unameMatches = !!uname && String(uname) === String(expectedUsername);
|
||||
results.assertions.push({ name: 'session_username_matches_expected', ok: unameMatches, detail: unameMatches ? `username matches ${expectedUsername}` : `expected ${expectedUsername} got ${String(uname)}` });
|
||||
} else {
|
||||
const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName);
|
||||
results.assertions.push({ name: 'session_username_present', ok: !!uname, detail: !!uname ? `username: ${String(uname)}` : 'username missing' });
|
||||
}
|
||||
|
||||
let ttlSeconds = null;
|
||||
if (parsed && typeof parsed.ttlSeconds === 'number') ttlSeconds = parsed.ttlSeconds;
|
||||
else if (parsed && typeof parsed.ttl === 'number') ttlSeconds = parsed.ttl;
|
||||
else if (parsed && parsed.expiresAt) {
|
||||
const ex = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : Date.parse(String(parsed.expiresAt));
|
||||
if (!Number.isNaN(ex)) ttlSeconds = Math.max(0, Math.floor((ex - Date.now()) / 1000));
|
||||
}
|
||||
if (ttlSeconds !== null) {
|
||||
results.assertions.push({ name: 'session_ttl_seconds', ok: true, detail: `ttlSeconds=${ttlSeconds}` });
|
||||
results.assertions.push({ name: 'session_ttl_minimum', ok: ttlSeconds >= minTtl, detail: `ttl=${ttlSeconds} min=${minTtl}` });
|
||||
} else {
|
||||
results.assertions.push({ name: 'session_ttl_seconds', ok: false, detail: 'ttl not found in session payload' });
|
||||
}
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'session_ttl_check_failed', ok: false, detail: String(e) });
|
||||
}
|
||||
|
||||
if (parsed && (parsed.id || parsed.sessionId)) {
|
||||
const sid = parsed.id || parsed.sessionId;
|
||||
try {
|
||||
const base = (BROADCAST_URL || '').replace(/\/$/, '');
|
||||
const tokenEndpoint = `${base}/api/session/${encodeURIComponent(sid)}/token`;
|
||||
let fetched = null;
|
||||
try {
|
||||
const fetchMod = await import('node-fetch');
|
||||
const r = await fetchMod.default(tokenEndpoint).catch(() => null);
|
||||
if (r && r.ok) fetched = await r.json().catch(() => null);
|
||||
} catch (fe) { fetched = null }
|
||||
if (!fetched) {
|
||||
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.VITE_BACKEND_TOKENS_URL || null;
|
||||
if (TOKEN_SERVER) {
|
||||
const abs = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sid)}/token`;
|
||||
try {
|
||||
const fetchMod2 = await import('node-fetch');
|
||||
const r2 = await fetchMod2.default(abs).catch(() => null);
|
||||
if (r2 && r2.ok) fetched = await r2.json().catch(() => null);
|
||||
} catch (fe2) { fetched = null }
|
||||
}
|
||||
}
|
||||
if (fetched && fetched.token) {
|
||||
results.assertions.push({ name: 'backend_token_fetch', ok: true, detail: 'fetched token from backend' });
|
||||
try {
|
||||
const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30);
|
||||
const bttl = fetched.ttlSeconds || fetched.ttl || null;
|
||||
if (typeof bttl === 'number') {
|
||||
results.assertions.push({ name: 'backend_ttl_seconds', ok: true, detail: `backend ttlSeconds=${bttl}` });
|
||||
results.assertions.push({ name: 'backend_ttl_minimum', ok: bttl >= minTtl, detail: `backend ttl=${bttl} min=${minTtl}` });
|
||||
} else {
|
||||
results.assertions.push({ name: 'backend_ttl_seconds', ok: false, detail: 'backend ttl not present or not numeric' });
|
||||
}
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'backend_ttl_check_failed', ok: false, detail: String(e) });
|
||||
}
|
||||
} else {
|
||||
results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'could not fetch token for session id' });
|
||||
}
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'error: ' + String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
} 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) });
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const outDir = path.resolve(__dirname);
|
||||
const results = { startedAt: new Date().toISOString(), console: [], navigations: [] };
|
||||
@ -92,6 +237,8 @@ const puppeteer = require('puppeteer-core');
|
||||
await studioPage.goto(targetStudioUrl, { waitUntil: 'networkidle2' });
|
||||
results.navigations.push({ type: 'studio_opened', url: studioPage.url() });
|
||||
await studioPage.waitForTimeout(2500);
|
||||
// Run assertions (sessionStorage + backend checks)
|
||||
await evaluateSessionAssertions(page, studioPage, results, BROADCAST_URL, TOKEN);
|
||||
const shot = path.join(outDir, 'studio_flow_browserless_result.png');
|
||||
await studioPage.screenshot({ path: shot, fullPage: true });
|
||||
results.screenshot = shot;
|
||||
@ -125,6 +272,8 @@ const puppeteer = require('puppeteer-core');
|
||||
const directUrl = `${STUDIO_URL}?token=${encodeURIComponent(TOKEN)}`;
|
||||
await page.goto(directUrl, { waitUntil: 'networkidle2' });
|
||||
await page.waitForTimeout(1500);
|
||||
// Run assertions using current page as both broadcast and studio contexts
|
||||
await evaluateSessionAssertions(page, page, results, BROADCAST_URL, TOKEN);
|
||||
const shot = path.join(outDir, 'studio_flow_browserless_result.png');
|
||||
await page.screenshot({ path: shot, fullPage: true });
|
||||
results.screenshot = shot;
|
||||
|
||||
@ -116,18 +116,149 @@ async function getWsEndpointFromHost(host) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
// Accept any token returned by backend; if TOKEN env is set we also note if it matches
|
||||
// Basic presence checks
|
||||
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 });
|
||||
|
||||
// Token format: JWT-like (3 parts)
|
||||
try {
|
||||
const parts = String(parsed.token).split('.');
|
||||
const isJwt = parts.length >= 3;
|
||||
results.assertions.push({ name: 'token_format_jwt', ok: isJwt, detail: isJwt ? 'looks like JWT' : 'not JWT-like' });
|
||||
if (isJwt) {
|
||||
// decode payload safely (base64url)
|
||||
const payloadB64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const pad = '='.repeat((4 - (payloadB64.length % 4)) % 4);
|
||||
const decoded = Buffer.from(payloadB64 + pad, 'base64').toString('utf8');
|
||||
try {
|
||||
const payloadObj = JSON.parse(decoded);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
if (payloadObj.exp && typeof payloadObj.exp === 'number') {
|
||||
results.assertions.push({ name: 'token_not_expired', ok: payloadObj.exp > nowSec, detail: `exp=${payloadObj.exp} now=${nowSec}` });
|
||||
} else {
|
||||
results.assertions.push({ name: 'token_has_exp', ok: false, detail: 'exp missing or not a number' });
|
||||
}
|
||||
if (payloadObj.room) results.assertions.push({ name: 'token_payload_room', ok: true, detail: String(payloadObj.room) });
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'token_payload_parse', ok: false, detail: 'failed to parse payload: ' + String(e) });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'token_format_check_failed', ok: false, detail: String(e) });
|
||||
}
|
||||
} 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' });
|
||||
}
|
||||
|
||||
// Check room and url fields if present
|
||||
if (parsed && parsed.room) {
|
||||
results.assertions.push({ name: 'session_room_present', ok: true, detail: String(parsed.room) });
|
||||
} else {
|
||||
results.assertions.push({ name: 'session_room_present', ok: false, detail: 'room missing in session payload' });
|
||||
}
|
||||
if (parsed && (parsed.url || parsed.serverUrl)) {
|
||||
const serverUrl = parsed.url || parsed.serverUrl;
|
||||
const okUrl = typeof serverUrl === 'string' && (serverUrl.startsWith('ws://') || serverUrl.startsWith('wss://') || serverUrl.startsWith('http://') || serverUrl.startsWith('https://'));
|
||||
results.assertions.push({ name: 'session_serverUrl_valid', ok: okUrl, detail: okUrl ? String(serverUrl) : 'invalid or missing serverUrl' });
|
||||
} else {
|
||||
results.assertions.push({ name: 'session_serverUrl_valid', ok: false, detail: 'serverUrl missing' });
|
||||
}
|
||||
|
||||
// Additional optional assertions: username and TTL
|
||||
try {
|
||||
const expectedRoom = process.env.EXPECTED_ROOM || null;
|
||||
const expectedUsername = process.env.EXPECTED_USERNAME || null;
|
||||
const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30);
|
||||
|
||||
if (expectedRoom) {
|
||||
const roomMatches = !!parsed && String(parsed.room) === String(expectedRoom);
|
||||
results.assertions.push({ name: 'session_room_matches_expected', ok: roomMatches, detail: roomMatches ? `room matches ${expectedRoom}` : `expected ${expectedRoom} got ${String(parsed.room)}` });
|
||||
}
|
||||
|
||||
if (expectedUsername) {
|
||||
const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName);
|
||||
const unameMatches = !!uname && String(uname) === String(expectedUsername);
|
||||
results.assertions.push({ name: 'session_username_matches_expected', ok: unameMatches, detail: unameMatches ? `username matches ${expectedUsername}` : `expected ${expectedUsername} got ${String(uname)}` });
|
||||
} else {
|
||||
// still assert presence of username if not explicitly expected
|
||||
const uname = parsed && (parsed.username || parsed.user || parsed.participantName || parsed.participant || parsed.participantName);
|
||||
results.assertions.push({ name: 'session_username_present', ok: !!uname, detail: !!uname ? `username: ${String(uname)}` : 'username missing' });
|
||||
}
|
||||
|
||||
// TTL checks: looks for ttlSeconds, ttl or expiresAt
|
||||
let ttlSeconds = null;
|
||||
if (parsed && typeof parsed.ttlSeconds === 'number') ttlSeconds = parsed.ttlSeconds;
|
||||
else if (parsed && typeof parsed.ttl === 'number') ttlSeconds = parsed.ttl;
|
||||
else if (parsed && parsed.expiresAt) {
|
||||
// expiresAt may be timestamp or Date string
|
||||
const ex = typeof parsed.expiresAt === 'number' ? parsed.expiresAt : Date.parse(String(parsed.expiresAt));
|
||||
if (!Number.isNaN(ex)) ttlSeconds = Math.max(0, Math.floor((ex - Date.now()) / 1000));
|
||||
}
|
||||
if (ttlSeconds !== null) {
|
||||
results.assertions.push({ name: 'session_ttl_seconds', ok: true, detail: `ttlSeconds=${ttlSeconds}` });
|
||||
results.assertions.push({ name: 'session_ttl_minimum', ok: ttlSeconds >= minTtl, detail: `ttl=${ttlSeconds} min=${minTtl}` });
|
||||
} else {
|
||||
results.assertions.push({ name: 'session_ttl_seconds', ok: false, detail: 'ttl not found in session payload' });
|
||||
}
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'session_ttl_check_failed', ok: false, detail: String(e) });
|
||||
}
|
||||
|
||||
// If there's an id/sessionId available, try to query backend token endpoint for extra validation
|
||||
if (parsed && (parsed.id || parsed.sessionId)) {
|
||||
const sid = parsed.id || parsed.sessionId;
|
||||
try {
|
||||
// attempt to fetch from broadcast origin first
|
||||
const base = (BROADCAST_URL || '').replace(/\/$/, '');
|
||||
const tokenEndpoint = `${base}/api/session/${encodeURIComponent(sid)}/token`;
|
||||
let fetched = null;
|
||||
try {
|
||||
const r = await (await import('node-fetch'))(tokenEndpoint);
|
||||
if (r && r.ok) fetched = await r.json().catch(() => null);
|
||||
} catch (fe) {
|
||||
// ignore - will try absolute later
|
||||
fetched = null;
|
||||
}
|
||||
if (!fetched) {
|
||||
// try absolute token server env if available
|
||||
const TOKEN_SERVER = process.env.TOKEN_SERVER_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.VITE_BACKEND_TOKENS_URL || null;
|
||||
if (TOKEN_SERVER) {
|
||||
const abs = `${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(sid)}/token`;
|
||||
try {
|
||||
const r2 = await (await import('node-fetch'))(abs, { mode: 'cors' });
|
||||
if (r2 && r2.ok) fetched = await r2.json().catch(() => null);
|
||||
} catch (fe2) { fetched = null }
|
||||
}
|
||||
}
|
||||
if (fetched && fetched.token) {
|
||||
results.assertions.push({ name: 'backend_token_fetch', ok: true, detail: 'fetched token from backend' });
|
||||
// If backend returned ttlSeconds, validate against minTtl
|
||||
try {
|
||||
const minTtl = Number(process.env.MIN_TTL_SECONDS || process.env.MIN_TTL || 30);
|
||||
const bttl = fetched.ttlSeconds || fetched.ttl || null;
|
||||
if (typeof bttl === 'number') {
|
||||
results.assertions.push({ name: 'backend_ttl_seconds', ok: true, detail: `backend ttlSeconds=${bttl}` });
|
||||
results.assertions.push({ name: 'backend_ttl_minimum', ok: bttl >= minTtl, detail: `backend ttl=${bttl} min=${minTtl}` });
|
||||
} else {
|
||||
results.assertions.push({ name: 'backend_ttl_seconds', ok: false, detail: 'backend ttl not present or not numeric' });
|
||||
}
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'backend_ttl_check_failed', ok: false, detail: String(e) });
|
||||
}
|
||||
} else {
|
||||
results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'could not fetch token for session id' });
|
||||
}
|
||||
} catch (e) {
|
||||
results.assertions.push({ name: 'backend_token_fetch', ok: false, detail: 'error: ' + String(e) });
|
||||
}
|
||||
}
|
||||
} 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) });
|
||||
}
|
||||
|
||||
38
livekit-server.bfzqqk.easypanel.host/caddy.yaml
Normal file
@ -0,0 +1,38 @@
|
||||
logging:
|
||||
logs:
|
||||
default:
|
||||
level: INFO
|
||||
storage:
|
||||
"module": "file_system"
|
||||
"root": "/data"
|
||||
apps:
|
||||
tls:
|
||||
certificates:
|
||||
automate:
|
||||
- livekit-server.bfzqqk.easypanel.host
|
||||
- nextream.sytes.net
|
||||
layer4:
|
||||
servers:
|
||||
main:
|
||||
listen: [":443"]
|
||||
routes:
|
||||
- match:
|
||||
- tls:
|
||||
sni:
|
||||
- "nextream.sytes.net"
|
||||
handle:
|
||||
- handler: tls
|
||||
- handler: proxy
|
||||
upstreams:
|
||||
- dial: ["localhost:5349"]
|
||||
- match:
|
||||
- tls:
|
||||
sni:
|
||||
- "livekit-server.bfzqqk.easypanel.host"
|
||||
handle:
|
||||
- handler: tls
|
||||
connection_policies:
|
||||
- alpn: ["http/1.1"]
|
||||
- handler: proxy
|
||||
upstreams:
|
||||
- dial: ["localhost:7880"]
|
||||
18
livekit-server.bfzqqk.easypanel.host/docker-compose.yaml
Normal file
@ -0,0 +1,18 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
container_name: livekit-server
|
||||
restart: always
|
||||
volumes:
|
||||
# Monta tu archivo de configuración
|
||||
- ./livekit.yaml:/etc/livekit/livekit.yaml
|
||||
command:
|
||||
# Asegura que LiveKit use el archivo de configuración montado
|
||||
- --config
|
||||
- /etc/livekit/livekit.yaml
|
||||
ports:
|
||||
# Mapeo del puerto TCP para la señalización (Proxy <-- Docker)
|
||||
- "7880:7880"
|
||||
# Mapeo del rango de puertos UDP para el tráfico de medios (¡CRUCIAL!)
|
||||
- "40000-40200:40000-40200/udp"
|
||||
18
livekit-server.bfzqqk.easypanel.host/docker-compose.yaml_bak
Normal file
@ -0,0 +1,18 @@
|
||||
# This docker-compose requires host networking, which is only available on Linux
|
||||
# This compose will not function correctly on Mac or Windows
|
||||
services:
|
||||
# caddy:
|
||||
# image: livekit/caddyl4
|
||||
# command: run --config /etc/caddy.yaml --adapter yaml
|
||||
# restart: unless-stopped
|
||||
# network_mode: "host"
|
||||
# volumes:
|
||||
# - ./caddy.yaml:/etc/caddy.yaml
|
||||
# - ./caddy_data:/data
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --config /etc/livekit.yaml
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml
|
||||
156
livekit-server.bfzqqk.easypanel.host/init_script.sh
Normal file
@ -0,0 +1,156 @@
|
||||
#!/bin/sh
|
||||
# This script will write all of your configurations to /opt/livekit.
|
||||
# It'll also install LiveKit as a systemd service that will run at startup
|
||||
# LiveKit will be started automatically at machine startup.
|
||||
|
||||
# create directories for LiveKit
|
||||
mkdir -p /opt/livekit/caddy_data
|
||||
mkdir -p /usr/local/bin
|
||||
|
||||
# Docker & Docker Compose will need to be installed on the machine
|
||||
curl -fsSL https://get.docker.com -o /tmp/get-docker.sh
|
||||
sh /tmp/get-docker.sh
|
||||
curl -L "https://github.com/docker/compose/releases/download/v2.20.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
chmod 755 /usr/local/bin/docker-compose
|
||||
|
||||
sudo systemctl enable docker
|
||||
|
||||
# livekit config
|
||||
cat << EOF > /opt/livekit/livekit.yaml
|
||||
port: 7880
|
||||
bind_addresses:
|
||||
- ""
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
use_external_ip: true
|
||||
enable_loopback_candidate: false
|
||||
redis:
|
||||
address: <redis-host>:6379
|
||||
username: ""
|
||||
password: ""
|
||||
db: 0
|
||||
use_tls: false
|
||||
sentinel_master_name: ""
|
||||
sentinel_username: ""
|
||||
sentinel_password: ""
|
||||
sentinel_addresses: []
|
||||
cluster_addresses: []
|
||||
max_redirects: null
|
||||
turn:
|
||||
enabled: true
|
||||
domain: nextream.sytes.net
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
external_tls: true
|
||||
keys:
|
||||
APIBTqTGxf9htMK: 0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW
|
||||
|
||||
|
||||
EOF
|
||||
|
||||
# caddy config
|
||||
cat << EOF > /opt/livekit/caddy.yaml
|
||||
logging:
|
||||
logs:
|
||||
default:
|
||||
level: INFO
|
||||
storage:
|
||||
"module": "file_system"
|
||||
"root": "/data"
|
||||
apps:
|
||||
tls:
|
||||
certificates:
|
||||
automate:
|
||||
- livekit-server.bfzqqk.easypanel.host
|
||||
- nextream.sytes.net
|
||||
layer4:
|
||||
servers:
|
||||
main:
|
||||
listen: [":443"]
|
||||
routes:
|
||||
- match:
|
||||
- tls:
|
||||
sni:
|
||||
- "nextream.sytes.net"
|
||||
handle:
|
||||
- handler: tls
|
||||
- handler: proxy
|
||||
upstreams:
|
||||
- dial: ["localhost:5349"]
|
||||
- match:
|
||||
- tls:
|
||||
sni:
|
||||
- "livekit-server.bfzqqk.easypanel.host"
|
||||
handle:
|
||||
- handler: tls
|
||||
connection_policies:
|
||||
- alpn: ["http/1.1"]
|
||||
- handler: proxy
|
||||
upstreams:
|
||||
- dial: ["localhost:7880"]
|
||||
|
||||
|
||||
EOF
|
||||
|
||||
# update ip script
|
||||
cat << "EOF" > /opt/livekit/update_ip.sh
|
||||
#!/usr/bin/env bash
|
||||
ip=`ip addr show |grep "inet " |grep -v 127.0.0. |head -1|cut -d" " -f6|cut -d/ -f1`
|
||||
sed -i.orig -r "s/\\\"(.+)(\:5349)/\\\"$ip\2/" /opt/livekit/caddy.yaml
|
||||
|
||||
|
||||
EOF
|
||||
|
||||
# docker compose
|
||||
cat << EOF > /opt/livekit/docker-compose.yaml
|
||||
# This docker-compose requires host networking, which is only available on Linux
|
||||
# This compose will not function correctly on Mac or Windows
|
||||
services:
|
||||
caddy:
|
||||
image: livekit/caddyl4
|
||||
command: run --config /etc/caddy.yaml --adapter yaml
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
- ./caddy.yaml:/etc/caddy.yaml
|
||||
- ./caddy_data:/data
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --config /etc/livekit.yaml
|
||||
restart: unless-stopped
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
- ./livekit.yaml:/etc/livekit.yaml
|
||||
|
||||
|
||||
EOF
|
||||
|
||||
# systemd file
|
||||
cat << EOF > /etc/systemd/system/livekit-docker.service
|
||||
[Unit]
|
||||
Description=LiveKit Server Container
|
||||
After=docker.service
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
LimitNOFILE=500000
|
||||
Restart=always
|
||||
WorkingDirectory=/opt/livekit
|
||||
# Shutdown container (if running) when unit is started
|
||||
ExecStartPre=/usr/local/bin/docker-compose -f docker-compose.yaml down
|
||||
ExecStart=/usr/local/bin/docker-compose -f docker-compose.yaml up
|
||||
ExecStop=/usr/local/bin/docker-compose -f docker-compose.yaml down
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
|
||||
EOF
|
||||
|
||||
chmod 755 /opt/livekit/update_ip.sh
|
||||
/opt/livekit/update_ip.sh
|
||||
|
||||
systemctl enable livekit-docker
|
||||
systemctl start livekit-docker
|
||||
29
livekit-server.bfzqqk.easypanel.host/livekit.yaml
Normal file
@ -0,0 +1,29 @@
|
||||
port: 7880
|
||||
bind_addresses:
|
||||
- ""
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
port_range_start: 40000
|
||||
port_range_end: 40200
|
||||
use_external_ip: true
|
||||
enable_loopback_candidate: false
|
||||
redis:
|
||||
address: 192.168.1.20:6380
|
||||
username: "default"
|
||||
password: "52a4a5b5efdd2ac4a8fd"
|
||||
db: 0
|
||||
use_tls: false
|
||||
sentinel_master_name: ""
|
||||
sentinel_username: ""
|
||||
sentinel_password: ""
|
||||
sentinel_addresses: []
|
||||
cluster_addresses: []
|
||||
max_redirects: null
|
||||
turn:
|
||||
enabled: true
|
||||
domain: nextream.sytes.net
|
||||
tls_port: 5349
|
||||
udp_port: 3478
|
||||
external_tls: true
|
||||
keys:
|
||||
APIBTqTGxf9htMK: 0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW
|
||||
22
livekit-server.bfzqqk.easypanel.host/livekit.yaml_bak2
Normal file
@ -0,0 +1,22 @@
|
||||
# 1. Configuración de API Key/Secret
|
||||
api:
|
||||
key: "APIBTqTGxf9htMK"
|
||||
secret: "0dOHWPffwneaPg7OYpe4PeAes21zLJfeYJB9cKzSTtXW"
|
||||
|
||||
# 2. Configuración RTC y Medios (¡Crucial!)
|
||||
rtc:
|
||||
# La URL que el cliente usará para conectarse (a través de tu proxy)
|
||||
# Debe ser ws:// o wss:// dependiendo de tu proxy
|
||||
url: "wss://livekit-server.bfzqqk.easypanel.host"
|
||||
|
||||
# La IP interna en la que LiveKit escuchará el tráfico de medios (ICE/TURN)
|
||||
# Usa 0.0.0.0 para escuchar en todas las interfaces del contenedor
|
||||
rtc_ip: "0.0.0.0"
|
||||
|
||||
# El rango de puertos UDP que LiveKit usará. ¡Debes mapear estos puertos en Docker!
|
||||
port_range:
|
||||
start: 40000
|
||||
end: 40200
|
||||
|
||||
# 3. Configuración de HTTP/Websocket (Señalización)
|
||||
port: 7880 # El puerto interno de LiveKit que expondremos al proxy
|
||||
660
package-lock.json
generated
@ -18,6 +18,8 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm:dev:*\"",
|
||||
"visual-test:prejoin": "node scripts/visual_test_prejoin.cjs",
|
||||
"e2e:remote-chrome": "bash e2e/run-remote-chrome.sh",
|
||||
"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",
|
||||
@ -36,7 +38,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2",
|
||||
"playwright": "^1.51.0",
|
||||
"pixelmatch": "^7.1.0",
|
||||
"playwright": "^1.56.1",
|
||||
"pngjs": "^7.0.0",
|
||||
"puppeteer": "^24.31.0",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"engines": {
|
||||
@ -44,7 +49,6 @@
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^19.11.1",
|
||||
"puppeteer-core": "^24.30.0",
|
||||
"react-icons": "^5.5.0"
|
||||
}
|
||||
|
||||
0
packages/avanza-ui/.storybook/main.ts
Normal file
0
packages/avanza-ui/.storybook/manager.ts
Normal file
0
packages/avanza-ui/.storybook/preview.ts
Normal file
@ -1,19 +1,102 @@
|
||||
# avanza-ui
|
||||
|
||||
Librería de componentes reutilizables para AvanzaCast.
|
||||
Paquete de componentes UI usado por AvanzaCast.
|
||||
|
||||
Componentes añadidos en esta entrega:
|
||||
Este README explica el flujo de build y las instrucciones rápidas para desarrollar, compilar y depurar `avanza-ui` en este monorepo.
|
||||
|
||||
- `ControlButton` - botón redondo con icono y etiqueta opcional (tamaños: sm|md|lg)
|
||||
- `IconButton` - botón icon-only para acciones rápidas
|
||||
- `ControlGroup` - contenedor para agrupar controles
|
||||
- `ControlBar` - barra de controles centrada que usa `ControlGroup`
|
||||
## Propósito
|
||||
|
||||
Importar desde otros paquetes:
|
||||
`avanza-ui` contiene componentes React reutilizables (Button, Modal, VideoTile, StudioHeader, etc.) y sus estilos. El artefacto compilado queda en `dist/` y es consumido por otras aplicaciones del monorepo (por ejemplo `broadcast-panel`).
|
||||
|
||||
```ts
|
||||
import { ControlButton, IconButton, ControlGroup, ControlBar } from 'avanza-ui'
|
||||
## Resumen del flujo de build
|
||||
|
||||
- Usamos Rollup para compilar el código TS/TSX a `dist/index.cjs.js` y `dist/index.esm.js`.
|
||||
- `postcss` genera CSS extracted (archivo global) y `rollup-plugin-copy` copia desde `src/components/**/*.module.css` a `dist/components/` y `src/styles/**/*.css` a `dist/styles/` para que las importaciones relativas en los archivos emitidos en `dist` (p. ej. `@import '../styles/globals.css'`) resuelvan correctamente en tiempo de ejecución.
|
||||
- Se dejó un script `scripts/copy-css.js` como respaldo, pero está marcado como obsoleto; la copia la realiza `rollup-plugin-copy` en el build normal.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Node.js (recomendado >= 18)
|
||||
- npm
|
||||
|
||||
## Comandos útiles
|
||||
|
||||
Instalar dependencias del paquete:
|
||||
|
||||
```bash
|
||||
cd packages/avanza-ui
|
||||
npm install
|
||||
```
|
||||
|
||||
Los estilos se importan como efecto secundario al importar `avanza-ui` (archivo `controls.css`).
|
||||
Construir (producción / CI):
|
||||
|
||||
```bash
|
||||
cd packages/avanza-ui
|
||||
npm run build
|
||||
```
|
||||
|
||||
- `rollup -c` compila el paquete y `rollup-plugin-copy` copiará automáticamente los CSS modules y estilos globales a `dist`.
|
||||
|
||||
Modo desarrollo (watch):
|
||||
|
||||
```bash
|
||||
cd packages/avanza-ui
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Verificar que los CSS aparezcan en dist:
|
||||
|
||||
```bash
|
||||
ls -la packages/avanza-ui/dist/components | head -n 40
|
||||
ls -la packages/avanza-ui/dist/styles
|
||||
```
|
||||
|
||||
Levantar el `broadcast-panel` para probar la UI integrada:
|
||||
|
||||
```bash
|
||||
cd packages/broadcast-panel
|
||||
npm run dev
|
||||
# abrir http://localhost:5176/ (o el puerto que Vite muestre)
|
||||
```
|
||||
|
||||
## Notas y troubleshooting
|
||||
|
||||
- Si ves warnings de Node como `MODULE_TYPELESS_PACKAGE_JSON` al ejecutar `npm run build`, puedes añadir `"type": "module"` a `packages/avanza-ui/package.json` para evitar que Node reanalice el `rollup.config.js` como CommonJS.
|
||||
|
||||
- Si aparece un error tipo `ENOENT: no such file or directory, open '../styles/globals.css'` significa que `dist/styles/globals.css` no existe. Ejecuta `npm run build` en `packages/avanza-ui` y verifica que `dist/styles/globals.css` y `dist/components/*.module.css` estén presentes.
|
||||
|
||||
- El script `packages/avanza-ui/scripts/copy-css.js` existe como respaldo histórico. Ya está marcado como "deprecated" y no se ejecuta en el flujo por defecto. Si prefieres eliminarlo, puedes borrarlo del repositorio.
|
||||
|
||||
## Integración en CI
|
||||
|
||||
Asegúrate de que el job que construye la aplicación principal (o que publica paquetes) ejecute:
|
||||
|
||||
```bash
|
||||
cd packages/avanza-ui
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
Esto garantiza que los artefactos `dist` contienen tanto los JS compilados como los CSS necesarios.
|
||||
|
||||
## Limpieza / regeneración de dist
|
||||
|
||||
Para forzar una reconstrucción limpia:
|
||||
|
||||
```bash
|
||||
cd packages/avanza-ui
|
||||
rm -rf dist node_modules
|
||||
npm ci
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Contribuciones
|
||||
|
||||
Si agregas nuevos componentes que usan `*.module.css` o nuevos archivos en `src/styles/`, asegúrate de que las rutas relativas usadas en los ficheros emitidos en `dist` coincidan con la estructura que `rollup-plugin-copy` genera (`dist/components` y `dist/styles`).
|
||||
|
||||
---
|
||||
Si quieres, puedo:
|
||||
- añadir una nota al `README.md` de la raíz del repo explicando el cambio, o
|
||||
- abrir un commit/PR con estos cambios documentados.
|
||||
|
||||
Dime qué prefieres y lo hago ahora.
|
||||
|
||||
0
packages/avanza-ui/public/.gitkeep
Normal file
38
packages/avanza-ui/rollup.config.js
Normal file
@ -0,0 +1,38 @@
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
import resolve from '@rollup/plugin-node-resolve';
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import typescript from '@rollup/plugin-typescript';
|
||||
import postcss from 'rollup-plugin-postcss';
|
||||
import copy from 'rollup-plugin-copy';
|
||||
|
||||
export default {
|
||||
input: 'src/index.ts',
|
||||
output: [
|
||||
{
|
||||
file: 'dist/index.cjs.js',
|
||||
format: 'cjs',
|
||||
sourcemap: true,
|
||||
},
|
||||
{
|
||||
file: 'dist/index.esm.js',
|
||||
format: 'es',
|
||||
sourcemap: true,
|
||||
},
|
||||
],
|
||||
plugins: [
|
||||
peerDepsExternal(),
|
||||
resolve({ extensions: ['.js', '.ts', '.tsx'] }),
|
||||
commonjs(),
|
||||
typescript({ tsconfig: './tsconfig.json' }),
|
||||
postcss({ extract: true, minimize: true }),
|
||||
// copy CSS module files and global styles to dist so relative @import paths resolve
|
||||
copy({
|
||||
targets: [
|
||||
{ src: 'src/components/**/*.module.css', dest: 'dist/components' },
|
||||
{ src: 'src/styles/**/*.css', dest: 'dist/styles' }
|
||||
],
|
||||
verbose: true,
|
||||
flatten: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
5
packages/avanza-ui/scripts/copy-css.js
Normal file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
// Deprecated: rollup-plugin-copy now handles copying CSS files into dist.
|
||||
// Keeping this script for backward compatibility/history. It will not perform any action.
|
||||
console.log('scripts/copy-css.js is deprecated; rollup-plugin-copy handles CSS copying now.');
|
||||
process.exit(0);
|
||||
@ -8,7 +8,7 @@ export interface ControlBarProps {
|
||||
|
||||
export const ControlBar: React.FC<ControlBarProps> = ({ children, className }) => {
|
||||
return (
|
||||
<div style={{display:'flex',justifyContent:'center',padding:'8px'}} className={className}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }} className={className}>
|
||||
<ControlGroup className="controls-inner">
|
||||
{children}
|
||||
</ControlGroup>
|
||||
|
||||
@ -1,80 +1,30 @@
|
||||
.controlButton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* ...existing code... */
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: linear-gradient(135deg, var(--au-gray-700) 0%, var(--au-gray-800) 100%);
|
||||
border: 2px solid var(--au-border-dark);
|
||||
color: var(--au-text-primary);
|
||||
gap: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: var(--au-radius-full);
|
||||
transition: all var(--au-transition-fast);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
position: relative;
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 24px;
|
||||
outline: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
color: #1f2937;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.controlButton:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, var(--au-gray-600) 0%, var(--au-gray-700) 100%);
|
||||
border-color: var(--au-primary);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.row { flex-direction: row; }
|
||||
.column { flex-direction: column; }
|
||||
|
||||
.controlButton:active:not(:disabled) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.default { background: rgba(0,0,0,0.03); }
|
||||
.studio { background: #fff; box-shadow: 0 1px 0 rgba(0,0,0,0.02); }
|
||||
|
||||
.controlButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.active { box-shadow: 0 0 0 2px rgba(59,130,246,0.2); }
|
||||
.danger { color: #ef4444; }
|
||||
|
||||
.controlButton.active {
|
||||
background: linear-gradient(135deg, var(--au-primary) 0%, var(--au-primary-hover) 100%);
|
||||
border-color: var(--au-primary);
|
||||
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.5);
|
||||
}
|
||||
.icon { display: inline-flex; align-items: center; justify-content: center; }
|
||||
.label { display: inline-block; }
|
||||
|
||||
.controlButton.danger {
|
||||
background: linear-gradient(135deg, var(--au-danger-600) 0%, var(--au-danger-700) 100%);
|
||||
border-color: var(--au-danger-600);
|
||||
}
|
||||
|
||||
.controlButton.danger:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, var(--au-danger-500) 0%, var(--au-danger-600) 100%);
|
||||
box-shadow: 0 6px 20px rgba(220, 38, 38, 0.5);
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.sm {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.md {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.lg {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.controlButtonLabel {
|
||||
font-size: 10px;
|
||||
font-weight: var(--au-font-medium);
|
||||
margin-top: 2px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.sm { padding: 6px 8px; font-size: 12px; }
|
||||
.md { padding: 8px 12px; font-size: 14px; }
|
||||
.lg { padding: 10px 14px; font-size: 16px; }
|
||||
|
||||
|
||||
@ -1,55 +1,50 @@
|
||||
import React from 'react';
|
||||
import { cn } from '../utils/helpers';
|
||||
import type { ComponentBaseProps } from '../types';
|
||||
import styles from './ControlButton.module.css';
|
||||
// ...existing code...
|
||||
import React from 'react'
|
||||
import cx from 'clsx'
|
||||
import styles from './ControlButton.module.css'
|
||||
|
||||
export interface ControlButtonProps extends ComponentBaseProps {
|
||||
icon?: React.ReactNode;
|
||||
label?: string;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
export type ControlButtonProps = {
|
||||
className?: string
|
||||
icon?: React.ReactNode
|
||||
label?: string
|
||||
active?: boolean
|
||||
danger?: boolean
|
||||
layout?: 'row' | 'column'
|
||||
variant?: 'studio' | 'default'
|
||||
onClick?: () => void
|
||||
hint?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export const ControlButton: React.FC<ControlButtonProps> = (props) => {
|
||||
const {
|
||||
icon,
|
||||
label,
|
||||
active = false,
|
||||
danger = false,
|
||||
size = 'md',
|
||||
onClick,
|
||||
disabled = false,
|
||||
title,
|
||||
className,
|
||||
style,
|
||||
id,
|
||||
} = props;
|
||||
|
||||
export const ControlButton: React.FC<ControlButtonProps> = ({
|
||||
className,
|
||||
icon,
|
||||
label,
|
||||
active = false,
|
||||
danger = false,
|
||||
layout = 'row',
|
||||
variant = 'default',
|
||||
onClick,
|
||||
hint,
|
||||
size = 'md'
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
styles.controlButton,
|
||||
styles[size],
|
||||
active && styles.active,
|
||||
danger && styles.danger,
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
style={style}
|
||||
id={id}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cx(styles.root, className, styles[layout], styles[variant], styles[size], {
|
||||
[styles.active]: active,
|
||||
[styles.danger]: danger
|
||||
})}
|
||||
aria-pressed={active}
|
||||
title={hint}
|
||||
>
|
||||
{icon && <span>{icon}</span>}
|
||||
{label && <span className={styles.controlButtonLabel}>{label}</span>}
|
||||
<span className={styles.icon}>{icon}</span>
|
||||
{label && <span className={styles.label}>{label}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
ControlButton.displayName = 'ControlButton';
|
||||
// default export for convenience
|
||||
export default ControlButton
|
||||
|
||||
|
||||
15
packages/avanza-ui/src/components/MicrophoneMeter.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
// ...existing code...
|
||||
import React from 'react'
|
||||
|
||||
export type MicrophoneMeterProps = { level?: number }
|
||||
|
||||
export const MicrophoneMeter: React.FC<MicrophoneMeterProps> = ({ level = 0 }) => {
|
||||
return (
|
||||
<div style={{ width: 48, height: 24, background: '#eee', borderRadius: 6 }} aria-hidden>
|
||||
<div style={{ width: `${Math.min(100, Math.max(0, level * 100))}%`, height: '100%', background: '#10b981', borderRadius: 6 }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MicrophoneMeter
|
||||
|
||||
@ -67,10 +67,13 @@ export type { StudioHeaderProps } from './StudioHeader';
|
||||
|
||||
export { ControlButton } from './ControlButton';
|
||||
export type { ControlButtonProps } from './ControlButton';
|
||||
export { ControlBar } from './ControlBar';
|
||||
export { ControlGroup } from './ControlGroup';
|
||||
export { IconButton } from './IconButton';
|
||||
export { MicrophoneMeter } from './MicrophoneMeter';
|
||||
|
||||
export { SceneCard } from './SceneCard';
|
||||
export type { SceneCardProps } from './SceneCard';
|
||||
|
||||
export { VideoTile } from './VideoTile';
|
||||
export type { VideoTileProps, ConnectionQuality } from './VideoTile';
|
||||
|
||||
|
||||
@ -1,92 +1,3 @@
|
||||
// Styles
|
||||
import './styles/globals.css';
|
||||
import './styles/controls.css';
|
||||
|
||||
// Components
|
||||
export { Button } from './components/Button';
|
||||
export type { ButtonProps } from './components/Button';
|
||||
|
||||
export { Card, CardHeader, CardBody, CardFooter } from './components/Card';
|
||||
export type { CardProps, CardSectionProps } from './components/Card';
|
||||
|
||||
export { Input } from './components/Input';
|
||||
export type { InputProps } from './components/Input';
|
||||
|
||||
export { Textarea } from './components/Textarea';
|
||||
export type { TextareaProps } from './components/Textarea';
|
||||
|
||||
export { Select } from './components/Select';
|
||||
export type { SelectProps, SelectOption } from './components/Select';
|
||||
|
||||
export { Checkbox } from './components/Checkbox';
|
||||
export type { CheckboxProps } from './components/Checkbox';
|
||||
|
||||
export { Radio } from './components/Radio';
|
||||
export type { RadioProps } from './components/Radio';
|
||||
|
||||
export { Switch } from './components/Switch';
|
||||
export type { SwitchProps } from './components/Switch';
|
||||
|
||||
export { Dropdown, DropdownItem, DropdownDivider, DropdownHeader } from './components/Dropdown';
|
||||
export type { DropdownProps, DropdownItemProps } from './components/Dropdown';
|
||||
|
||||
export { Modal, ModalHeader, ModalBody, ModalFooter } from './components/Modal';
|
||||
export type { ModalProps, ModalSectionProps, ModalHeaderProps } from './components/Modal';
|
||||
|
||||
export { Tooltip } from './components/Tooltip';
|
||||
export type { TooltipProps } from './components/Tooltip';
|
||||
|
||||
export { Avatar } from './components/Avatar';
|
||||
export type { AvatarProps } from './components/Avatar';
|
||||
|
||||
export { Badge } from './components/Badge';
|
||||
export type { BadgeProps } from './components/Badge';
|
||||
|
||||
export { Spinner } from './components/Spinner';
|
||||
export type { SpinnerProps } from './components/Spinner';
|
||||
|
||||
export { Alert } from './components/Alert';
|
||||
export type { AlertProps } from './components/Alert';
|
||||
|
||||
export { Tabs } from './components/Tabs';
|
||||
export type { TabsProps, Tab } from './components/Tabs';
|
||||
|
||||
export { Accordion } from './components/Accordion';
|
||||
export type { AccordionProps, AccordionItem } from './components/Accordion';
|
||||
|
||||
export { Breadcrumb } from './components/Breadcrumb';
|
||||
export type { BreadcrumbProps, BreadcrumbItem } from './components/Breadcrumb';
|
||||
|
||||
export { Progress } from './components/Progress';
|
||||
export type { ProgressProps } from './components/Progress';
|
||||
|
||||
export { Pagination } from './components/Pagination';
|
||||
export type { PaginationProps } from './components/Pagination';
|
||||
|
||||
// Studio Components
|
||||
export { StudioHeader } from './components/StudioHeader';
|
||||
export type { StudioHeaderProps } from './components/StudioHeader';
|
||||
|
||||
export { ControlButton } from './components/ControlButton';
|
||||
export type { ControlButtonProps } from './components/ControlButton';
|
||||
|
||||
export { ControlGroup } from './components/ControlGroup';
|
||||
export type { ControlGroupProps } from './components/ControlGroup';
|
||||
|
||||
export { ControlBar } from './components/ControlBar';
|
||||
export type { ControlBarProps } from './components/ControlBar';
|
||||
|
||||
export { IconButton } from './components/IconButton';
|
||||
export type { IconButtonProps } from './components/IconButton';
|
||||
|
||||
export { SceneCard } from './components/SceneCard';
|
||||
export type { SceneCardProps } from './components/SceneCard';
|
||||
|
||||
export { VideoTile } from './components/VideoTile';
|
||||
export type { VideoTileProps, ConnectionQuality } from './components/VideoTile';
|
||||
|
||||
// Types
|
||||
export type { ButtonVariant, ButtonSize, Theme, ComponentBaseProps } from './types';
|
||||
|
||||
// Utils
|
||||
export { cn, formatDate, generateId, debounce, throttle } from './utils/helpers';
|
||||
export * from './components'
|
||||
export * from './types'
|
||||
export * from './utils/platform'
|
||||
|
||||
@ -1,154 +1,14 @@
|
||||
/* avanza-ui global tokens and resets */
|
||||
:root{
|
||||
/* Colors */
|
||||
--au-gray-950: #0b1220;
|
||||
--au-gray-900: #0f172a;
|
||||
--au-gray-800: #111827;
|
||||
--au-gray-700: #1f2937;
|
||||
--au-gray-600: #374151;
|
||||
--au-gray-600-2: #4b5563;
|
||||
|
||||
/* Placeholder globals used by components during build */
|
||||
:root {
|
||||
--au-primary: #4f46e5;
|
||||
--au-primary-hover: #4338ca;
|
||||
--au-success-500: #10b981;
|
||||
--au-warning-500: #f59e0b;
|
||||
--au-danger-500: #ef4444;
|
||||
|
||||
--au-gray-900: #0f172a;
|
||||
--au-gray-950: #0b1220;
|
||||
--au-radius-md: 8px;
|
||||
--au-font-bold: 700;
|
||||
--au-text-primary: #f1f5f9;
|
||||
--au-text-secondary: #cbd5e1;
|
||||
|
||||
/* Radius */
|
||||
--au-radius-sm: 4px;
|
||||
--au-radius-md: 8px;
|
||||
--au-radius-lg: 12px;
|
||||
--au-radius-full: 9999px;
|
||||
|
||||
/* Typography */
|
||||
--au-font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
--au-font-bold: 700;
|
||||
--au-font-medium: 500;
|
||||
--au-font-normal: 400;
|
||||
|
||||
/* Transitions */
|
||||
--au-transition-fast: 150ms ease;
|
||||
--au-transition-medium: 250ms ease;
|
||||
--au-transition-slow: 400ms ease;
|
||||
|
||||
/* Shadows */
|
||||
--au-shadow-sm: 0 4px 12px rgba(2,6,23,0.18);
|
||||
--au-shadow-md: 0 8px 24px rgba(2,6,23,0.28);
|
||||
}
|
||||
|
||||
/* Light theme overrides (if used in non-dark mode) */
|
||||
[data-theme="light"]{
|
||||
--au-text-primary: #1f2937;
|
||||
--au-text-secondary: #6b7280;
|
||||
--au-gray-950: #f8fafc;
|
||||
--au-gray-900: #ffffff;
|
||||
--au-gray-800: #f3f4f6;
|
||||
--au-gray-700: #e5e7eb;
|
||||
--au-gray-600: #9ca3af;
|
||||
}
|
||||
body { font-family: Inter, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial; }
|
||||
|
||||
/* Basic resets for avanza-ui components */
|
||||
.au-root, .avanza-ui-root {
|
||||
font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
color: var(--au-text-primary);
|
||||
}
|
||||
|
||||
button { font-family: inherit }
|
||||
|
||||
/* Ensure modal/backdrop stacking works */
|
||||
.avanza-ui-modal-backdrop { z-index: 9999 }
|
||||
|
||||
/* Compatibility tokens for other packages (broadcast-panel / studio-panel)
|
||||
These map commonly used project tokens to the avanza-ui design tokens so
|
||||
all packages can import the avanza-ui globals and get consistent values.
|
||||
*/
|
||||
:root{
|
||||
/* Broadcast-style tokens */
|
||||
--primary-blue: var(--au-primary);
|
||||
--primary-blue-hover: var(--au-primary-hover);
|
||||
--background-color: var(--au-gray-950);
|
||||
--surface-color: var(--au-gray-900);
|
||||
--text-primary: var(--au-text-primary);
|
||||
--text-secondary: var(--au-text-secondary);
|
||||
--border-light: rgba(255,255,255,0.04);
|
||||
--active-bg-light: rgba(79,70,229,0.06);
|
||||
--shadow-sm: var(--au-shadow-sm);
|
||||
--shadow-md: var(--au-shadow-md);
|
||||
--skeleton-base: #e5e7eb;
|
||||
--skeleton-highlight: #f3f4f6;
|
||||
|
||||
/* Surface tokens used by studio-panel */
|
||||
--surface-50: #f8fafc;
|
||||
--surface-900: #0f172a;
|
||||
|
||||
/* Studio specific tokens (map to avanza-ui tokens) */
|
||||
--studio-bg-primary: var(--background-color);
|
||||
--studio-bg-secondary: var(--surface-color);
|
||||
--studio-bg-tertiary: var(--active-bg-light);
|
||||
--studio-bg-elevated: var(--surface-color);
|
||||
--studio-bg-hover: rgba(255,255,255,0.02);
|
||||
|
||||
--studio-border: var(--border-light);
|
||||
--studio-border-light: rgba(255,255,255,0.02);
|
||||
--studio-border-subtle: rgba(255,255,255,0.01);
|
||||
|
||||
--studio-text-primary: var(--text-primary);
|
||||
--studio-text-secondary: var(--text-secondary);
|
||||
--studio-text-muted: #94a3b8;
|
||||
--studio-text-disabled: #9ca3af;
|
||||
|
||||
--studio-accent: var(--primary-blue);
|
||||
--studio-accent-hover: var(--primary-blue-hover);
|
||||
--studio-accent-light: rgba(79,70,229,0.08);
|
||||
|
||||
--studio-success: var(--au-success-500);
|
||||
--studio-warning: var(--au-warning-500);
|
||||
--studio-danger: var(--au-danger-500);
|
||||
|
||||
--studio-recording: var(--au-danger-500);
|
||||
--studio-recording-pulse: rgba(239, 68, 68, 0.12);
|
||||
|
||||
--studio-space-xs: 4px;
|
||||
--studio-space-sm: 8px;
|
||||
--studio-space-md: 12px;
|
||||
--studio-space-lg: 16px;
|
||||
--studio-space-xl: 24px;
|
||||
|
||||
--studio-radius-sm: var(--au-radius-sm);
|
||||
--studio-radius-md: var(--au-radius-md);
|
||||
--studio-radius-lg: var(--au-radius-lg);
|
||||
--studio-radius-xl: calc(var(--au-radius-lg) + 4px);
|
||||
|
||||
--studio-font-family: var(--au-font-family, 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif);
|
||||
--studio-text-base: 14px;
|
||||
--studio-text-sm: 12px;
|
||||
|
||||
/* Additional studio tokens required by studio-theme.css and components */
|
||||
--studio-font-normal: var(--au-font-normal, 400);
|
||||
--studio-leading-normal: 1.4;
|
||||
--studio-radius-full: var(--au-radius-full, 9999px);
|
||||
|
||||
--studio-shadow-sm: var(--au-shadow-sm);
|
||||
--studio-shadow-md: var(--au-shadow-md);
|
||||
--studio-shadow-lg: 0 12px 40px rgba(2,6,23,0.32);
|
||||
|
||||
--studio-transition: 200ms ease;
|
||||
--studio-transition-fast: 120ms ease;
|
||||
--studio-transition-slow: 320ms ease;
|
||||
}
|
||||
|
||||
/* Light theme compatibility mapping */
|
||||
[data-theme="light"]{
|
||||
--primary-blue: var(--au-primary);
|
||||
--background-color: #f7f8fa;
|
||||
--surface-color: #ffffff;
|
||||
--text-primary: #1f2937;
|
||||
--text-secondary: #6b7280;
|
||||
--surface-50: #f8fafc;
|
||||
--surface-900: #0f172a;
|
||||
}
|
||||
|
||||
/* End of compatibility tokens */
|
||||
|
||||
10
packages/avanza-ui/src/utils/platform.ts
Normal file
@ -0,0 +1,10 @@
|
||||
// ...existing code...
|
||||
export function isMacPlatform() {
|
||||
if (typeof navigator === 'undefined') return false
|
||||
return /Mac|iPhone|iPad|iPod/.test(navigator.platform)
|
||||
}
|
||||
|
||||
export function modifierKeyLabel() {
|
||||
return isMacPlatform() ? { key: 'Meta', display: '⌘' } : { key: 'Control', display: 'Ctrl' }
|
||||
}
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"noEmit": false,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
@ -26,9 +26,10 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"declarationDir": "dist/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
|
||||
7
packages/backend-api/.env.debug
Normal file
@ -0,0 +1,7 @@
|
||||
# Debug env for backend-api
|
||||
# Enables decoded JWT header in responses and some extra debug verification info
|
||||
ALLOW_DEBUG_TOKEN_HEADER=1
|
||||
NODE_ENV=development
|
||||
# Optional: enable extra debug verify step using LIVEKIT_API_SECRET (if configured)
|
||||
# LIVEKIT_API_SECRET=your_livekit_api_secret_here
|
||||
|
||||
52
packages/backend-api/README.DEBUG.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Debugging backend-api: enable token header debug
|
||||
|
||||
This small guide shows how to enable JWT debug headers in `/api/session/:id/token` responses.
|
||||
|
||||
What it does
|
||||
- When `ALLOW_DEBUG_TOKEN_HEADER=1` or NODE_ENV=development, the backend will include `debugHeader` in the JSON response for `/api/session/:id/token`.
|
||||
- This helps to inspect `alg`, `typ` and other header fields when LiveKit rejects tokens.
|
||||
|
||||
How to enable locally
|
||||
1) Copy the debug env file (optional):
|
||||
|
||||
```bash
|
||||
cd packages/backend-api
|
||||
cp .env.debug .env
|
||||
# Edit .env to set LIVEKIT_API_SECRET or LIVEKIT_API_KEY if required
|
||||
```
|
||||
|
||||
2) Start the backend in development mode (or restart if it's already running):
|
||||
|
||||
```bash
|
||||
# if you run directly with node
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# or if you use ts-node / nodemon adjust accordingly
|
||||
```
|
||||
|
||||
How to enable in docker-compose
|
||||
- If `backend-api` runs in docker-compose, set environment in the compose file or use an env_file:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend-api:
|
||||
image: ...
|
||||
env_file:
|
||||
- packages/backend-api/.env.debug
|
||||
```
|
||||
|
||||
Then `docker compose up -d backend-api`.
|
||||
|
||||
How to test
|
||||
1) Create or obtain a session id from the token server or the UI.
|
||||
2) Request token details with debug:
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:4000/api/session/<id>/token" | jq .
|
||||
```
|
||||
|
||||
Look for `debugHeader` and `debugVerifyHS256` fields.
|
||||
|
||||
If you prefer I can (A) add an endpoint `/debug/verify-token` that runs jwt verify using the configured secret (only when ALLOW_DEBUG_TOKEN_HEADER=1) or (B) prepare a docker-compose snippet that injects the debug env automatically. Reply A or B to proceed.
|
||||
|
||||
@ -301,10 +301,30 @@ async function createLivekitTokenFor(room: string, username: string) {
|
||||
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 */ }
|
||||
// `decoded` may be a string or JSON; use `any` to avoid strict TS errors when inspecting header fields
|
||||
let h: any = decoded;
|
||||
try { h = JSON.parse(decoded); } catch (e) { /* keep raw string if parsing fails */ }
|
||||
console.log('[LIVEKIT] token header:', h);
|
||||
|
||||
// If SDK produced a token with non-HS256 alg, and we have LIVEKIT_API_SECRET, re-sign payload with HS256
|
||||
try {
|
||||
const secretForResign = LIVEKIT_API_SECRET || process.env.LIVEKIT_API_SECRET;
|
||||
if (secretForResign && typeof h === 'object' && (h as any).alg && String((h as any).alg).toUpperCase() !== 'HS256') {
|
||||
try {
|
||||
const parts = (token as string).split('.');
|
||||
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
|
||||
const payloadRaw = parts[1] || '';
|
||||
const payloadJson = JSON.parse(Buffer.from(pad(payloadRaw).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'));
|
||||
// preserve iat/exp if present, stringify payload as-is and sign with HS256
|
||||
const jwtLib = require('jsonwebtoken');
|
||||
const newToken = jwtLib.sign(payloadJson, secretForResign, { algorithm: 'HS256' });
|
||||
console.log('[LIVEKIT] SDK token used alg=' + (h.alg || 'unknown') + ' — re-signed with HS256 fallback');
|
||||
return { token: newToken, url: LIVEKIT_WS || (LIVEKIT_HTTP ? LIVEKIT_HTTP.replace(/^https?:\/\//i, (m) => m.toLowerCase().startsWith('https') ? 'wss://' : 'ws://') : 'ws://localhost:7880') };
|
||||
} catch (e) {
|
||||
console.warn('[LIVEKIT] failed to re-sign token with HS256 fallback', String(e));
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore fallback errors */ }
|
||||
}
|
||||
} 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))
|
||||
@ -349,32 +369,60 @@ app.get('/api/token', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// New endpoint: connection-details
|
||||
app.get('/api/connection-details', async (req, res) => {
|
||||
try {
|
||||
// Accept either 'room' or 'roomName' for compatibility with frontend
|
||||
const room = (req.query.room && typeof req.query.room === 'string') ? req.query.room : undefined;
|
||||
const roomName = (req.query.roomName && typeof req.query.roomName === 'string') ? req.query.roomName : undefined;
|
||||
const participantName = (req.query.participantName && typeof req.query.participantName === 'string') ? req.query.participantName : undefined;
|
||||
const finalRoom = room || roomName;
|
||||
if (!finalRoom) return res.status(400).json({ error: 'room or roomName query param is required' });
|
||||
if (!participantName) return res.status(400).json({ error: 'participantName query param is required' });
|
||||
|
||||
const { token, url } = await createLivekitTokenFor(finalRoom, participantName);
|
||||
|
||||
// create a session id to store the token for short-lived retrieval if needed
|
||||
const sessionId = generateShortId(9);
|
||||
await saveSession(sessionId, { token, url, room: finalRoom, username: participantName }, SESSION_TTL);
|
||||
|
||||
// Return roomName for frontend compatibility
|
||||
return res.json({ serverUrl: url, participantToken: token, sessionId, roomName: finalRoom });
|
||||
} catch (err) {
|
||||
console.error('[backend-api] /api/connection-details failed', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
async function validateTokenWithLiveKit(token: string) {
|
||||
try {
|
||||
// derive http(s) origin from LIVEKIT_WS_URL or LIVEKIT_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) {}
|
||||
const validateUrl = `${httpUrl}/rtc/validate?access_token=${encodeURIComponent(token)}&auto_subscribe=1&sdk=js&version=2.15.14&protocol=16`;
|
||||
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 { return { status: resp.status, ok: resp.ok, body: JSON.parse(text) }; } catch (e) { return { status: resp.status, ok: resp.ok, body: text }; }
|
||||
}
|
||||
return { status: resp.status, ok: resp.ok, body: text };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
const result: any = await validateTokenWithLiveKit(token);
|
||||
// validateTokenWithLiveKit may return { ok: false, error: '...' } without numeric status
|
||||
const statusCode: number = (result && typeof result.status === 'number') ? result.status : (result && result.ok === false ? 502 : 200);
|
||||
return res.status(statusCode).json({ ok: !!result.ok, status: result.status ?? statusCode, body: result.body ?? result.error });
|
||||
} catch (err) {
|
||||
console.error('[backend-api] validate proxy failed', err);
|
||||
return res.status(500).json({ error: 'validate_failed', details: String(err) });
|
||||
@ -413,14 +461,55 @@ app.post('/api/session', async (req, res) => {
|
||||
? `${studioBase}/?token=${encodeURIComponent(token)}&room=${encodeURIComponent(room)}&username=${encodeURIComponent(username)}`
|
||||
: `${studioBase}/?session=${encodeURIComponent(id)}`;
|
||||
|
||||
return res.json({
|
||||
// Prepare base response
|
||||
const responsePayload: any = {
|
||||
id,
|
||||
studioUrl: `${studioBase}/${id}`,
|
||||
// Ruta amigable para el frontend: /studio/:id
|
||||
studioUrl: `${studioBase}/studio/${id}`,
|
||||
redirectUrl,
|
||||
ttlSeconds: ttlSec,
|
||||
room,
|
||||
username,
|
||||
});
|
||||
};
|
||||
|
||||
// In dev or when explicitly enabled, attach decoded header of generated token to help debugging
|
||||
try {
|
||||
if ((process.env.NODE_ENV === 'development') || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
|
||||
if (token && 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');
|
||||
try { responsePayload.debugHeader = JSON.parse(decoded); } catch (e) { responsePayload.debugHeader = decoded }
|
||||
|
||||
// Extra debug: attempt to verify HS256 signature locally using LIVEKIT_API_SECRET (only for debug)
|
||||
try {
|
||||
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||
if (LIVEKIT_API_SECRET) {
|
||||
// do a best-effort verify using jsonwebtoken
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
let verified = null;
|
||||
try {
|
||||
verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
|
||||
responsePayload.debugVerifyHS256 = { ok: true, payload: verified };
|
||||
} catch (verifyErr) {
|
||||
responsePayload.debugVerifyHS256 = { ok: false, error: (verifyErr && (verifyErr as any).message) || String(verifyErr) };
|
||||
}
|
||||
} catch (e) {
|
||||
responsePayload.debugVerifyHS256 = { ok: false, error: 'jsonwebtoken_not_available' };
|
||||
}
|
||||
} else {
|
||||
responsePayload.debugVerifyHS256 = { ok: false, error: 'no_livekit_api_secret_configured' };
|
||||
}
|
||||
} catch (e) {
|
||||
// swallow debug verification errors
|
||||
responsePayload.debugVerifyHS256 = { ok: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore debug attach errors */ }
|
||||
|
||||
return res.json(responsePayload);
|
||||
} catch (err) {
|
||||
console.error('Failed to create session', err);
|
||||
return res.status(500).json({ error: String(err) });
|
||||
@ -450,7 +539,19 @@ app.get('/api/session/:id/token', async (req, res) => {
|
||||
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 });
|
||||
const payload: any = { token: s.token, ttlSeconds: ttlLeft, room: s.room, username: s.username, url: s.url };
|
||||
try {
|
||||
if ((process.env.NODE_ENV === 'development') || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
|
||||
const token = s.token || '';
|
||||
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');
|
||||
try { payload.debugHeader = JSON.parse(decoded); } catch (e) { payload.debugHeader = decoded }
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore debug attach errors */ }
|
||||
return res.json(payload);
|
||||
} catch (err) {
|
||||
console.error('GET /api/session/:id/token failed', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
@ -678,7 +779,7 @@ app.post('/api/broadcasts/:id/session', async (req, res) => {
|
||||
|
||||
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 });
|
||||
return res.json({ id: sid, studioUrl: `${studioBase}/studio/${sid}`, redirectUrl, ttlSeconds: ttlSec });
|
||||
} catch (err) {
|
||||
console.error('Failed to create broadcast session', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
@ -724,7 +825,7 @@ app.post('/api/internal/session', async (req, res) => {
|
||||
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 });
|
||||
return res.json({ id, studioUrl: `${studioBase}/studio/${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) });
|
||||
@ -782,3 +883,138 @@ app.get('/debug/session/:id/header', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Debug endpoint: verify a token locally (only enabled in development or when ALLOW_DEBUG_TOKEN_HEADER=1)
|
||||
app.post('/debug/verify-token', async (req, res) => {
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'development' && process.env.ALLOW_DEBUG_TOKEN_HEADER !== '1') {
|
||||
return res.status(403).json({ error: 'not_allowed' });
|
||||
}
|
||||
const token = (req.body && req.body.token) || req.query.token || '';
|
||||
if (!token || typeof token !== 'string') return res.status(400).json({ error: 'missing_token' });
|
||||
|
||||
const out: any = { tokenPreview: token.slice(0, 32) + '...' };
|
||||
// decode header and payload
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const header = parts[0];
|
||||
const payload = parts[1];
|
||||
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
|
||||
try { out.header = JSON.parse(Buffer.from(pad(header).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.header = Buffer.from(pad(header).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
|
||||
try { out.payload = JSON.parse(Buffer.from(pad(payload).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.payload = Buffer.from(pad(payload).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
|
||||
} else {
|
||||
out.header = null; out.payload = null;
|
||||
}
|
||||
} catch (e) {
|
||||
out.decodeError = String(e);
|
||||
}
|
||||
|
||||
// Local verify using configured secret (HS256) if available
|
||||
try {
|
||||
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||
if (LIVEKIT_API_SECRET) {
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
|
||||
out.localVerify = { ok: true, payload: verified };
|
||||
} catch (e) {
|
||||
out.localVerify = { ok: false, error: (e && (e as any).message) ? (e as any).message : String(e) };
|
||||
}
|
||||
} else {
|
||||
out.localVerify = { ok: false, error: 'no_livekit_api_secret_configured' };
|
||||
}
|
||||
} catch (e) {
|
||||
out.localVerify = { ok: false, error: 'verify_failed: ' + String(e) };
|
||||
}
|
||||
|
||||
// Proxy to LiveKit validate endpoint to get server-side validation result
|
||||
try {
|
||||
const result = await validateTokenWithLiveKit(token);
|
||||
out.livekit = { status: result.status, ok: result.ok, body: result.body };
|
||||
} catch (e) {
|
||||
out.livekit = { ok: false, error: String(e) };
|
||||
}
|
||||
|
||||
return res.json(out);
|
||||
} catch (err) {
|
||||
console.error('[DEBUG] /debug/verify-token failed', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// Debug endpoint: verify and return full session info (token, decoded parts, local and LiveKit verification)
|
||||
app.get('/debug/session/:id/full', 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 || '';
|
||||
const out: any = {
|
||||
id,
|
||||
room: s.room,
|
||||
username: s.username,
|
||||
url: s.url,
|
||||
expiresAt: s.expiresAt,
|
||||
ttlSeconds: Math.max(0, Math.floor((s.expiresAt - Date.now()) / 1000)),
|
||||
tokenPreview: typeof token === 'string' ? (token.length > 64 ? token.slice(0, 32) + '...' + token.slice(-16) : token) : token,
|
||||
};
|
||||
|
||||
// decode header/payload if JWT-like
|
||||
try {
|
||||
if (typeof token === 'string' && token.split('.').length >= 2) {
|
||||
const parts = token.split('.');
|
||||
const pad = (s: string) => s + '='.repeat((4 - (s.length % 4)) % 4);
|
||||
try { out.header = JSON.parse(Buffer.from(pad(parts[0]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.header = Buffer.from(pad(parts[0]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
|
||||
try { out.payload = JSON.parse(Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')); } catch (e) { out.payload = Buffer.from(pad(parts[1]).replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); }
|
||||
} else {
|
||||
out.header = null; out.payload = null;
|
||||
}
|
||||
} catch (e) {
|
||||
out.decodeError = String(e);
|
||||
}
|
||||
|
||||
// Local verify using configured secret (HS256) if available
|
||||
try {
|
||||
const LIVEKIT_API_SECRET = process.env.LIVEKIT_API_SECRET;
|
||||
if (LIVEKIT_API_SECRET) {
|
||||
try {
|
||||
const jwt = require('jsonwebtoken');
|
||||
const verified = jwt.verify(token, LIVEKIT_API_SECRET, { algorithms: ['HS256'] });
|
||||
out.localVerify = { ok: true, payload: verified };
|
||||
} catch (e) {
|
||||
out.localVerify = { ok: false, error: (e && (e as any).message) ? (e as any).message : String(e) };
|
||||
}
|
||||
} else {
|
||||
out.localVerify = { ok: false, error: 'no_livekit_api_secret_configured' };
|
||||
}
|
||||
} catch (e) {
|
||||
out.localVerify = { ok: false, error: 'verify_failed: ' + String(e) };
|
||||
}
|
||||
|
||||
// Proxy to LiveKit validate endpoint to get server-side validation result
|
||||
try {
|
||||
const result = await validateTokenWithLiveKit(token);
|
||||
out.livekit = { status: result.status, ok: result.ok, body: result.body };
|
||||
} catch (e) {
|
||||
out.livekit = { ok: false, error: String(e) };
|
||||
}
|
||||
|
||||
// Attach raw token only in development to avoid leaking secrets in other envs
|
||||
try {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.ALLOW_DEBUG_TOKEN_HEADER === '1') {
|
||||
out.token = token;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return res.json(out);
|
||||
} catch (err) {
|
||||
console.error('[DEBUG] /debug/session/:id/full failed', err);
|
||||
return res.status(500).json({ error: 'failed', details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
# LiveKit Configuration
|
||||
VITE_LIVEKIT_WS_URL=wss://livekit-server.bfzqqk.easypanel.host
|
||||
VITE_TOKEN_SERVER_URL=https://avanzacast-studio.bfzqqk.easypanel.host
|
||||
# Example env for broadcast-panel (Vite)
|
||||
# Enable/disable automatic getUserMedia preflight in StudioRoom
|
||||
# Set to 1/true to enable (default), 0/false to disable
|
||||
VITE_STUDIO_PREFLIGHT=1
|
||||
|
||||
# Other env examples used by the package (adjust as needed)
|
||||
# VITE_BACKEND_API_URL=https://api.example.com
|
||||
# VITE_BACKEND_TOKENS_URL=https://tokens.example.com
|
||||
# VITE_BROADCASTPANEL_URL=https://broadcastpanel.example.com
|
||||
# VITE_LIVEKIT_WS_URL=wss://livekit.example.com
|
||||
|
||||
# Application Configuration
|
||||
VITE_APP_NAME=AvanzaCast Broadcast Panel
|
||||
VITE_APP_VERSION=1.0.0
|
||||
|
||||
18
packages/broadcast-panel/README-MOCK.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Broadcast Panel — Mock Studio (deprecated)
|
||||
|
||||
La funcionalidad de "mock studio" integrada (toggle runtime y variable de entorno `VITE_MOCK_STUDIO`) ha sido eliminada del flujo principal de la aplicación.
|
||||
|
||||
Motivo
|
||||
- El modo mock introducía complejidad en el código de producción y causaba confusiones al depurar flujos reales. Para asegurar comportamiento consistente, el panel ahora usa siempre el `backend-api` real para crear sesiones y tokens.
|
||||
|
||||
Qué cambió
|
||||
- Se eliminó el toggle `MockToggle` del UI y la detección de `VITE_MOCK_STUDIO` en runtime.
|
||||
- `useStudioLauncher` ya no genera sesiones mock; siempre usa la API real (`/api/session` / `connection-details`) para crear/obtener tokens.
|
||||
- Las referencias a `localStorage['avz:mock_studio']` fueron retiradas del flujo principal.
|
||||
|
||||
Pruebas y E2E
|
||||
- Si necesitas ejecutar pruebas E2E o flujos aislados con un servidor mock, existen utilidades en la carpeta `e2e/`:
|
||||
- `e2e/mock_server.js` y `e2e/run_e2e_with_mock.js` siguen disponibles para pruebas locales y no forman parte del flujo de la aplicación.
|
||||
- Usa esos scripts explícitamente cuando quieras simular la infra (no se cargan por defecto en el dev server).
|
||||
|
||||
Si necesitas que vuelva a habilitarse un modo mock controlado (documentado y con feature flag), puedo preparar un PR con una implementación aislada y conmutador que no afecte el código en producción: dime si quieres que lo haga.
|
||||
@ -1,38 +1,86 @@
|
||||
Local E2E runner
|
||||
# E2E visual baseline — instrucciones de uso
|
||||
|
||||
This E2E runner automates the UI flow for Broadcast Panel -> StudioPortal -> LiveKit token handshake.
|
||||
Este README explica cómo verificar que un `token` y una `room` habilitan la videollamada con el flujo E2E del proyecto.
|
||||
|
||||
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
|
||||
Requisitos
|
||||
- Node.js (>=16)
|
||||
- Chrome/Chromium instalado en el host donde corras la prueba
|
||||
- Opcional: browserless si no quieres ejecutar Chrome local
|
||||
|
||||
Start local Chrome with remote debugging (example):
|
||||
Archivos relevantes
|
||||
- `packages/broadcast-panel/e2e/generate_visual_baseline.js` — script E2E que crea una sesión en el token server, se conecta a Chrome remoto/browserless, navega al estudio y publica token vía postMessage.
|
||||
- `packages/broadcast-panel/e2e/start-chrome-remote.sh` — script para arrancar Chrome/Chromium en modo remote-debugging.
|
||||
|
||||
Flujo de verificación paso a paso
|
||||
|
||||
1) Levantar token server (opcional si ya tienes uno)
|
||||
|
||||
Si no tienes un token server, puedes usar la ruta Next.js incluida: `/app/api/session/route.ts`.
|
||||
- Asegúrate de tener `LIVEKIT_API_KEY` y `LIVEKIT_API_SECRET` en el entorno (o usa un mock server local).
|
||||
|
||||
2) Arrancar Chrome con remote debugging
|
||||
|
||||
Opción segura (localhost):
|
||||
|
||||
```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:
|
||||
Opción con exposición pública (NO RECOMENDADA):
|
||||
```bash
|
||||
REMOTE_DEBUG_PUBLIC=1 REMOTE_DEBUG_PORT=9222 ./start-chrome-remote.sh
|
||||
```
|
||||
|
||||
3) Comprobar endpoint CDP
|
||||
|
||||
```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
|
||||
curl -sS http://localhost:9222/json/version
|
||||
curl -sS http://localhost:9222/json/list
|
||||
```
|
||||
|
||||
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`.
|
||||
Busca `webSocketDebuggerUrl` en la salida.
|
||||
|
||||
4) Ejecutar el script E2E
|
||||
|
||||
```bash
|
||||
# ejemplo apuntando al chrome local y al token server local (Next.js en 3000 o 3001)
|
||||
REMOTE_DEBUG_ADDRESS=127.0.0.1 REMOTE_DEBUG_PORT=9222 TOKEN_SERVER="http://localhost:3001" ROOM="e2e-room-1" BROADCAST_URL="http://url_dominio_publico/room/:idroom" node packages/broadcast-panel/e2e/generate_visual_baseline.js
|
||||
```
|
||||
|
||||
El script escribirá logs a `e2e/out/generate_visual_baseline.log` y guardará capturas en `e2e/out/visual_<timestamp>/studio.png`.
|
||||
|
||||
5) Validar en el frontend que el token es recibido
|
||||
|
||||
El script hace `window.postMessage({ type: 'LIVEKIT_TOKEN', token })`. En el frontend (estudio) debes tener un listener similar a:
|
||||
|
||||
```js
|
||||
window.addEventListener('message', (ev) => {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
if (ev.data && ev.data.type === 'LIVEKIT_TOKEN') {
|
||||
const token = ev.data.token;
|
||||
// conectar a LiveKit con token: room.connect(serverUrl, token) o usando components-react
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
6) Debug rápido
|
||||
|
||||
- Si `createSession` falla: revisa `TOKEN_SERVER` y mira `curl -v TOKEN_SERVER/api/session`.
|
||||
- Si no se resuelve CDP: valida `curl http://localhost:9222/json/version`.
|
||||
- Si el frontend no conecta: abre la consola del navegador (o inspecciona la ejecución en Puppeteer con page.on('console') logs en `e2e/out/generate_visual_baseline.log`).
|
||||
|
||||
7) Qué revisar si la videollamada no se inicia
|
||||
|
||||
- El token tiene que ser válido (LIVEKIT_API_KEY/SECRET correctos y server LiveKit accesible desde el frontend).
|
||||
- `studioUrl` debe corresponder al dominio público donde está el frontend. `BROADCAST_URL` o `studioUrl` devuelto por el token server debe ser exactamente la URL que abrirás (por ejemplo: `https://miapp.example/room/abc123`).
|
||||
- El frontend debe usar el token para conectar al room (Room.connect o LiveKit components).
|
||||
|
||||
|
||||
Si quieres, puedo:
|
||||
- Añadir una ruta de verificación GET `/api/session/:id` que devuelva la sesión guardada (facilita recuperar token tras POST).
|
||||
- Añadir un mock server `tools/mock-token-server.js` para pruebas locales sin credenciales.
|
||||
|
||||
Dime si quieres que cree el mock-server o adapte la API a un flujo GET/POST adicional.
|
||||
|
||||
|
||||
155
packages/broadcast-panel/e2e/e2e_remote_9222.js
Normal file
@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env node
|
||||
// E2E script: connect to remote Chrome on localhost:9222 and run token flow (create session -> open Broadcast Panel -> Entrar al Estudio)
|
||||
// Usage: BROWSERLESS_WS not used. Instead set REMOTE_DEBUGGER_URL (e.g. http://localhost:9222) or default to http://localhost:9222
|
||||
// Environment variables:
|
||||
// TOKEN_SERVER (default: https://avanzacast-servertokens.bfzqqk.easypanel.host)
|
||||
// ROOM, USERNAME
|
||||
// OUT_DIR to save artifacts
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
async function main() {
|
||||
const REMOTE_DEBUGGER = process.env.REMOTE_DEBUGGER_URL || 'http://localhost:9222';
|
||||
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 || '/tmp';
|
||||
|
||||
function outLog(...args) {
|
||||
console.log(...args);
|
||||
if (OUT_DIR) {
|
||||
try { fs.appendFileSync(path.join(OUT_DIR, 'e2e_remote_9222.log'), args.map(String).join(' ') + '\n'); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
outLog('E2E remote 9222 starting', { REMOTE_DEBUGGER, TOKEN_SERVER, ROOM, USERNAME, OUT_DIR });
|
||||
|
||||
// Create session on token server
|
||||
let sessResp;
|
||||
try {
|
||||
sessResp = 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 when creating session:', String(err));
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
outLog('Token server status', sessResp.status);
|
||||
let sessBodyText = await sessResp.text().catch(() => '');
|
||||
let sessJson = null;
|
||||
try { sessJson = JSON.parse(sessBodyText); } catch(e) { sessJson = null }
|
||||
outLog('Session response body start:', String(sessBodyText).slice(0,400));
|
||||
if (!sessResp.ok) {
|
||||
outLog('Failed to create session');
|
||||
process.exit(4);
|
||||
}
|
||||
if (!sessJson) {
|
||||
outLog('Invalid JSON from token server');
|
||||
process.exit(5);
|
||||
}
|
||||
|
||||
const sessionId = sessJson.id || sessJson.sessionId || null;
|
||||
const token = sessJson.token || null;
|
||||
const studioUrl = sessJson.studioUrl || sessJson.redirectUrl || sessJson.url || null;
|
||||
|
||||
outLog('Session created', { sessionId: sessionId ? sessionId.slice(0,10) + '...' : null, studioUrl, hasToken: !!token });
|
||||
|
||||
// Connect to remote Chrome DevTools
|
||||
// We expect a running Chrome with --remote-debugging-port=9222. puppeteer.connect accepts websocket endpoint; fetch the ws endpoint from /json/version
|
||||
let wsEndpoint;
|
||||
try {
|
||||
const versionResp = await fetch(`${REMOTE_DEBUGGER.replace(/\/$/, '')}/json/version`);
|
||||
const verJson = await versionResp.json();
|
||||
wsEndpoint = verJson.webSocketDebuggerUrl || null;
|
||||
} catch (err) {
|
||||
outLog('Failed to query remote debugger /json/version:', String(err));
|
||||
process.exit(6);
|
||||
}
|
||||
|
||||
if (!wsEndpoint) {
|
||||
outLog('Remote debugger did not return webSocketDebuggerUrl. Check Chrome is running with --remote-debugging-port=9222');
|
||||
process.exit(7);
|
||||
}
|
||||
|
||||
outLog('Connecting to remote chrome wsEndpoint', wsEndpoint);
|
||||
let browser;
|
||||
try {
|
||||
browser = await puppeteer.connect({ browserWSEndpoint: wsEndpoint, defaultViewport: { width: 1366, height: 768 }, timeout: 20000 });
|
||||
} catch (err) {
|
||||
outLog('puppeteer.connect failed:', String(err));
|
||||
process.exit(8);
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.on('console', msg => outLog('[BROWSER]', msg.type(), msg.text()));
|
||||
page.on('pageerror', err => outLog('[PAGEERROR]', String(err)));
|
||||
|
||||
// Navigate to either the studioUrl returned or to broadcast panel root and click 'Entrar al Estudio'
|
||||
let targetUrl = studioUrl;
|
||||
if (!targetUrl && sessionId) {
|
||||
// use broadcast panel host from environment or default
|
||||
const BROADCAST_HOST = process.env.BROADCAST_HOST || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
|
||||
targetUrl = `${BROADCAST_HOST.replace(/\/$/, '')}/${encodeURIComponent(sessionId)}`;
|
||||
}
|
||||
|
||||
if (!targetUrl) {
|
||||
outLog('No target URL to open');
|
||||
process.exit(9);
|
||||
}
|
||||
|
||||
outLog('Opening target URL:', targetUrl);
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }).catch(err => { outLog('page.goto error', String(err)); throw err; });
|
||||
|
||||
// If page has a visible button/link text 'Entrar' or 'Entrar al Estudio', click it
|
||||
const clickSelectors = ["button[aria-label='Entrar al Estudio']","button:has-text('Entrar al Estudio')","button:has-text('Entrar')","a:has-text('Entrar al Estudio')","a:has-text('Entrar')"];
|
||||
let clicked = false;
|
||||
for (const sel of clickSelectors) {
|
||||
try {
|
||||
// Puppeteer does not support :has-text in older versions; use evaluate to find by text
|
||||
const found = await page.evaluate((text) => {
|
||||
const els = Array.from(document.querySelectorAll('button,a'));
|
||||
const el = els.find(e => (e.innerText || '').trim().toLowerCase().includes(text.toLowerCase()));
|
||||
if (el) { el.scrollIntoView(); el.click(); return true; }
|
||||
return false;
|
||||
}, sel.replace(/.*:has-text\('(.+)'\).*/, '$1'));
|
||||
if (found) { clicked = true; outLog('Clicked element matching', sel); break; }
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// If token is present, postMessage it
|
||||
if (token) {
|
||||
outLog('Posting token via postMessage (len=' + String(token.length) + ')');
|
||||
try {
|
||||
await page.evaluate((tk) => { window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, url: window.location.href }, window.location.origin); }, token);
|
||||
} catch (e) { outLog('postMessage failed', String(e)); }
|
||||
} else {
|
||||
outLog('No token present in session response; relying on redirect flow');
|
||||
}
|
||||
|
||||
// Wait for token received indicator in page
|
||||
const gotToken = await page.waitForFunction(() => document.body && document.body.innerText && (document.body.innerText.includes('Token recibido') || document.body.innerText.includes('Token recibido desde Broadcast Panel') || document.body.innerText.includes('Esperando token')), { timeout: 10000 }).catch(() => false);
|
||||
outLog('Token indicator found:', !!gotToken);
|
||||
|
||||
// Save artifacts
|
||||
if (OUT_DIR) {
|
||||
try { const html = await page.content(); fs.writeFileSync(path.join(OUT_DIR, `page_${sessionId || 'noid'}.html`), html); outLog('Saved page HTML'); } catch(e) { outLog('Failed to save HTML', String(e)); }
|
||||
try { await page.screenshot({ path: path.join(OUT_DIR, `e2e_${sessionId || 'noid'}.png`), fullPage: true }); outLog('Saved screenshot'); } catch(e) { outLog('Failed to save screenshot', String(e)); }
|
||||
}
|
||||
|
||||
await page.close();
|
||||
} finally {
|
||||
try { await browser.disconnect(); } catch(e) {}
|
||||
}
|
||||
|
||||
outLog('E2E remote 9222 finished');
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('Unhandled error in main:', err && err.stack ? err.stack : String(err)); process.exit(99); });
|
||||
|
||||
222
packages/broadcast-panel/e2e/generate_visual_baseline.js
Normal file
@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
generate_visual_baseline.js
|
||||
- Creates a session on the token server
|
||||
- Connects to a remote Chrome with debugging enabled (default http://localhost:9222),
|
||||
or to browserless via WS/CDP using puppeteer-core if remote-debug is not available
|
||||
- Navigates to studioUrl and posts token if present
|
||||
- Saves a screenshot to e2e/baseline/studio.png and e2e/out/<timestamp>/studio.png
|
||||
*/
|
||||
|
||||
// Diagnostic helpers early to surface environment issues quickly
|
||||
try {
|
||||
console.log('=== generate_visual_baseline start ===');
|
||||
console.log('cwd:', process.cwd());
|
||||
console.log('node version:', process.version);
|
||||
console.log('SCRIPT: generate_visual_baseline.js');
|
||||
console.log('ENV: REMOTE_DEBUG, REMOTE_DEBUG_WS, BROWSERLESS_WS, BROWSERLESS_TOKEN, TOKEN_SERVER, BROADCAST_URL');
|
||||
console.log('REMOTE_DEBUG=', process.env.REMOTE_DEBUG, 'REMOTE_DEBUG_WS=', process.env.REMOTE_DEBUG_WS || process.env.REMOTE_WS, 'REMOTE_DEBUG_PORT=', process.env.REMOTE_DEBUG_PORT);
|
||||
console.log('BROWSERLESS_WS=', process.env.BROWSERLESS_WS, 'BROWSERLESS_TOKEN=', !!process.env.BROWSERLESS_TOKEN);
|
||||
console.log('TOKEN_SERVER=', process.env.TOKEN_SERVER || process.env.BACKEND);
|
||||
console.log('BROADCAST_URL=', process.env.BROADCAST_URL);
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('UNCAUGHT EXCEPTION:', err && err.stack ? err.stack : err);
|
||||
process.exit(2);
|
||||
});
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('UNHANDLED REJECTION:', reason && reason.stack ? reason.stack : reason);
|
||||
process.exit(2);
|
||||
});
|
||||
|
||||
const fetch = global.fetch ? global.fetch : (...args) => import('node-fetch').then(m => m.default(...args));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Enhanced remote debug resolution: support REMOTE_DEBUG_ADDRESS + REMOTE_DEBUG_PORT
|
||||
const REMOTE_DEBUG_ADDRESS = process.env.REMOTE_DEBUG_ADDRESS || process.env.REMOTE_DEBUG_HOST || 'localhost';
|
||||
const REMOTE_DEBUG_PORT = process.env.REMOTE_DEBUG_PORT || process.env.REMOTE_PORT || '';
|
||||
let REMOTE_DEBUG_WS = process.env.REMOTE_WS || process.env.REMOTE_DEBUG || '';
|
||||
if (!REMOTE_DEBUG_WS) {
|
||||
if (REMOTE_DEBUG_PORT) {
|
||||
REMOTE_DEBUG_WS = `http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT}`;
|
||||
} else {
|
||||
REMOTE_DEBUG_WS = 'http://localhost:9222';
|
||||
}
|
||||
}
|
||||
|
||||
const BROWSERLESS_WS = process.env.BROWSERLESS_WS || process.env.REMOTE_WSE || 'wss://browserless.bfzqqk.easypanel.host';
|
||||
const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS_KEY || process.env.BROWSERLESS || '';
|
||||
const TOKEN_SERVER = process.env.TOKEN_SERVER || process.env.BACKEND || 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
|
||||
// Cambiar BROADCAST default para seguir la estructura rooms/<room>
|
||||
const BROADCAST_BASE = process.env.BROADCAST_BASE || process.env.STUDIO_BASE || 'http://localhost:5175';
|
||||
const ROOM = process.env.ROOM || 'iuqiw-aksjka';
|
||||
const BROADCAST = process.env.BROADCAST_URL || `${BROADCAST_BASE.replace(/\/$/, '')}/rooms/${encodeURIComponent(ROOM)}`;
|
||||
const CDP_RESOLVE_RETRIES = Number(process.env.CDP_RESOLVE_RETRIES || 6);
|
||||
const CDP_RESOLVE_INTERVAL = Number(process.env.CDP_RESOLVE_INTERVAL_MS || 2000);
|
||||
|
||||
function log(...args){
|
||||
try { console.log(...args); } catch (e) {}
|
||||
try {
|
||||
const outdir = path.join(process.cwd(), 'e2e', 'out');
|
||||
try { fs.mkdirSync(outdir, { recursive: true }); } catch(e) {}
|
||||
const logfile = path.join(outdir, 'generate_visual_baseline.log');
|
||||
try { fs.appendFileSync(logfile, `[${new Date().toISOString()}] ${args.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')}\n`); } catch(e) {}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function createSession(){
|
||||
log('Creating session on token server', TOKEN_SERVER, ROOM);
|
||||
const res = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room: ROOM, username: 'visual-runner', ttl: 300 }),
|
||||
});
|
||||
const text = await res.text(); try { return JSON.parse(text); } catch(e){ return null; }
|
||||
}
|
||||
|
||||
async function resolveRemoteWSEndpoint(raw){
|
||||
if (!raw) return null;
|
||||
raw = String(raw).trim();
|
||||
// if already a ws:// or wss:// endpoint, return as-is
|
||||
if (raw.startsWith('ws://') || raw.startsWith('wss://')) return raw;
|
||||
// if given as a numeric port, assume localhost:port
|
||||
if (/^\d+$/.test(raw)) raw = `http://localhost:${raw}`;
|
||||
// if it's missing scheme, assume http
|
||||
if (!raw.startsWith('http://') && !raw.startsWith('https://')) raw = `http://${raw}`;
|
||||
try{
|
||||
const ver = await fetch(raw.replace(/\/$/, '') + '/json/version');
|
||||
if (ver && ver.ok){ const j = await ver.json(); if (j.webSocketDebuggerUrl) return j.webSocketDebuggerUrl; }
|
||||
}catch(e){ log('resolveRemoteWSEndpoint /json/version error', String(e)); }
|
||||
try{
|
||||
const list = await fetch(raw.replace(/\/$/, '') + '/json/list');
|
||||
if (list && list.ok){ const arr = await list.json(); if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl; }
|
||||
}catch(e){ log('resolveRemoteWSEndpoint /json/list error', String(e)); }
|
||||
try{
|
||||
const j = await fetch(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){ log('resolveRemoteWSEndpoint /json error', String(e)); }
|
||||
return null;
|
||||
}
|
||||
|
||||
async function waitForCDP(raw, retries = CDP_RESOLVE_RETRIES, interval = CDP_RESOLVE_INTERVAL){
|
||||
let attempt = 0;
|
||||
while(attempt < retries){
|
||||
attempt++;
|
||||
try{
|
||||
log(`CDP resolve attempt ${attempt}/${retries} for ${raw}`);
|
||||
const resolved = await resolveRemoteWSEndpoint(raw);
|
||||
if (resolved) return resolved;
|
||||
}catch(e){ log('waitForCDP attempt error', String(e)); }
|
||||
await new Promise(r => setTimeout(r, interval));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
(async ()=>{
|
||||
try{
|
||||
const session = await createSession();
|
||||
if (!session) throw new Error('Failed to create session');
|
||||
log('Session created:', session.id, 'studioUrl=', session.studioUrl || session.url);
|
||||
const studioUrl = session.studioUrl || session.redirectUrl || session.url || BROADCAST;
|
||||
let token = session.token || null;
|
||||
if (!token && session.id) {
|
||||
// try GET
|
||||
try{
|
||||
const getResp = await fetch(`${TOKEN_SERVER.replace(/\/$/, '')}/api/session/${encodeURIComponent(session.id)}`);
|
||||
const txt = await getResp.text(); const json = JSON.parse(txt);
|
||||
if (json && json.token) token = json.token;
|
||||
}catch(e){ log('GET session token failed', String(e)); }
|
||||
}
|
||||
|
||||
// Try to resolve CDP endpoint from remote debug first
|
||||
let connectEndpoint = null;
|
||||
if (REMOTE_DEBUG_WS) {
|
||||
try{
|
||||
const resolved = await waitForCDP(REMOTE_DEBUG_WS);
|
||||
if (resolved) {
|
||||
connectEndpoint = resolved;
|
||||
log('Resolved remote-debug CDP endpoint:', connectEndpoint);
|
||||
} else {
|
||||
log('No CDP endpoint discovered at remote-debug URL after retries:', REMOTE_DEBUG_WS);
|
||||
}
|
||||
} catch(e){ log('Error resolving remote-debug endpoint', String(e)); }
|
||||
}
|
||||
|
||||
// If remote debug not available, try browserless
|
||||
if (!connectEndpoint && BROWSERLESS_WS) {
|
||||
try{
|
||||
if ((BROWSERLESS_WS.startsWith('ws://') || BROWSERLESS_WS.startsWith('wss://')) && BROWSERLESS_TOKEN) {
|
||||
connectEndpoint = `${BROWSERLESS_WS}${BROWSERLESS_WS.includes('?') ? '&' : '?'}token=${encodeURIComponent(BROWSERLESS_TOKEN)}`;
|
||||
} else {
|
||||
connectEndpoint = await waitForCDP(BROWSERLESS_WS + (BROWSERLESS_TOKEN ? (BROWSERLESS_WS.includes('?') ? '&' : '?') + `token=${encodeURIComponent(BROWSERLESS_TOKEN)}` : ''));
|
||||
if (!connectEndpoint) connectEndpoint = await waitForCDP(BROWSERLESS_WS);
|
||||
}
|
||||
log('Browserless resolution result:', connectEndpoint || '(none)');
|
||||
}catch(e){ log('Browserless resolve error', String(e)); }
|
||||
}
|
||||
|
||||
log('connectEndpoint resolved to', connectEndpoint || '(none)');
|
||||
|
||||
let browser = null;
|
||||
if (connectEndpoint) {
|
||||
try{
|
||||
browser = await puppeteer.connect({ browserWSEndpoint: connectEndpoint, timeout: 30000, ignoreHTTPSErrors: true });
|
||||
log('Connected to remote browser via puppeteer (CDP)');
|
||||
}catch(e){
|
||||
log('puppeteer.connect failed:', String(e));
|
||||
browser = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!browser) {
|
||||
log('Launching local puppeteer fallback');
|
||||
browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
page.on('console', msg => { try{ log('[BROWSER]', msg.type(), msg.text()); } catch(e){} });
|
||||
page.on('pageerror', err => log('[PAGEERROR]', err && err.stack ? err.stack : String(err)));
|
||||
|
||||
log('Navigating to', studioUrl);
|
||||
await page.goto(studioUrl, { waitUntil: 'networkidle2' }).catch(e=>{ log('page.goto failed', String(e)); });
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
if (token) {
|
||||
try{
|
||||
log('Posting token to page via postMessage (length', token.length, ')');
|
||||
await page.evaluate((tk)=>{ try{ window.postMessage({ type:'LIVEKIT_TOKEN', token: tk, room: '', url: window.location.href }, window.location.origin); } catch(e){} }, token);
|
||||
}catch(e){ log('postMessage evaluate failed', String(e)); }
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
const baselineDir = path.join(process.cwd(), 'e2e', 'baseline');
|
||||
try{ fs.mkdirSync(baselineDir, { recursive: true }); } catch(e){}
|
||||
const outDir = path.join(process.cwd(), 'e2e', 'out', `visual_${Date.now()}`);
|
||||
try{ fs.mkdirSync(outDir, { recursive: true }); } catch(e){}
|
||||
const baselinePath = path.join(baselineDir, 'studio.png');
|
||||
const outPath = path.join(outDir, 'studio.png');
|
||||
|
||||
await page.screenshot({ path: outPath, fullPage: false });
|
||||
log('Saved capture to', outPath);
|
||||
|
||||
// Copy to baseline if not exists or if FORCE_BASELINE=1
|
||||
const force = process.env.FORCE_BASELINE === '1';
|
||||
if (!fs.existsSync(baselinePath) || force) {
|
||||
try{ fs.copyFileSync(outPath, baselinePath); log('Wrote baseline to', baselinePath); } catch(e){ log('Failed writing baseline', String(e)); }
|
||||
} else {
|
||||
log('Baseline exists at', baselinePath, '(not overwritten)');
|
||||
}
|
||||
|
||||
try{ await page.close(); } catch(e){}
|
||||
try{ await browser.close(); } catch(e){}
|
||||
|
||||
log('Done');
|
||||
process.exit(0);
|
||||
}catch(err){
|
||||
console.error('Fatal', err && err.stack ? err.stack : err);
|
||||
process.exit(2);
|
||||
}
|
||||
})();
|
||||
18
packages/broadcast-panel/e2e/playwright.config.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './',
|
||||
timeout: 60_000,
|
||||
expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.02 } },
|
||||
use: {
|
||||
headless: true,
|
||||
viewport: { width: 1280, height: 800 },
|
||||
actionTimeout: 10_000,
|
||||
trace: 'on-first-retry',
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
});
|
||||
|
||||
124
packages/broadcast-panel/e2e/run_e2e_auto.sh
Executable file
@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env zsh
|
||||
set -euo pipefail
|
||||
|
||||
# run_e2e_auto.sh
|
||||
# Script helper para ejecutar los runners E2E del broadcast-panel (browserless o local)
|
||||
# - Crea un OUT_DIR con timestamp
|
||||
# - Exporta variables necesarias y ejecuta el runner seleccionado
|
||||
# - Guarda stdout/stderr en OUT_DIR/e2e.log
|
||||
|
||||
# Defaults (puedes sobrescribir por env o con flags)
|
||||
BROWSERLESS_WS_DEFAULT="wss://browserless.bfzqqk.easypanel.host"
|
||||
BROWSERLESS_TOKEN_DEFAULT=""
|
||||
ROOM_DEFAULT="e2e-room"
|
||||
TOKEN_SERVER_DEFAULT="https://avanzacast-servertokens.bfzqqk.easypanel.host"
|
||||
BROADCAST_URL_DEFAULT="http://avanzacast-studio.bfzqqk.easypanel.host"
|
||||
|
||||
# Parse args simples
|
||||
LOCAL_MODE=0
|
||||
SHOW=0
|
||||
BROWSERLESS_WS="${BROWSERLESS_WS:-$BROWSERLESS_WS_DEFAULT}"
|
||||
BROWSERLESS_TOKEN="${BROWSERLESS_TOKEN:-$BROWSERLESS_TOKEN_DEFAULT}"
|
||||
ROOM="${ROOM:-$ROOM_DEFAULT}"
|
||||
TOKEN_SERVER="${TOKEN_SERVER:-$TOKEN_SERVER_DEFAULT}"
|
||||
BROADCAST_URL="${BROADCAST_URL:-$BROADCAST_URL_DEFAULT}"
|
||||
OUT_DIR="${OUT_DIR:-}"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: run_e2e_auto.sh [--local] [--show] [--room NAME] [--ws URL] [--token TOKEN] [--token-server URL] [--broadcast-url URL]
|
||||
|
||||
Options:
|
||||
--local Use run_local_e2e.js (con --ws o REMOTE_WS para apuntar a Chrome remoto)
|
||||
--show Pass flag --show to the local runner (visual mode)
|
||||
--room NAME Room name to create (env ROOM)
|
||||
--ws URL Browserless/remote ws base (env BROWSERLESS_WS / REMOTE_WS)
|
||||
--token TOKEN Browserless token (env BROWSERLESS_TOKEN)
|
||||
--token-server URL Token server (env TOKEN_SERVER)
|
||||
--broadcast-url URL Broadcast panel URL (env BROADCAST_URL)
|
||||
--out DIR Directory where logs/screenshots will be stored (overrides default)
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
# Run against browserless remote (recommended)
|
||||
BROWSERLESS_TOKEN=xxx ./run_e2e_auto.sh --room iuqiw-aksjka
|
||||
|
||||
# Run local remote-debugging chrome
|
||||
./run_e2e_auto.sh --local --ws http://localhost:9222 --show
|
||||
EOF
|
||||
}
|
||||
|
||||
# simple arg loop
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--local) LOCAL_MODE=1; shift ;;
|
||||
--show) SHOW=1; shift ;;
|
||||
--room) ROOM="$2"; shift 2 ;;
|
||||
--ws) BROWSERLESS_WS="$2"; shift 2 ;;
|
||||
--token) BROWSERLESS_TOKEN="$2"; shift 2 ;;
|
||||
--token-server) TOKEN_SERVER="$2"; shift 2 ;;
|
||||
--broadcast-url) BROADCAST_URL="$2"; shift 2 ;;
|
||||
--out) OUT_DIR="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown arg: $1"; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# prepare outdir with timestamp if not provided
|
||||
if [[ -z "${OUT_DIR}" ]]; then
|
||||
TIMESTAMP=$(date -u +"%Y%m%dT%H%M%SZ")
|
||||
OUT_DIR="./e2e/out/$TIMESTAMP"
|
||||
fi
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
# decide runner
|
||||
if [[ "$LOCAL_MODE" -eq 1 ]]; then
|
||||
RUNNER_JS="e2e/run_local_e2e.js"
|
||||
# If SHOW requested, pass flag through env to the runner via --show
|
||||
RUN_CMD=(node "$RUNNER_JS")
|
||||
if [[ "$SHOW" -eq 1 ]]; then RUN_CMD+=(--show); fi
|
||||
else
|
||||
RUNNER_JS="e2e/run_browserless_e2e.js"
|
||||
RUN_CMD=(node "$RUNNER_JS")
|
||||
fi
|
||||
|
||||
echo "[run_e2e_auto] Starting E2E runner"
|
||||
echo " Runner: $RUNNER_JS"
|
||||
echo " OUT_DIR: $OUT_DIR"
|
||||
echo " ROOM: $ROOM"
|
||||
if [[ -n "$BROWSERLESS_TOKEN" ]]; then
|
||||
echo " BROWSERLESS_TOKEN: (present)"
|
||||
else
|
||||
echo " BROWSERLESS_TOKEN: (empty)"
|
||||
fi
|
||||
|
||||
# Build environment for the child process
|
||||
export BROWSERLESS_WS BROWSERLESS_TOKEN ROOM TOKEN_SERVER BROADCAST_URL OUT_DIR
|
||||
|
||||
# Show final command (for debugging)
|
||||
echo "[run_e2e_auto] Executing: ${RUN_CMD[@]}"
|
||||
|
||||
# Run and tee output
|
||||
LOGFILE="$OUT_DIR/e2e.log"
|
||||
(
|
||||
echo "=== START $(date -u) ==="
|
||||
echo "Command: ${RUN_CMD[@]}"
|
||||
echo "Environment: BROWSERLESS_WS=$BROWSERLESS_WS ROOM=$ROOM TOKEN_SERVER=$TOKEN_SERVER BROADCAST_URL=$BROADCAST_URL"
|
||||
echo "--- OUTPUT ---"
|
||||
) > "$LOGFILE"
|
||||
|
||||
# run and capture both stdout and stderr
|
||||
{
|
||||
"${RUN_CMD[@]}" 2>&1
|
||||
} | tee -a "$LOGFILE"
|
||||
EXIT_CODE=${PIPESTATUS[1]:-0}
|
||||
|
||||
echo "=== FINISH $(date -u) exit=$EXIT_CODE ===" | tee -a "$LOGFILE"
|
||||
|
||||
if [[ $EXIT_CODE -ne 0 ]]; then
|
||||
echo "Runner exited with code $EXIT_CODE. Revisa $LOGFILE y los archivos en $OUT_DIR"
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
|
||||
echo "E2E runner finished OK. Revisa logs y capturas en: $OUT_DIR"
|
||||
exit 0
|
||||
115
packages/broadcast-panel/e2e/session_loader.e2e.js
Normal file
@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env node
|
||||
// E2E test: session_loader
|
||||
// - Creates a session via backend-api POST /api/session
|
||||
// - Opens broadcast panel at /<sessionId>
|
||||
// - Waits for sessionStorage key and verifies token/url present
|
||||
|
||||
const puppeteer = require('puppeteer');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
(async () => {
|
||||
const BACKEND = process.env.BACKEND || 'http://localhost:4000';
|
||||
const BROADCAST = process.env.BROADCAST || 'http://localhost:5175';
|
||||
const HEADLESS = process.env.HEADLESS !== '0'; // set HEADLESS=0 to see browser
|
||||
|
||||
console.log('Using BACKEND=%s BROADCAST=%s HEADLESS=%s', BACKEND, BROADCAST, HEADLESS);
|
||||
|
||||
// 1) create session via backend
|
||||
const room = 'e2e-test-room-' + Math.random().toString(36).slice(2,8);
|
||||
const username = 'e2e-user';
|
||||
console.log('Creating session for room=%s username=%s', room, username);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room, username })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to reach backend to create session:', String(err));
|
||||
process.exit(2);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.error('Failed to create session on backend:', resp.status);
|
||||
const txt = await resp.text().catch(()=>null);
|
||||
console.error('Body:', txt);
|
||||
process.exit(2);
|
||||
}
|
||||
const js = await resp.json();
|
||||
const sessionId = js.id || js.sessionId || js.id;
|
||||
if (!sessionId) {
|
||||
console.error('Session creation response missing id:', js);
|
||||
process.exit(2);
|
||||
}
|
||||
console.log('Created session id=', sessionId);
|
||||
|
||||
// 2) open broadcast panel at /<id>
|
||||
const url = `${BROADCAST.replace(/\/$/, '')}/${encodeURIComponent(sessionId)}`;
|
||||
console.log('Opening', url);
|
||||
|
||||
const browser = await puppeteer.launch({ headless: HEADLESS, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'] });
|
||||
const page = await browser.newPage();
|
||||
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
|
||||
page.on('pageerror', err => console.error('PAGE ERROR:', err.message));
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
} catch (e) {
|
||||
console.error('Failed to open broadcast panel url:', e.message);
|
||||
// capture screenshot for debugging if possible
|
||||
try { await page.screenshot({ path: '/tmp/e2e_page_error.png', fullPage: false }); console.log('Saved screenshot to /tmp/e2e_page_error.png'); } catch(e){}
|
||||
await browser.close();
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// wait for sessionStorage key with retry
|
||||
const STORE_KEY = process.env.STORE_KEY || 'avanzacast_studio_session';
|
||||
console.log('Waiting for sessionStorage key=', STORE_KEY);
|
||||
|
||||
let found = null;
|
||||
try {
|
||||
found = await page.waitForFunction((k) => { try { return !!sessionStorage.getItem(k); } catch (e) { return false } }, { timeout: 15000 }, STORE_KEY).catch(()=>null);
|
||||
} catch (err) { found = null }
|
||||
|
||||
if (!found) {
|
||||
console.error('sessionStorage key not found within timeout');
|
||||
try { const html = await page.content(); console.error('Page content snapshot length:', html.length); } catch(e){}
|
||||
try { await page.screenshot({ path: '/tmp/e2e_no_sessionstorage.png' }); console.log('Saved screenshot to /tmp/e2e_no_sessionstorage.png'); } catch(e){}
|
||||
await browser.close();
|
||||
process.exit(4);
|
||||
}
|
||||
|
||||
const stored = await page.evaluate((k) => sessionStorage.getItem(k), STORE_KEY);
|
||||
console.log('Stored value (truncated):', stored && stored.slice(0, 400));
|
||||
|
||||
let parsed = null;
|
||||
try { parsed = JSON.parse(stored); } catch (e) { console.error('Failed to parse stored JSON', e); }
|
||||
if (!parsed) {
|
||||
console.error('No valid session stored');
|
||||
await browser.close();
|
||||
process.exit(5);
|
||||
}
|
||||
|
||||
// Accept either token or participantToken
|
||||
const token = parsed.token || parsed.participantToken || parsed.access_token || parsed.token;
|
||||
const urlField = parsed.url || parsed.serverUrl || parsed.studioUrl || parsed.redirectUrl;
|
||||
console.log('Extracted token prefix:', token ? token.slice(0, 24) + '...' : '(none)');
|
||||
console.log('Extracted url:', urlField || '(none)');
|
||||
|
||||
if (!token) {
|
||||
console.error('Token not found in stored session');
|
||||
await browser.close();
|
||||
process.exit(6);
|
||||
}
|
||||
|
||||
// Optionally validate token via backend validate proxy
|
||||
try {
|
||||
const v = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session/validate?token=${encodeURIComponent(token)}`);
|
||||
const vt = await v.text();
|
||||
console.log('Validate response (first 400 chars):', vt && vt.slice(0,400));
|
||||
} catch (e) {
|
||||
console.warn('Validate proxy call failed:', e.message);
|
||||
}
|
||||
|
||||
console.log('E2E test succeeded');
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
})();
|
||||
91
packages/broadcast-panel/e2e/start-chrome-remote.sh
Normal file → Executable file
@ -4,25 +4,84 @@
|
||||
# Uso:
|
||||
# chmod +x start-chrome-remote.sh
|
||||
# ./start-chrome-remote.sh
|
||||
#
|
||||
# Mejoras:
|
||||
# - Permite configurar REMOTE_DEBUG_ADDRESS (por defecto 127.0.0.1).
|
||||
# - Si REMOTE_DEBUG_PUBLIC=1 se fuerza 0.0.0.0 (exponer a red pública, INSEGURO).
|
||||
# - Permite configurar REMOTE_DEBUG_PORT (por defecto 9222).
|
||||
# - Permite pasar flags adicionales con EXTRA_CHROME_FLAGS.
|
||||
# - Mantiene comportamiento headless cuando no hay DISPLAY.
|
||||
|
||||
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"
|
||||
# Permite sobreescribir binario con variable de entorno CHROME_BIN
|
||||
CHROME_BIN=${CHROME_BIN:-/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
|
||||
CHROME_BIN=/usr/bin/google-chrome-stable
|
||||
fi
|
||||
if [ ! -x "$CHROME_BIN" ]; then
|
||||
CHROME_BIN=/usr/bin/chromium-browser
|
||||
fi
|
||||
if [ ! -x "$CHROME_BIN" ]; then
|
||||
CHROME_BIN=/usr/bin/chromium
|
||||
fi
|
||||
|
||||
if [ ! -x "$CHROME_BIN" ]; then
|
||||
echo "No se encontró un binario de Chrome/Chromium. Exporta CHROME_BIN o instala Chrome/Chromium." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Configurables
|
||||
REMOTE_DEBUG_PORT=${REMOTE_DEBUG_PORT:-9222}
|
||||
# By default restrict debugging to localhost; set REMOTE_DEBUG_PUBLIC=1 to allow 0.0.0.0
|
||||
if [ "${REMOTE_DEBUG_PUBLIC:-0}" = "1" ]; then
|
||||
REMOTE_DEBUG_ADDRESS=${REMOTE_DEBUG_ADDRESS:-0.0.0.0}
|
||||
else
|
||||
REMOTE_DEBUG_ADDRESS=${REMOTE_DEBUG_ADDRESS:-127.0.0.1}
|
||||
fi
|
||||
|
||||
# Extra flags puede ser usado para ajustar comportamiento en CI o debug
|
||||
EXTRA_CHROME_FLAGS=${EXTRA_CHROME_FLAGS:-}
|
||||
|
||||
echo "Arrancando Chrome/Chromium con remote-debugging en http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT} usando perfil: $PROFILE_DIR"
|
||||
if [ "${REMOTE_DEBUG_PUBLIC:-0}" = "1" ]; then
|
||||
echo "ADVERTENCIA: REMOTE_DEBUG_PUBLIC=1 permite conexiones desde cualquier host. Asegúrate de usar firewall/SSH tunneling para seguridad." >&2
|
||||
fi
|
||||
|
||||
# Opciones: usa headless nuevo por defecto si se ejecuta en CI (no hay DISPLAY), pero permite pasar flags extra.
|
||||
FLAGS=(
|
||||
--remote-debugging-port=${REMOTE_DEBUG_PORT}
|
||||
--remote-debugging-address=${REMOTE_DEBUG_ADDRESS}
|
||||
--user-data-dir="$PROFILE_DIR"
|
||||
--no-first-run
|
||||
--disable-extensions
|
||||
--disable-backgrounding-occluded-windows
|
||||
--window-size=1280,900
|
||||
--enable-logging
|
||||
--v=1
|
||||
)
|
||||
|
||||
# Si no hay DISPLAY, arrancamos headless; si hay DISPLAY, arrancamos en modo normal
|
||||
if [ -z "$DISPLAY" ]; then
|
||||
FLAGS+=(--headless=new --disable-gpu --no-sandbox --disable-dev-shm-usage)
|
||||
fi
|
||||
|
||||
# Añadir flags extra si se proporcionan
|
||||
if [ -n "$EXTRA_CHROME_FLAGS" ]; then
|
||||
# Separa por espacio, permite pasar múltiples flags
|
||||
FLAGS+=(${=EXTRA_CHROME_FLAGS})
|
||||
fi
|
||||
|
||||
# Ejecutar en background y devolver control
|
||||
"$CHROME_BIN" "${(@)FLAGS}" "$@" &
|
||||
CHROME_PID=$!
|
||||
|
||||
echo "Chrome PID: $CHROME_PID"
|
||||
echo "Remote debugging URL: http://${REMOTE_DEBUG_ADDRESS}:${REMOTE_DEBUG_PORT}/"
|
||||
|
||||
# Notas de uso:
|
||||
# - Para exponer a otra máquina de forma segura, crea un tunnel SSH en lugar de exponer el puerto públicamente:
|
||||
# ssh -L 9222:localhost:9222 user@remoteserver
|
||||
# - Para probar la disponibilidad del endpoint:
|
||||
# curl -sS http://localhost:${REMOTE_DEBUG_PORT}/json/version
|
||||
|
||||
17
packages/broadcast-panel/e2e/test_cdp_connect.js
Normal file
@ -0,0 +1,17 @@
|
||||
(async () => {
|
||||
const { chromium } = require('playwright');
|
||||
const ws = process.env.BROWSERLESS_WS || 'wss://browserless.bfzqqk.easypanel.host';
|
||||
const token = process.env.BROWSERLESS_TOKEN || '';
|
||||
const endpoint = ws + (ws.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(token);
|
||||
console.log('Attempting CDP connect to', endpoint);
|
||||
try {
|
||||
const browser = await chromium.connectOverCDP(endpoint, { timeout: 20000 });
|
||||
console.log('Connected! version:', await browser.version());
|
||||
await browser.close();
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error('CDP connect failed:', e && e.stack ? e.stack : e);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
0
packages/broadcast-panel/e2e/visual-prejoin.spec.ts
Normal file
132
packages/broadcast-panel/e2e/visual-studio.spec.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { chromium as pwChromium } from 'playwright';
|
||||
|
||||
// Defaults point to public deployments (overrideable via env)
|
||||
const BACKEND = process.env.TOKEN_SERVER || process.env.BACKEND || 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
|
||||
const BROADCAST = process.env.BROADCAST_URL || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
|
||||
const ROOM = process.env.ROOM || `e2e-visual-${Date.now()}`;
|
||||
const BROWSERLESS_WS = process.env.BROWSERLESS_WS || process.env.REMOTE_WS || '';
|
||||
const BROWSERLESS_TOKEN = process.env.BROWSERLESS_TOKEN || process.env.BROWSERLESS_KEY || '';
|
||||
|
||||
async function createSession() {
|
||||
const res = await fetch(`${BACKEND.replace(/\/$/, '')}/api/session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ room: ROOM, username: 'visual-runner', ttl: 300 }),
|
||||
});
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
async function resolveRemoteWSEndpoint(raw: string | undefined) {
|
||||
if (!raw) return null;
|
||||
let r = String(raw).trim();
|
||||
// if starts with ws or wss, return as-is (may still need token appended)
|
||||
if (r.startsWith('ws://') || r.startsWith('wss://')) return r;
|
||||
// numeric port -> assume localhost
|
||||
if (/^\d+$/.test(r)) r = `http://localhost:${r}`;
|
||||
if (!r.startsWith('http://') && !r.startsWith('https://')) r = `http://${r}`;
|
||||
try {
|
||||
const ver = await fetch(r.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 fetch(r.replace(/\/$/, '') + '/json/list');
|
||||
if (list && list.ok) {
|
||||
const arr = await list.json();
|
||||
if (Array.isArray(arr) && arr.length && arr[0].webSocketDebuggerUrl) return arr[0].webSocketDebuggerUrl;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
try {
|
||||
const j = await fetch(r.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) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
test('Studio visual snapshot (browserless-aware)', async () => {
|
||||
const session = await createSession();
|
||||
const target = session && session.studioUrl ? session.studioUrl : BROADCAST;
|
||||
|
||||
// Create browser/page either via browserless CDP or local chromium
|
||||
let browser: any = null;
|
||||
let context: any = null;
|
||||
let page: any = null;
|
||||
let usedRemote = false;
|
||||
|
||||
try {
|
||||
if (BROWSERLESS_WS) {
|
||||
// try resolve to webSocketDebuggerUrl (CDP)
|
||||
let connectEndpoint: string | null = null;
|
||||
// if BROWSERLESS_WS already contains token param, use as-is; otherwise append token
|
||||
if ((BROWSERLESS_WS.startsWith('ws://') || BROWSERLESS_WS.startsWith('wss://')) && BROWSERLESS_TOKEN) {
|
||||
connectEndpoint = `${BROWSERLESS_WS}${BROWSERLESS_WS.includes('?') ? '&' : '?'}token=${encodeURIComponent(BROWSERLESS_TOKEN)}`;
|
||||
} else {
|
||||
// try to resolve via http endpoints
|
||||
connectEndpoint = await resolveRemoteWSEndpoint(BROWSERLESS_WS + (BROWSERLESS_TOKEN ? (BROWSERLESS_WS.includes('?') ? '&' : '?') + `token=${encodeURIComponent(BROWSERLESS_TOKEN)}` : ''));
|
||||
if (!connectEndpoint) connectEndpoint = await resolveRemoteWSEndpoint(BROWSERLESS_WS);
|
||||
}
|
||||
|
||||
if (connectEndpoint) {
|
||||
// connect via CDP
|
||||
try {
|
||||
browser = await pwChromium.connectOverCDP(connectEndpoint, { timeout: 20000 });
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
usedRemote = true;
|
||||
console.log('Connected to remote browser via CDP:', connectEndpoint);
|
||||
} catch (err) {
|
||||
console.warn('connectOverCDP failed, will fallback to local chromium:', String(err));
|
||||
}
|
||||
} else {
|
||||
console.warn('Could not resolve remote CDP endpoint for', BROWSERLESS_WS);
|
||||
}
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
// fallback: launch local chromium
|
||||
browser = await pwChromium.launch({ headless: true });
|
||||
context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
page = await context.newPage();
|
||||
console.log('Launched local chromium for visual test');
|
||||
}
|
||||
|
||||
console.log('Navigating to', target);
|
||||
await page.goto(target, { waitUntil: 'networkidle' });
|
||||
|
||||
// Wait a bit for UI to settle
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Attempt to post message if we have token
|
||||
if (session && session.token) {
|
||||
try {
|
||||
await page.evaluate((tk) => {
|
||||
window.postMessage({ type: 'LIVEKIT_TOKEN', token: tk, room: '', url: window.location.href }, window.location.origin);
|
||||
}, session.token);
|
||||
} catch (e) { console.warn('postMessage failed', e); }
|
||||
}
|
||||
|
||||
// Wait for connection indicators or token received text
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Take screenshot and compare to baseline
|
||||
const now = Date.now();
|
||||
const outDir = `${process.cwd()}/e2e/out/visual_${now}`;
|
||||
const fs = require('fs');
|
||||
try { fs.mkdirSync(outDir, { recursive: true }); } catch (e) {}
|
||||
const capturePath = `${outDir}/studio.png`;
|
||||
await page.screenshot({ path: capturePath, fullPage: false });
|
||||
console.log('Saved visual capture to', capturePath);
|
||||
// Playwright snapshot assertion (baseline management)
|
||||
await expect(page).toHaveScreenshot('studio.png', { fullPage: false });
|
||||
} finally {
|
||||
try { if (context) await context.close(); } catch (e) {}
|
||||
try { if (browser && usedRemote && typeof browser.close === 'function') await browser.close(); else if (browser) await browser.close(); } catch (e) {}
|
||||
}
|
||||
});
|
||||
@ -7,10 +7,16 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"e2e:dify": "node e2e/dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png"
|
||||
"test:connect-backend": "node scripts/connect-via-backend.js",
|
||||
"e2e:dify": "node e2e/dify-plugin-playwright.mjs --ws ws://localhost:3003 --url http://localhost:5176 --out /tmp/dify-shot.png",
|
||||
"e2e:session-loader": "node e2e/session_loader.e2e.js",
|
||||
"e2e:visual": "npx playwright test e2e/visual-studio.spec.ts",
|
||||
"e2e:visual:update": "BROWSERLESS_WS=\"wss://browserless.bfzqqk.easypanel.host\" BROWSERLESS_TOKEN=\"e2e098863b912f6a178b68e71ec3c58d\" node e2e/generate_visual_baseline.js",
|
||||
"create:session": "node scripts/create_session.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.15",
|
||||
"ws": "^8.13.0",
|
||||
"@livekit/components-styles": "^1.1.6",
|
||||
"avanza-ui": "file:../avanza-ui",
|
||||
"livekit-client": "^2.15.14",
|
||||
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
83
packages/broadcast-panel/public/create_session.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Crear sesión — Broadcast Panel</title>
|
||||
<style>
|
||||
body { font-family: Inter, system-ui, Arial; background:#0b0b0d; color:#eee; display:flex; align-items:center; justify-content:center; height:100vh; }
|
||||
.card { background:#0f1724; padding:20px;border-radius:8px; width:520px; box-shadow:0 6px 18px rgba(2,6,23,0.6); }
|
||||
label{display:block;margin-top:12px;font-size:13px;color:#cbd5e1}
|
||||
input{width:100%;padding:8px;border-radius:6px;border:1px solid #334155;background:#020617;color:#fff}
|
||||
button{margin-top:14px;padding:10px 14px;border-radius:6px;border:none;background:#2563eb;color:#fff;cursor:pointer}
|
||||
pre{background:#020617;padding:10px;border-radius:6px;overflow:auto;color:#9aa8ff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>Crear sesión (Broadcast Panel)</h2>
|
||||
<p>Genera una sessionId en el token-server y abre la ruta del broadcast panel: <code>https://avanzacast-broadcastpanel.bfzqqk.easypanel.host/<sessionId></code></p>
|
||||
|
||||
<label>Room (opcional)</label>
|
||||
<input id="room" placeholder="broadcast-room-name (default: random)" />
|
||||
|
||||
<label>Username</label>
|
||||
<input id="username" placeholder="Xesar" value="Guest" />
|
||||
|
||||
<label>Token server (opcional)</label>
|
||||
<input id="backend" placeholder="https://avanzacast-servertokens.bfzqqk.easypanel.host" value="https://avanzacast-servertokens.bfzqqk.easypanel.host" />
|
||||
|
||||
<div style="display:flex;gap:8px">
|
||||
<button id="createBtn">Crear sesión y abrir Studio</button>
|
||||
<button id="createOnly">Crear sin abrir</button>
|
||||
</div>
|
||||
|
||||
<div id="log" style="margin-top:12px;display:none">
|
||||
<div style="font-size:13px;color:#9ca3af;margin-bottom:6px">Resultado (JSON):</div>
|
||||
<pre id="out"></pre>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;color:#94a3b8;font-size:13px">Nota: este script intenta POST `/api/session` al token-server y abrir la URL del broadcast panel producida.</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function createSession(openAfter = false) {
|
||||
const room = document.getElementById('room').value || undefined;
|
||||
const username = document.getElementById('username').value || 'Guest';
|
||||
let backend = document.getElementById('backend').value || '';
|
||||
if (!backend) backend = 'https://avanzacast-servertokens.bfzqqk.easypanel.host';
|
||||
const url = backend.replace(/\/$/, '') + '/api/session';
|
||||
|
||||
const out = document.getElementById('out');
|
||||
const log = document.getElementById('log');
|
||||
log.style.display = 'block';
|
||||
out.textContent = 'Enviando petición...';
|
||||
|
||||
try {
|
||||
const body = { username };
|
||||
if (room) body.room = room;
|
||||
const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const txt = await resp.text();
|
||||
let json;
|
||||
try { json = JSON.parse(txt); } catch (e) { json = { raw: txt } }
|
||||
out.textContent = JSON.stringify({ status: resp.status, body: json }, null, 2);
|
||||
if (!resp.ok) return;
|
||||
|
||||
const id = json && (json.id || json.sessionId) ? (json.id || json.sessionId) : null;
|
||||
const studioHost = (new URL(window.location.href)).origin || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
|
||||
const sessionUrl = json && json.studioUrl ? json.studioUrl : (studioHost.replace(/\/$/, '') + '/' + encodeURIComponent(id || ''));
|
||||
out.textContent += '\n\nOpen URL:\n' + sessionUrl;
|
||||
if (openAfter && sessionUrl) {
|
||||
window.location.href = sessionUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
out.textContent = 'Error: ' + String(err);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('createBtn').addEventListener('click', () => createSession(true));
|
||||
document.getElementById('createOnly').addEventListener('click', () => createSession(false));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
0
packages/broadcast-panel/scripts/capture_prejoin.js
Normal file
82
packages/broadcast-panel/scripts/connect-via-backend.js
Normal file
@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env node
|
||||
/* connect-via-backend.js
|
||||
|
||||
Simple script de prueba que solicita al backend /api/connection-details y luego intenta una conexión WebSocket al servidor LiveKit
|
||||
USO: node scripts/connect-via-backend.js --backend http://localhost:4000 --room test_room --name tester
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
import WebSocket from 'ws';
|
||||
import { argv } from 'process';
|
||||
|
||||
function parseArgs() {
|
||||
const out = { backend: 'http://localhost:4000', room: 'test_room', name: 'tester' };
|
||||
for (let i = 2; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--backend' && argv[i+1]) { out.backend = argv[i+1]; i++; continue }
|
||||
if (a === '--room' && argv[i+1]) { out.room = argv[i+1]; i++; continue }
|
||||
if (a === '--name' && argv[i+1]) { out.name = argv[i+1]; i++; continue }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const { backend, room, name } = parseArgs();
|
||||
console.log('Requesting token from backend:', backend, 'room=', room, 'name=', name);
|
||||
try {
|
||||
const resp = await fetch(`${backend}/api/connection-details?room=${encodeURIComponent(room)}&participantName=${encodeURIComponent(name)}`);
|
||||
if (!resp.ok) {
|
||||
console.error('Backend responded', resp.status, await resp.text());
|
||||
process.exit(2);
|
||||
}
|
||||
const json = await resp.json();
|
||||
console.log('Backend returned:', json);
|
||||
const { serverUrl, participantToken } = json;
|
||||
if (!serverUrl || !participantToken) {
|
||||
console.error('Missing serverUrl or participantToken in backend response');
|
||||
process.exit(3);
|
||||
}
|
||||
|
||||
// Build wss url param if serverUrl is http(s)
|
||||
let wsUrl = serverUrl;
|
||||
if (wsUrl.startsWith('http://')) wsUrl = wsUrl.replace(/^http:\/\//, 'ws://');
|
||||
if (wsUrl.startsWith('https://')) wsUrl = wsUrl.replace(/^https:\/\//, 'wss://');
|
||||
// append path if not present
|
||||
if (!wsUrl.endsWith('/')) wsUrl = wsUrl.replace(/\/$/, '');
|
||||
const connectUrl = `${wsUrl}/rtc?access_token=${encodeURIComponent(participantToken)}`;
|
||||
console.log('Attempting WebSocket connect to', connectUrl.slice(0,200));
|
||||
|
||||
const ws = new WebSocket(connectUrl, { rejectUnauthorized: false });
|
||||
let connected = false;
|
||||
ws.on('open', () => {
|
||||
connected = true;
|
||||
console.log('WebSocket opened OK');
|
||||
// close after a short timeout
|
||||
setTimeout(() => { ws.close(); }, 1500);
|
||||
});
|
||||
ws.on('message', (msg) => {
|
||||
try { console.log('Message:', msg.toString().slice(0,200)); } catch (e) { }
|
||||
});
|
||||
ws.on('close', (code, reason) => {
|
||||
console.log('WebSocket closed', code, reason && reason.toString ? reason.toString() : reason);
|
||||
process.exit(0);
|
||||
});
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error', err && err.message ? err.message : err);
|
||||
if (!connected) process.exit(4);
|
||||
});
|
||||
|
||||
// Timeout if no connect
|
||||
setTimeout(() => {
|
||||
if (!connected) {
|
||||
console.error('Connection timeout');
|
||||
process.exit(5);
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
} catch (e) {
|
||||
console.error('Script failed', String(e));
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
67
packages/broadcast-panel/scripts/create_session.js
Normal file
@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env node
|
||||
// create_session.js
|
||||
// Usage: node scripts/create_session.js [room] [username] [--open]
|
||||
// Environment overrides: BACKEND (default http://localhost:4000) or TOKEN_SERVER env
|
||||
|
||||
const fetch = require('node-fetch');
|
||||
const child = require('child_process');
|
||||
|
||||
async function main() {
|
||||
console.log('[create_session] starting');
|
||||
const args = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
||||
const openFlag = process.argv.slice(2).some(a => a === '--open');
|
||||
const room = args[0] || `broadcast-${Math.random().toString(36).slice(2,8)}`;
|
||||
const username = args[1] || 'browser-user';
|
||||
|
||||
const backend = process.env.BACKEND || process.env.VITE_BACKEND_API_URL || process.env.VITE_TOKEN_SERVER_URL || 'http://localhost:4000';
|
||||
const base = backend.replace(/\/$/, '');
|
||||
const url = `${base}/api/session`;
|
||||
|
||||
console.log('[create_session] Creating session on', url);
|
||||
console.log('[create_session] room=', room, 'username=', username);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ room, username }), signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
console.log('[create_session] response status=', r.status);
|
||||
const text = await r.text().catch(() => null);
|
||||
console.log('[create_session] response body (raw):', text && text.slice(0,2000));
|
||||
let json = null;
|
||||
try { json = text ? JSON.parse(text) : null; } catch (e) { json = null }
|
||||
if (!r.ok) {
|
||||
console.error('[create_session] Request failed status=', r.status);
|
||||
console.error('[create_session] Body:', text);
|
||||
process.exit(2);
|
||||
}
|
||||
const id = (json && (json.id || json.sessionId)) || null;
|
||||
if (!id) {
|
||||
console.error('[create_session] No session id in response:', json || text);
|
||||
process.exit(3);
|
||||
}
|
||||
const studioHost = process.env.BROADCAST_HOST || 'https://avanzacast-broadcastpanel.bfzqqk.easypanel.host';
|
||||
const sessionUrl = `${studioHost.replace(/\/$/, '')}/${encodeURIComponent(id)}`;
|
||||
console.log('[create_session] Session created:', JSON.stringify(json, null, 2));
|
||||
console.log('[create_session] Open Studio URL:', sessionUrl);
|
||||
|
||||
if (openFlag) {
|
||||
// xdg-open for linux
|
||||
try {
|
||||
child.execSync(`xdg-open "${sessionUrl}"`);
|
||||
} catch (e) {
|
||||
console.warn('[create_session] Failed to open browser automatically:', String(e.message || e));
|
||||
}
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') {
|
||||
console.error('[create_session] request timed out');
|
||||
} else {
|
||||
console.error('[create_session] Failed to create session:', String(err));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
0
packages/broadcast-panel/scripts/node_test.cjs
Normal file
0
packages/broadcast-panel/scripts/runner_wrapper.cjs
Normal file
@ -1,22 +1,55 @@
|
||||
import React from 'react'
|
||||
import React from "react";
|
||||
|
||||
interface BannerProps {
|
||||
children: React.ReactNode
|
||||
type?: 'info' | 'error' | 'warning'
|
||||
onClose?: () => void
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
actionDisabled?: boolean
|
||||
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
|
||||
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={`${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>
|
||||
@ -26,17 +59,22 @@ const Banner: React.FC<BannerProps> = ({ children, type = 'info', onClose, actio
|
||||
<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'}`}
|
||||
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>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white/80 underline ml-2 text-sm"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Banner
|
||||
export default Banner;
|
||||
|
||||
@ -8,20 +8,20 @@
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid rgba(0,0,0,0.04);
|
||||
border: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-radius: 8px;
|
||||
/* lighter shadow so it's less pronounced */
|
||||
box-shadow: 0 6px 14px rgba(2,6,23,0.10);
|
||||
box-shadow: 0 6px 14px rgba(2, 6, 23, 0.1);
|
||||
/* match the create-card min width (220px) so menus align visually */
|
||||
width: 220px;
|
||||
padding: 4px 0;
|
||||
z-index: 1200;
|
||||
animation: slideDown 0.18s cubic-bezier(.2,.9,.2,1);
|
||||
animation: slideDown 0.18s cubic-bezier(0.2, 0.9, 0.2, 1);
|
||||
}
|
||||
|
||||
/* caret (little pointer) under the trigger */
|
||||
.dropdownMenu::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
/* horizontal offset from the right edge; configurable via --dropdown-caret-right */
|
||||
@ -30,8 +30,8 @@
|
||||
height: 12px;
|
||||
background: var(--surface-color);
|
||||
transform: rotate(45deg);
|
||||
border-left: 1px solid rgba(0,0,0,0.04);
|
||||
border-top: 1px solid rgba(0,0,0,0.04);
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.04);
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
@ -72,12 +72,12 @@
|
||||
}
|
||||
|
||||
.dropdownItem.deleteItem:hover {
|
||||
background-color: rgba(234,67,53,0.04);
|
||||
background-color: rgba(234, 67, 53, 0.04);
|
||||
}
|
||||
|
||||
.dropdownItem:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59,130,246,0.12);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
|
||||
}
|
||||
|
||||
/* ensure header (non-interactive) doesn't get hover background */
|
||||
@ -103,12 +103,12 @@
|
||||
padding: 8px 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
opacity: 0.90; /* user requested opacity */
|
||||
opacity: 0.9; /* user requested opacity */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dropdownHeaderLabel {
|
||||
opacity: 0.90;
|
||||
opacity: 0.9;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@ -134,7 +134,7 @@
|
||||
|
||||
/* make delete icon sit inside a small rounded red box like the screenshot */
|
||||
.deleteItem .icon {
|
||||
background: rgba(234,67,53,0.08); /* subtle red bg */
|
||||
background: rgba(234, 67, 53, 0.08); /* subtle red bg */
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
display: inline-flex;
|
||||
@ -145,6 +145,6 @@
|
||||
/* tighten divider */
|
||||
.divider {
|
||||
height: 1px;
|
||||
background-color: rgba(0,0,0,0.04);
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import styles from './Dropdown.module.css';
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import styles from "./Dropdown.module.css";
|
||||
|
||||
interface DropdownItem {
|
||||
label: string;
|
||||
@ -22,26 +22,27 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className={styles.dropdown} ref={dropdownRef}>
|
||||
<div onClick={() => setIsOpen(!isOpen)}>
|
||||
{trigger}
|
||||
</div>
|
||||
|
||||
<div onClick={() => setIsOpen(!isOpen)}>{trigger}</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className={styles.dropdownMenu}>
|
||||
{items.map((item, index) => (
|
||||
@ -52,17 +53,31 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
|
||||
(() => {
|
||||
const cp = item.containerProps || {};
|
||||
const { className: cpClassName, ...cpRest } = cp as any;
|
||||
const headerClasses = [styles.dropdownHeader, cpClassName].filter(Boolean).join(' ');
|
||||
const headerClasses = [styles.dropdownHeader, cpClassName]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div className={headerClasses} {...cpRest}>
|
||||
{item.icon && <span className={styles.icon}>{item.icon}</span>}
|
||||
{item.icon && (
|
||||
<span className={styles.icon}>{item.icon}</span>
|
||||
)}
|
||||
{
|
||||
// merge className for the header label
|
||||
(() => {
|
||||
const lp = item.labelProps || {};
|
||||
const { className: lpClassName, ...lpOther } = lp as any;
|
||||
const classes = [styles.dropdownHeaderLabel, lpClassName].filter(Boolean).join(' ');
|
||||
return <span className={classes} {...lpOther}>{item.label}</span>;
|
||||
const { className: lpClassName, ...lpOther } =
|
||||
lp as any;
|
||||
const classes = [
|
||||
styles.dropdownHeaderLabel,
|
||||
lpClassName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<span className={classes} {...lpOther}>
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
@ -72,7 +87,9 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
|
||||
(() => {
|
||||
const cp = item.containerProps || {};
|
||||
const { className: cpClassName, ...cpRest } = cp as any;
|
||||
const btnClasses = [styles.dropdownItem, cpClassName].filter(Boolean).join(' ');
|
||||
const btnClasses = [styles.dropdownItem, cpClassName]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<button
|
||||
className={btnClasses}
|
||||
@ -82,15 +99,20 @@ export const Dropdown: React.FC<DropdownProps> = ({ trigger, items }) => {
|
||||
}}
|
||||
{...cpRest}
|
||||
>
|
||||
{item.icon && <span className={styles.icon}>{item.icon}</span>}
|
||||
{
|
||||
(() => {
|
||||
const lp = item.labelProps || {};
|
||||
const { className: lpClassName, ...lpOther } = lp as any;
|
||||
const classes = [lpClassName].filter(Boolean).join(' ');
|
||||
return <span className={classes} {...lpOther}>{item.label}</span>;
|
||||
})()
|
||||
}
|
||||
{item.icon && (
|
||||
<span className={styles.icon}>{item.icon}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const lp = item.labelProps || {};
|
||||
const { className: lpClassName, ...lpOther } =
|
||||
lp as any;
|
||||
const classes = [lpClassName].filter(Boolean).join(" ");
|
||||
return (
|
||||
<span className={classes} {...lpOther}>
|
||||
{item.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
);
|
||||
})()
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Modal, ModalLink, ModalCopyInput, ModalShareButtons, ModalToggle } from '@shared/components'
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalLink,
|
||||
ModalCopyInput,
|
||||
ModalShareButtons,
|
||||
ModalToggle,
|
||||
} from "@shared/components";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -11,7 +17,7 @@ interface Props {
|
||||
* Demuestra cómo reutilizar las partes creadas
|
||||
*/
|
||||
const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -21,14 +27,14 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
|
||||
width="md"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: 'transparent',
|
||||
border: '1px solid #dadce0',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
border: "1px solid #dadce0",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
@ -36,12 +42,12 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 24px',
|
||||
background: '#1a73e8',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
padding: "8px 24px",
|
||||
background: "#1a73e8",
|
||||
color: "white",
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Aceptar
|
||||
@ -49,21 +55,25 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<p style={{ margin: 0, color: '#5f6368', fontSize: '14px' }}>
|
||||
Este es un ejemplo de cómo usar los componentes modulares. Puedes leer más en{' '}
|
||||
<ModalLink href="https://example.com/docs">nuestra documentación</ModalLink>.
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
<p style={{ margin: 0, color: "#5f6368", fontSize: "14px" }}>
|
||||
Este es un ejemplo de cómo usar los componentes modulares. Puedes leer
|
||||
más en{" "}
|
||||
<ModalLink href="https://example.com/docs">
|
||||
nuestra documentación
|
||||
</ModalLink>
|
||||
.
|
||||
</p>
|
||||
|
||||
<ModalCopyInput
|
||||
<ModalCopyInput
|
||||
value="https://example.com/invite/abc123"
|
||||
buttonText="Copiar enlace"
|
||||
/>
|
||||
|
||||
<ModalShareButtons
|
||||
onGmail={() => console.log('Gmail')}
|
||||
onEmail={() => console.log('Email')}
|
||||
onMessenger={() => console.log('Messenger')}
|
||||
onGmail={() => console.log("Gmail")}
|
||||
onEmail={() => console.log("Email")}
|
||||
onMessenger={() => console.log("Messenger")}
|
||||
/>
|
||||
|
||||
<ModalToggle
|
||||
@ -74,7 +84,7 @@ const ExampleModal: React.FC<Props> = ({ open, onClose }) => {
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default ExampleModal
|
||||
export default ExampleModal;
|
||||
|
||||
@ -9,7 +9,9 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
@ -36,7 +38,7 @@
|
||||
|
||||
.segmentControl {
|
||||
display: inline-flex;
|
||||
background-color: rgba(0,0,0,0.06);
|
||||
background-color: rgba(0, 0, 0, 0.06);
|
||||
border-radius: 999px;
|
||||
padding: 4px;
|
||||
gap: 6px;
|
||||
@ -62,8 +64,12 @@
|
||||
}
|
||||
|
||||
.activeSegment {
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(0,0,0,0.04));
|
||||
box-shadow: 0 6px 14px rgba(2,6,23,0.4);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.02),
|
||||
rgba(0, 0, 0, 0.04)
|
||||
);
|
||||
box-shadow: 0 6px 14px rgba(2, 6, 23, 0.4);
|
||||
color: var(--surface-color);
|
||||
}
|
||||
|
||||
@ -114,7 +120,7 @@
|
||||
}
|
||||
|
||||
.userEmail {
|
||||
opacity: 0.90;
|
||||
opacity: 0.9;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@ -126,7 +132,7 @@
|
||||
|
||||
/* Dropdown trigger style polished */
|
||||
.userMenu:after {
|
||||
content: '';
|
||||
content: "";
|
||||
}
|
||||
|
||||
.userMenu svg {
|
||||
|
||||
@ -1,52 +1,76 @@
|
||||
import React from 'react'
|
||||
import { MdLightMode, MdDarkMode, MdNotifications, MdPerson, MdLogout, MdHelpOutline } from 'react-icons/md'
|
||||
import { useTheme } from './ThemeProvider'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { Dropdown } from './Dropdown'
|
||||
import styles from './Header.module.css'
|
||||
import React from "react";
|
||||
import {
|
||||
MdLightMode,
|
||||
MdDarkMode,
|
||||
MdNotifications,
|
||||
MdPerson,
|
||||
MdLogout,
|
||||
MdHelpOutline,
|
||||
} from "react-icons/md";
|
||||
import { useTheme } from "./ThemeProvider";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { Dropdown } from "./Dropdown";
|
||||
import styles from "./Header.module.css";
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const { theme, resolvedTheme, setThemeMode } = useTheme()
|
||||
const { theme, resolvedTheme, setThemeMode } = useTheme();
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('mock_user')
|
||||
window.location.href = '/auth/login'
|
||||
}
|
||||
localStorage.removeItem("mock_user");
|
||||
window.location.href = "/auth/login";
|
||||
};
|
||||
|
||||
// Read mock user from localStorage (if set elsewhere in the app)
|
||||
const storedUser = typeof window !== 'undefined' ? localStorage.getItem('mock_user') : null
|
||||
let email = 'Usuario'
|
||||
let avatarUrl: string | null = null
|
||||
let displayName: string | null = null
|
||||
const storedUser =
|
||||
typeof window !== "undefined" ? localStorage.getItem("mock_user") : null;
|
||||
let email = "Usuario";
|
||||
let avatarUrl: string | null = null;
|
||||
let displayName: string | null = null;
|
||||
try {
|
||||
if (storedUser) {
|
||||
const parsed = JSON.parse(storedUser)
|
||||
email = parsed.email || storedUser || 'Usuario'
|
||||
avatarUrl = parsed.avatar || parsed.photo || parsed.picture || null
|
||||
displayName = parsed.name || parsed.fullname || null
|
||||
const parsed = JSON.parse(storedUser);
|
||||
email = parsed.email || storedUser || "Usuario";
|
||||
avatarUrl = parsed.avatar || parsed.photo || parsed.picture || null;
|
||||
displayName = parsed.name || parsed.fullname || null;
|
||||
}
|
||||
} catch (e) {
|
||||
email = storedUser || 'Usuario'
|
||||
email = storedUser || "Usuario";
|
||||
}
|
||||
|
||||
const computeInitials = (nameOrEmail: string) => {
|
||||
if (!nameOrEmail) return 'U'
|
||||
const name = nameOrEmail.split('@')[0]
|
||||
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean)
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||
return nameOrEmail.slice(0, 2).toUpperCase()
|
||||
}
|
||||
if (!nameOrEmail) return "U";
|
||||
const name = nameOrEmail.split("@")[0];
|
||||
const parts = name.split(/[^A-Za-z0-9]+/).filter(Boolean);
|
||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return nameOrEmail.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
const initials = computeInitials(displayName || email)
|
||||
const initials = computeInitials(displayName || email);
|
||||
|
||||
const avatarElement = avatarUrl ? (
|
||||
<img src={avatarUrl} alt="avatar" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt="avatar"
|
||||
style={{ width: 28, height: 28, borderRadius: "50%", objectFit: "cover" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--primary-blue)', color: 'white', display: 'grid', placeItems: 'center', fontSize: 12, fontWeight: 700 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: "var(--primary-blue)",
|
||||
color: "white",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
const userMenuItems = [
|
||||
// non-interactive header with email
|
||||
@ -54,56 +78,53 @@ const Header: React.FC = () => {
|
||||
label: email,
|
||||
icon: avatarElement,
|
||||
disabled: true,
|
||||
containerProps: { 'data-test': 'user-email', id: 'user-email' },
|
||||
labelProps: { style: { opacity: 0.7 } }
|
||||
containerProps: { "data-test": "user-email", id: "user-email" },
|
||||
labelProps: { style: { opacity: 0.7 } },
|
||||
},
|
||||
{
|
||||
label: 'Mi perfil',
|
||||
label: "Mi perfil",
|
||||
icon: <MdPerson size={18} />,
|
||||
onClick: () => console.log('Ir a perfil'),
|
||||
divider: true // separator after the header
|
||||
onClick: () => console.log("Ir a perfil"),
|
||||
divider: true, // separator after the header
|
||||
},
|
||||
{
|
||||
label: 'Ayuda',
|
||||
label: "Ayuda",
|
||||
icon: <MdHelpOutline size={18} />,
|
||||
onClick: () => console.log('Ir a ayuda')
|
||||
onClick: () => console.log("Ir a ayuda"),
|
||||
},
|
||||
{
|
||||
label: 'Cerrar sesión',
|
||||
label: "Cerrar sesión",
|
||||
icon: <MdLogout size={18} />,
|
||||
onClick: handleLogout,
|
||||
divider: true
|
||||
}
|
||||
]
|
||||
divider: true,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<header className={styles.header}>
|
||||
<div></div> {/* Spacer */}
|
||||
|
||||
<div className={styles.headerActions}>
|
||||
<button className={styles.planButton}>
|
||||
Mejora tu plan
|
||||
</button>
|
||||
<button className={styles.planButton}>Mejora tu plan</button>
|
||||
|
||||
{/* Segmented theme control: Sistema / Claro / Oscuro */}
|
||||
<div className={styles.segmentControl} role="tablist" aria-label="Tema">
|
||||
<button
|
||||
className={`${styles.segmentButton} ${theme === 'system' ? styles.activeSegment : ''}`}
|
||||
onClick={() => setThemeMode('system')}
|
||||
<button
|
||||
className={`${styles.segmentButton} ${theme === "system" ? styles.activeSegment : ""}`}
|
||||
onClick={() => setThemeMode("system")}
|
||||
title="Usar tema del sistema"
|
||||
>
|
||||
<span className={styles.segmentIcon}>⚙</span>
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.segmentButton} ${theme === 'light' ? styles.activeSegment : ''}`}
|
||||
onClick={() => setThemeMode('light')}
|
||||
<button
|
||||
className={`${styles.segmentButton} ${theme === "light" ? styles.activeSegment : ""}`}
|
||||
onClick={() => setThemeMode("light")}
|
||||
title="Modo claro"
|
||||
>
|
||||
<MdLightMode size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.segmentButton} ${theme === 'dark' ? styles.activeSegment : ''}`}
|
||||
onClick={() => setThemeMode('dark')}
|
||||
<button
|
||||
className={`${styles.segmentButton} ${theme === "dark" ? styles.activeSegment : ""}`}
|
||||
onClick={() => setThemeMode("dark")}
|
||||
title="Modo oscuro"
|
||||
>
|
||||
<MdDarkMode size={16} />
|
||||
@ -116,7 +137,7 @@ const Header: React.FC = () => {
|
||||
<span className={styles.notificationDot}></span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
<Dropdown
|
||||
trigger={
|
||||
<div className={styles.userMenu}>
|
||||
@ -127,7 +148,7 @@ const Header: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default Header
|
||||
export default Header;
|
||||
|
||||
@ -57,7 +57,6 @@
|
||||
border-top-color: #5f6368;
|
||||
}
|
||||
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .helpText,
|
||||
[data-theme="dark"] .planText {
|
||||
@ -67,4 +66,3 @@
|
||||
[data-theme="dark"] .toggle {
|
||||
border-top-color: #5f6368;
|
||||
}
|
||||
|
||||
|
||||
@ -1,27 +1,36 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Modal, ModalLink, ModalCopyInput, ModalShareButtons, ModalToggle } from '@shared/components'
|
||||
import styles from './InviteGuestsModal.module.css'
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
Modal,
|
||||
ModalLink,
|
||||
ModalCopyInput,
|
||||
ModalShareButtons,
|
||||
ModalToggle,
|
||||
} from "@shared/components";
|
||||
import styles from "./InviteGuestsModal.module.css";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
link: string
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
link: string;
|
||||
}
|
||||
|
||||
const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
|
||||
const [guestPermissions, setGuestPermissions] = useState(true)
|
||||
const [guestPermissions, setGuestPermissions] = useState(true);
|
||||
|
||||
const handleGmailShare = () => {
|
||||
window.open(`https://mail.google.com/mail/?view=cm&body=${encodeURIComponent(link)}`, '_blank')
|
||||
}
|
||||
window.open(
|
||||
`https://mail.google.com/mail/?view=cm&body=${encodeURIComponent(link)}`,
|
||||
"_blank",
|
||||
);
|
||||
};
|
||||
|
||||
const handleEmailShare = () => {
|
||||
window.location.href = `mailto:?body=${encodeURIComponent(link)}`
|
||||
}
|
||||
window.location.href = `mailto:?body=${encodeURIComponent(link)}`;
|
||||
};
|
||||
|
||||
const handleMessengerShare = () => {
|
||||
console.log('Compartir por Messenger:', link)
|
||||
}
|
||||
console.log("Compartir por Messenger:", link);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -32,19 +41,21 @@ const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<p className={styles.helpText}>
|
||||
Envía este enlace a tus invitados. Es posible que también quieras compartir nuestras {' '}
|
||||
Envía este enlace a tus invitados. Es posible que también quieras
|
||||
compartir nuestras{" "}
|
||||
<ModalLink href="https://support.streamyard.com/hc/en-us/articles/360043291612">
|
||||
instrucciones para invitados
|
||||
</ModalLink>.
|
||||
</ModalLink>
|
||||
.
|
||||
</p>
|
||||
|
||||
<p className={styles.planText}>
|
||||
Puedes tener hasta 6 personas en pantalla a la vez. {' '}
|
||||
<ModalLink href="/pricing">Mejora tu plan</ModalLink> {' '}
|
||||
si necesitas más.
|
||||
Puedes tener hasta 6 personas en pantalla a la vez.{" "}
|
||||
<ModalLink href="/pricing">Mejora tu plan</ModalLink> si necesitas
|
||||
más.
|
||||
</p>
|
||||
|
||||
<ModalCopyInput
|
||||
<ModalCopyInput
|
||||
value={link}
|
||||
buttonText="Copiar"
|
||||
className={styles.copyInput}
|
||||
@ -66,7 +77,7 @@ const InviteGuestsModal: React.FC<Props> = ({ open, onClose, link }) => {
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteGuestsModal
|
||||
export default InviteGuestsModal;
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
// MockToggle removed: mock studio feature is disabled in this codebase.
|
||||
// This file was intentionally left blank to avoid build errors from leftover imports.
|
||||
export default function MockToggle() { return null as any }
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
}
|
||||
|
||||
.selectedDestination::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
|
||||
@ -53,14 +53,17 @@
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.18s ease, transform 0.12s ease, border-color 0.12s ease;
|
||||
transition:
|
||||
box-shadow 0.18s ease,
|
||||
transform 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
position: relative; /* for plus overlay */
|
||||
}
|
||||
|
||||
.createCard:hover {
|
||||
box-shadow: 0 6px 18px rgba(16,24,40,0.06);
|
||||
box-shadow: 0 6px 18px rgba(16, 24, 40, 0.06);
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--primary-blue);
|
||||
}
|
||||
@ -81,7 +84,9 @@
|
||||
transform: translateY(-50%) translateX(8px) scale(0.85);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: transform 0.18s ease, opacity 0.18s ease;
|
||||
transition:
|
||||
transform 0.18s ease,
|
||||
opacity 0.18s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@ -103,16 +108,34 @@
|
||||
|
||||
/* color variants for left icon box and plus badge */
|
||||
.cardBlue .createIconBox,
|
||||
.cardBlue .plusBadge { background: rgba(26,115,232,0.12); }
|
||||
.cardBlue .createIconBox svg { color: var(--primary-blue); fill: var(--primary-blue); stroke: var(--primary-blue); }
|
||||
.cardBlue .plusBadge {
|
||||
background: rgba(26, 115, 232, 0.12);
|
||||
}
|
||||
.cardBlue .createIconBox svg {
|
||||
color: var(--primary-blue);
|
||||
fill: var(--primary-blue);
|
||||
stroke: var(--primary-blue);
|
||||
}
|
||||
|
||||
.cardRed .createIconBox,
|
||||
.cardRed .plusBadge { background: rgba(234,67,53,0.12); }
|
||||
.cardRed .createIconBox svg { color: #ea4335; fill: #ea4335; stroke: #ea4335; }
|
||||
.cardRed .plusBadge {
|
||||
background: rgba(234, 67, 53, 0.12);
|
||||
}
|
||||
.cardRed .createIconBox svg {
|
||||
color: #ea4335;
|
||||
fill: #ea4335;
|
||||
stroke: #ea4335;
|
||||
}
|
||||
|
||||
.cardGreen .createIconBox,
|
||||
.cardGreen .plusBadge { background: rgba(52,168,83,0.12); }
|
||||
.cardGreen .createIconBox svg { color: #34a853; fill: #34a853; stroke: #34a853; }
|
||||
.cardGreen .plusBadge {
|
||||
background: rgba(52, 168, 83, 0.12);
|
||||
}
|
||||
.cardGreen .createIconBox svg {
|
||||
color: #34a853;
|
||||
fill: #34a853;
|
||||
stroke: #34a853;
|
||||
}
|
||||
|
||||
/* ensure icons are solid and not dimmed */
|
||||
.createIconBox svg {
|
||||
|
||||
@ -1,69 +1,88 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { MdVideocam, MdFiberManualRecord, MdSchool } from 'react-icons/md'
|
||||
import PlusLarge from './icons/PlusLarge'
|
||||
import { ThemeProvider } from './ThemeProvider'
|
||||
import { Skeleton, SkeletonCard } from './Skeleton'
|
||||
import styles from './PageContainer.module.css'
|
||||
import Sidebar from './Sidebar'
|
||||
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'
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { MdVideocam, MdFiberManualRecord, MdSchool } from "react-icons/md";
|
||||
import PlusLarge from "./icons/PlusLarge";
|
||||
import { ThemeProvider } from "./ThemeProvider";
|
||||
import { SkeletonCard } from "./Skeleton";
|
||||
import styles from "./PageContainer.module.css";
|
||||
import Sidebar from "./Sidebar";
|
||||
import Header from "./Header";
|
||||
import TransmissionsTable from "./TransmissionsTable";
|
||||
import { NewTransmissionModal } from "@shared/components";
|
||||
import Studio from "./Studio";
|
||||
import type { Transmission } from "@shared/types";
|
||||
|
||||
const STORAGE_KEY = 'broadcast_transmissions'
|
||||
const STORAGE_KEY = "broadcast_transmissions";
|
||||
|
||||
const PageContainer: React.FC = () => {
|
||||
const [transmissions, setTransmissions] = useState<Transmission[]>([])
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [currentPage, setCurrentPage] = useState<string>('inicio')
|
||||
const [transmissions, setTransmissions] = useState<Transmission[]>([]);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
// Determine initial page: default 'inicio' but allow opening the Studio when feature enabled
|
||||
const initialPage = (() => {
|
||||
try {
|
||||
// feature via Vite env at build time
|
||||
const envFlag =
|
||||
(import.meta.env.VITE_FEATURE_STUDIOPORTAL as string) === "1";
|
||||
if (envFlag) return "studio";
|
||||
// runtime toggle via localStorage (useful during development)
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.localStorage.getItem("feature_studioportal") === "1"
|
||||
)
|
||||
return "studio";
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
return "inicio";
|
||||
})();
|
||||
const [currentPage, setCurrentPage] = useState<string>(initialPage);
|
||||
|
||||
useEffect(() => {
|
||||
// Simular carga de datos
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) setTransmissions(JSON.parse(raw))
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) setTransmissions(JSON.parse(raw));
|
||||
} catch (e) {
|
||||
console.error('Failed to load transmissions', e)
|
||||
console.error("Failed to load transmissions", e);
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, 800)
|
||||
}, [])
|
||||
setIsLoading(false);
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(transmissions))
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(transmissions));
|
||||
} catch (e) {
|
||||
console.error('Failed to save transmissions', e)
|
||||
console.error("Failed to save transmissions", e);
|
||||
}
|
||||
}
|
||||
}, [transmissions, isLoading])
|
||||
}, [transmissions, isLoading]);
|
||||
|
||||
const handleCreate = (t: Transmission) => {
|
||||
setTransmissions(prev => [t, ...prev])
|
||||
setIsModalOpen(false)
|
||||
}
|
||||
setTransmissions((prev) => [t, ...prev]);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setTransmissions(prev => prev.filter(p => p.id !== id))
|
||||
}
|
||||
setTransmissions((prev) => prev.filter((p) => p.id !== id));
|
||||
};
|
||||
|
||||
const handleUpdate = (updated: Transmission) => {
|
||||
setTransmissions(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
}
|
||||
setTransmissions((prev) =>
|
||||
prev.map((p) => (p.id === updated.id ? updated : p)),
|
||||
);
|
||||
};
|
||||
|
||||
const handleNavigate = (page: string) => {
|
||||
setCurrentPage(page)
|
||||
}
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
// Renderizar página según navegación
|
||||
if (currentPage === 'studio') {
|
||||
if (currentPage === "studio") {
|
||||
// Dev: render StudioConnector for quick testing of the session flow
|
||||
return <StudioConnector />
|
||||
return <Studio />;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -76,8 +95,16 @@ const PageContainer: React.FC = () => {
|
||||
<div className={styles.wrapContent}>
|
||||
<div className={styles.leftStack}>
|
||||
{/* Sección Crear */}
|
||||
<section style={{ marginBottom: '0' }}>
|
||||
<h2 style={{ fontSize: '22px', fontWeight: 600, marginBottom: '18px' }}>Crear</h2>
|
||||
<section style={{ marginBottom: "0" }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "22px",
|
||||
fontWeight: 600,
|
||||
marginBottom: "18px",
|
||||
}}
|
||||
>
|
||||
Crear
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className={styles.createGrid}>
|
||||
<SkeletonCard />
|
||||
@ -91,41 +118,63 @@ const PageContainer: React.FC = () => {
|
||||
className={`${styles.createCard} ${styles.cardBlue}`}
|
||||
>
|
||||
<div className={styles.createCardInner}>
|
||||
<div className={styles.createIconBox} style={{ background: 'rgba(26,115,232,0.08)' }}>
|
||||
<div
|
||||
className={styles.createIconBox}
|
||||
style={{ background: "rgba(26,115,232,0.08)" }}
|
||||
>
|
||||
<MdVideocam size={20} />
|
||||
</div>
|
||||
<span>Transmisión en vivo</span>
|
||||
</div>
|
||||
<span className={styles.createPlus}>
|
||||
<div className={styles.plusBadge} style={{ background: 'rgba(26,115,232,0.08)' }}>
|
||||
<div
|
||||
className={styles.plusBadge}
|
||||
style={{ background: "rgba(26,115,232,0.08)" }}
|
||||
>
|
||||
<PlusLarge size={20} color="var(--primary-blue)" />
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button className={`${styles.createCard} ${styles.cardRed}`}>
|
||||
<button
|
||||
className={`${styles.createCard} ${styles.cardRed}`}
|
||||
>
|
||||
<div className={styles.createCardInner}>
|
||||
<div className={styles.createIconBox} style={{ background: 'rgba(234,67,53,0.08)' }}>
|
||||
<div
|
||||
className={styles.createIconBox}
|
||||
style={{ background: "rgba(234,67,53,0.08)" }}
|
||||
>
|
||||
<MdFiberManualRecord size={20} />
|
||||
</div>
|
||||
<span>Grabación</span>
|
||||
</div>
|
||||
<span className={styles.createPlus}>
|
||||
<div className={styles.plusBadge} style={{ background: 'rgba(234,67,53,0.08)' }}>
|
||||
<div
|
||||
className={styles.plusBadge}
|
||||
style={{ background: "rgba(234,67,53,0.08)" }}
|
||||
>
|
||||
<PlusLarge size={20} color="#ea4335" />
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button className={`${styles.createCard} ${styles.cardGreen}`}>
|
||||
<button
|
||||
className={`${styles.createCard} ${styles.cardGreen}`}
|
||||
>
|
||||
<div className={styles.createCardInner}>
|
||||
<div className={styles.createIconBox} style={{ background: 'rgba(52,168,83,0.08)' }}>
|
||||
<div
|
||||
className={styles.createIconBox}
|
||||
style={{ background: "rgba(52,168,83,0.08)" }}
|
||||
>
|
||||
<MdSchool size={20} />
|
||||
</div>
|
||||
<span>Seminario web On-Air</span>
|
||||
</div>
|
||||
<span className={styles.createPlus}>
|
||||
<div className={styles.plusBadge} style={{ background: 'rgba(52,168,83,0.08)' }}>
|
||||
<div
|
||||
className={styles.plusBadge}
|
||||
style={{ background: "rgba(52,168,83,0.08)" }}
|
||||
>
|
||||
<PlusLarge size={20} color="#34a853" />
|
||||
</div>
|
||||
</span>
|
||||
@ -136,7 +185,13 @@ const PageContainer: React.FC = () => {
|
||||
|
||||
{/* Sección Transmisiones y grabaciones */}
|
||||
<section>
|
||||
<h2 style={{ fontSize: '22px', fontWeight: 600, marginBottom: '24px' }}>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "22px",
|
||||
fontWeight: 600,
|
||||
marginBottom: "24px",
|
||||
}}
|
||||
>
|
||||
Transmisiones y grabaciones
|
||||
</h2>
|
||||
<TransmissionsTable
|
||||
@ -162,7 +217,7 @@ const PageContainer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default PageContainer
|
||||
export default PageContainer;
|
||||
|
||||