feat(prejoin): refactor PreJoin UI and styles; remove mock studio feature; add visual test scripts and update dependencies
- Redesign PreJoin component and CSS for improved template compatibility and deterministic rendering - Remove mock studio toggle and related runtime logic; update useStudioLauncher to always use real backend - Add README-MOCK.md to document mock studio deprecation - Add mock-studio.html for manual popup emulation - Update environment variable resolution in route.ts for backend API - Add visual regression test scripts (capture, compare, visual_test_prejoin) using Playwright, Puppeteer, pixelmatch, and pngjs - Update package.json scripts and devDependencies for visual testing - Simplify PreJoin.stories.tsx for robust Storybook usage
This commit is contained in:
parent
f8516a5330
commit
adbec08f5e
@ -5,7 +5,7 @@ export async function GET(req: Request) {
|
|||||||
const id = parts[parts.length - 1] || '';
|
const id = parts[parts.length - 1] || '';
|
||||||
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } });
|
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400, headers: { 'content-type': 'application/json' } });
|
||||||
|
|
||||||
const backend = process.env.BACKEND_URL || process.env.VITE_BACKEND_TOKENS_URL || '';
|
const backend = process.env.VITE_BACKEND_API_URL || process.env.VITE_TOKEN_SERVER_URL || process.env.BACKEND_URL || process.env.BACKEND || '';
|
||||||
if (!backend) {
|
if (!backend) {
|
||||||
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||||
}
|
}
|
||||||
@ -19,4 +19,3 @@ export async function GET(req: Request) {
|
|||||||
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const backend = process.env.BACKEND_URL || process.env.VITE_BACKEND_TOKENS_URL || '';
|
// 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) {
|
if (!backend) {
|
||||||
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
return new Response(JSON.stringify({ error: 'BACKEND_URL not configured' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||||
}
|
}
|
||||||
@ -16,4 +17,3 @@ export async function POST(req: Request) {
|
|||||||
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
return new Response(JSON.stringify({ error: 'internal' }), { status: 500, headers: { 'content-type': 'application/json' } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
app/rooms/[roomName]/StudioReceiver.tsx
Normal file
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.
|
||||||
|
|
||||||
@ -15,4 +15,3 @@ export default function RoomPage({ params }: { params: { roomName: string } }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
130
docs/mock-studio.html
Normal file
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>
|
||||||
|
|
||||||
@ -94,9 +94,12 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mic-icon::before {
|
.mic-icon svg {
|
||||||
content: '🎤';
|
width: 24px;
|
||||||
font-size: 24px;
|
height: 24px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mic-meter {
|
.mic-meter {
|
||||||
@ -337,7 +340,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mic-status">
|
<div class="mic-status">
|
||||||
<div class="mic-icon"></div>
|
<div class="mic-icon">
|
||||||
|
<!-- replace emoji with inline SVG for deterministic rendering -->
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"></path>
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2"></path>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<div class="mic-meter">
|
<div class="mic-meter">
|
||||||
<div class="mic-level"></div>
|
<div class="mic-level"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
504
package-lock.json
generated
504
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently \"npm:dev:*\"",
|
"dev": "concurrently \"npm:dev:*\"",
|
||||||
|
"visual-test:prejoin": "node scripts/visual_test_prejoin.cjs",
|
||||||
"e2e:remote-chrome": "bash e2e/run-remote-chrome.sh",
|
"e2e:remote-chrome": "bash e2e/run-remote-chrome.sh",
|
||||||
"dev:landing": "npm run dev --workspace=packages/landing-page",
|
"dev:landing": "npm run dev --workspace=packages/landing-page",
|
||||||
"dev:api": "npm run dev --workspace=packages/backend-api",
|
"dev:api": "npm run dev --workspace=packages/backend-api",
|
||||||
@ -37,7 +38,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^8.2.2",
|
"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"
|
"typescript": "^5.2.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -45,7 +49,6 @@
|
|||||||
"npm": ">=10.0.0"
|
"npm": ">=10.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"puppeteer": "^19.11.1",
|
|
||||||
"puppeteer-core": "^24.30.0",
|
"puppeteer-core": "^24.30.0",
|
||||||
"react-icons": "^5.5.0"
|
"react-icons": "^5.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
18
packages/broadcast-panel/README-MOCK.md
Normal file
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.
|
||||||
@ -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 }
|
||||||
|
|
||||||
@ -1,165 +1,208 @@
|
|||||||
/* filepath: /home/xesar/Documentos/Nextream/AvanzaCast/packages/broadcast-panel/src/features/studio/PreJoin.module.css */
|
/* filepath: packages/broadcast-panel/src/features/studio/PreJoin.module.css */
|
||||||
:root{
|
* { box-sizing: border-box; }
|
||||||
--card-bg: #ffffff;
|
|
||||||
--muted: #666666;
|
|
||||||
--accent: #6366f1;
|
|
||||||
--badge-bg: rgba(99,102,241,0.9);
|
|
||||||
--danger: #dc2626;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prejoinContainer{
|
.container {
|
||||||
max-width: 628px;
|
max-width: 628px;
|
||||||
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
/* match template font stack to reduce font rendering diffs */
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card{
|
.header {
|
||||||
background: var(--card-bg);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header{
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header > div:first-child{
|
.header h1 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1a1a1a;
|
color: #1a1a1a;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note{
|
.header p {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: var(--muted);
|
color: #666666;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.contentRow{
|
.video-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewColumn{ flex: 1 }
|
.video-preview {
|
||||||
|
flex: 1;
|
||||||
.previewCard{
|
background-color: #0a0a1a;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
overflow: hidden;
|
|
||||||
background: #0a0a1a;
|
|
||||||
position: relative;
|
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.videoEl{
|
.user-badge {
|
||||||
width:100%;
|
|
||||||
height:100%;
|
|
||||||
object-fit:cover;
|
|
||||||
background:#0b0b0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge{
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
background: var(--badge-bg);
|
background-color: rgba(99, 102, 241, 0.9);
|
||||||
color: #fff;
|
color: white;
|
||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.micPanel{
|
.mic-status {
|
||||||
background-color: #f8f9fa;
|
background-color: #f8f9fa;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
min-width: 180px;
|
|
||||||
display:flex;
|
|
||||||
flex-direction:column;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.micPanelInner{
|
|
||||||
display:flex;
|
|
||||||
flex-direction:column;
|
|
||||||
align-items:center;
|
|
||||||
gap:8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mic-icon{ width:48px; height:48px; border-radius:50%; background:#e8e8e8; display:flex; align-items:center; justify-content:center; margin-bottom:12px }
|
|
||||||
|
|
||||||
.mic-meter{ width:32px; height:80px; background:#e8e8e8; border-radius:16px; margin-bottom:12px; position:relative; overflow:hidden }
|
|
||||||
.mic-level{ position:absolute; bottom:0; left:0; right:0; height:20%; background: linear-gradient(to top, #22c55e, #86efac); border-radius:16px; transition:height 0.1s ease-out }
|
|
||||||
|
|
||||||
.micStatus{ color: #22c55e; font-weight:500; font-size:14px; text-align:center; margin-bottom:4px }
|
|
||||||
.mic-device{ font-size:11px; color:#999999; text-align:center }
|
|
||||||
|
|
||||||
.controlsRow{
|
|
||||||
display:inline-flex;
|
|
||||||
justify-content:center;
|
|
||||||
gap:8px;
|
|
||||||
padding:12px;
|
|
||||||
background-color:var(--card-bg);
|
|
||||||
border:1px solid #e5e5e5;
|
|
||||||
border-radius:12px;
|
|
||||||
margin-bottom:24px;
|
|
||||||
margin-left:auto;
|
|
||||||
margin-right:auto;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.controlButtonLocal{ display:flex; flex-direction:column; align-items:center; gap:8px; background:transparent; border:none; cursor:pointer; color:var(--muted); font-size:13px; transition:all .2s; padding:12px 20px; border-radius:8px }
|
|
||||||
.controlButtonLocal:hover{ color:#1a1a1a; background-color:#fee2e2 }
|
|
||||||
|
|
||||||
.controlsRow > button[data-active="false"], .controlButtonLocal.disabled{ color:var(--danger); background-color:#fecaca }
|
|
||||||
.controlButtonLocal.disabled:hover, .controlsRow > button[data-active="false"]:hover{ color:#b91c1c; background-color:#fca5a5 }
|
|
||||||
|
|
||||||
.controlButtonLocal > span:first-child{ width:24px; height:24px; display:inline-flex; align-items:center; justify-content:center }
|
|
||||||
.controlButtonLocal > span:first-child svg{ width:24px; height:24px }
|
|
||||||
|
|
||||||
.control-hint{ position:absolute; bottom:100%; left:50%; transform:translateX(-50%); background-color:#1a1a1a; color:white; padding:6px 12px; border-radius:6px; font-size:12px; white-space:nowrap; opacity:0; pointer-events:none; transition:opacity .2s; margin-bottom:8px }
|
|
||||||
.controlButtonLocal:hover .control-hint{ opacity:1 }
|
|
||||||
|
|
||||||
.roomTitle{ margin-top:8px; margin-bottom:8px; font-weight:500; color:#1a1a1a }
|
|
||||||
.input{ width:100%; padding:12px 16px; border-radius:8px; border:1px solid #d1d5db; font-size:14px; margin-bottom:16px }
|
|
||||||
|
|
||||||
.shortcutsLegend{ text-align:center; margin-top:12px; color:var(--muted) }
|
|
||||||
.kbd{ background-color:#374151; padding:2px 6px; border-radius:3px; font-family:monospace; font-size:11px; color:#fff }
|
|
||||||
|
|
||||||
.checkboxRow{ margin-top:12px; margin-bottom:12px; display:flex; align-items:center; gap:8px }
|
|
||||||
|
|
||||||
.actions{ display:flex; gap:12px; margin-top:16px }
|
|
||||||
.cancelBtn{ background:transparent; border:1px solid #e5e7eb; padding:10px 14px; border-radius:8px; cursor:pointer }
|
|
||||||
.primaryBtn{ background:#2563eb; color:#fff; border:none; padding:12px 18px; border-radius:8px; cursor:pointer }
|
|
||||||
.primaryBtn:disabled{ opacity:0.7; cursor:not-allowed }
|
|
||||||
|
|
||||||
/* small error box */
|
|
||||||
.error{
|
|
||||||
background:#fff5f5;
|
|
||||||
border:1px solid #fecaca;
|
|
||||||
color:#b91c1c;
|
|
||||||
padding:10px 12px;
|
|
||||||
border-radius:8px;
|
|
||||||
margin-bottom:12px;
|
|
||||||
font-size:13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Side column (form & actions) */
|
|
||||||
.sideColumn{
|
|
||||||
width: 320px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ensure controls row centers on small screens */
|
.mic-icon {
|
||||||
@media (max-width:800px){
|
width: 48px;
|
||||||
.contentRow{ flex-direction:column }
|
height: 48px;
|
||||||
.micPanel{ min-width:unset; width:100% }
|
background-color: #e8e8e8;
|
||||||
.sideColumn{ width:100% }
|
border-radius: 50%;
|
||||||
.controlsRow{ width:100%; justify-content:space-around }
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-icon svg { width: 24px; height: 24px; display: block; }
|
||||||
|
|
||||||
|
.mic-meter {
|
||||||
|
width: 32px;
|
||||||
|
height: 80px;
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-level {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 20%;
|
||||||
|
/* match template gradient to reduce rendering differences */
|
||||||
|
background: linear-gradient(to top, #22c55e, #86efac);
|
||||||
|
border-radius: 16px;
|
||||||
|
transition: height 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-status-text {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #22c55e;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
/* preserve template line breaks to avoid subtle rendering diffs */
|
||||||
|
white-space: pre-line;
|
||||||
|
/* enforce exact line-height to match template rendering */
|
||||||
|
line-height: 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mic-device {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999999;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e5e5e5;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls-wrapper { text-align: center; }
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666666;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover { color: #1a1a1a; background-color: #fee2e2; }
|
||||||
|
|
||||||
|
.control-btn.disabled { color: #dc2626; background-color: #fecaca; }
|
||||||
|
.control-btn.disabled:hover { color: #b91c1c; background-color: #fca5a5; }
|
||||||
|
|
||||||
|
.control-icon { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; position: relative; }
|
||||||
|
|
||||||
|
.control-hint {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
color: white;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover .control-hint { opacity: 1; }
|
||||||
|
|
||||||
|
.kbd { background-color: #374151; padding: 2px 6px; border-radius: 3px; font-family: monospace; font-size: 11px; color: #fff; }
|
||||||
|
|
||||||
|
.form-group { margin-bottom: 20px; }
|
||||||
|
|
||||||
|
.form-label { display: block; font-size: 14px; color: #1a1a1a; margin-bottom: 8px; font-weight: 500; }
|
||||||
|
|
||||||
|
.form-label .optional { color: #666666; font-weight: 400; }
|
||||||
|
|
||||||
|
.info-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border: 1.5px solid #3b82f6; border-radius: 50%; color: #3b82f6; font-size: 11px; font-weight: 600; margin-left: 4px; cursor: help; }
|
||||||
|
|
||||||
|
.form-input { width: 100%; padding: 12px 16px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; color: #1a1a1a; transition: border-color 0.2s, box-shadow 0.2s; }
|
||||||
|
|
||||||
|
.form-input:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); }
|
||||||
|
|
||||||
|
.form-input::placeholder { color: #9ca3af; }
|
||||||
|
|
||||||
|
.submit-btn { width: 100%; padding: 14px; background-color: #2563eb; color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: 600; cursor: pointer; transition: background-color 0.2s; }
|
||||||
|
|
||||||
|
.submit-btn:hover { background-color: #1d4ed8; }
|
||||||
|
.submit-btn:active { background-color: #1e40af; }
|
||||||
|
|
||||||
|
/* responsive */
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.video-container { flex-direction: column; }
|
||||||
|
.mic-status { min-width: unset; width: 100%; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import styles from './PreJoin.module.css'
|
import styles from './PreJoin.module.css'
|
||||||
import { ControlButton, MicrophoneMeter, modifierKeyLabel, isMacPlatform } from 'avanza-ui'
|
// We'll dynamically import MockToggle inside the component when appropriate (DEV mode or VITE_MOCK_STUDIO).
|
||||||
|
|
||||||
|
import { isMacPlatform } from 'avanza-ui'
|
||||||
import { FiMic, FiVideo, FiSettings } from 'react-icons/fi'
|
import { FiMic, FiVideo, FiSettings } from 'react-icons/fi'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@ -11,25 +13,19 @@ type Props = {
|
|||||||
token?: string
|
token?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PreJoin({ roomName: _roomName, onProceed, onCancel }: Props) {
|
export default function PreJoin({ roomName: _roomName, onProceed, onCancel: _onCancel }: Props) {
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
const [name, setName] = useState(() => {
|
const [name, setName] = useState(() => {
|
||||||
try { return localStorage.getItem('avanzacast_user') || '' } catch { return '' }
|
try { return localStorage.getItem('avanzacast_user') || '' } catch { return '' }
|
||||||
})
|
})
|
||||||
const [micEnabled, setMicEnabled] = useState(true)
|
const [micEnabled, setMicEnabled] = useState(true)
|
||||||
const [camEnabled, setCamEnabled] = useState(true)
|
const [camEnabled, setCamEnabled] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [isChecking, setIsChecking] = useState(false)
|
const [isChecking, setIsChecking] = useState(false)
|
||||||
// checkbox state is local only; do NOT persist skip preference so PreJoin always appears
|
|
||||||
const [skipNextTime, setSkipNextTime] = useState<boolean>(false)
|
|
||||||
// keep preview stream active for meter and preview
|
// keep preview stream active for meter and preview
|
||||||
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null)
|
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null)
|
||||||
|
|
||||||
// Use shared platform utils
|
// Use shared platform utils
|
||||||
const isMac = isMacPlatform()
|
const isMac = isMacPlatform()
|
||||||
const modLabel = modifierKeyLabel()
|
|
||||||
const micHint = `${modLabel.display} + D`
|
|
||||||
const camHint = `${modLabel.display} + E`
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// ensure any old skip flag does not affect behavior: remove legacy key
|
// ensure any old skip flag does not affect behavior: remove legacy key
|
||||||
@ -102,7 +98,6 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel }: Pr
|
|||||||
}, [micEnabled, camEnabled])
|
}, [micEnabled, camEnabled])
|
||||||
|
|
||||||
const handleProceed = async () => {
|
const handleProceed = async () => {
|
||||||
setError(null)
|
|
||||||
setIsChecking(true)
|
setIsChecking(true)
|
||||||
try {
|
try {
|
||||||
// request permissions explicitly
|
// request permissions explicitly
|
||||||
@ -112,7 +107,7 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel }: Pr
|
|||||||
// proceed to connect
|
// proceed to connect
|
||||||
onProceed()
|
onProceed()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message || 'No se pudo acceder a la cámara/micrófono')
|
console.log(e)
|
||||||
} finally {
|
} finally {
|
||||||
setIsChecking(false)
|
setIsChecking(false)
|
||||||
}
|
}
|
||||||
@ -127,95 +122,70 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel }: Pr
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.prejoinContainer}>
|
<div className={styles.container}>
|
||||||
<div className={styles.card}>
|
<div className={styles.header}>
|
||||||
<div className={styles.header}>
|
<h1>Configura tu estudio</h1>
|
||||||
<div>Configura tu estudio</div>
|
<p>Entrar al estudio no iniciará automáticamente<br/>la transmisión.</p>
|
||||||
<div className={styles.note}>Entrar al estudio no iniciará automáticamente la transmisión.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.contentRow}>
|
|
||||||
<div className={styles.previewColumn}>
|
|
||||||
<div className={styles.previewCard}>
|
|
||||||
<video ref={videoRef} className={styles.videoEl} playsInline muted />
|
|
||||||
<div className={styles.badge}>{name || 'Invitado'}</div>
|
|
||||||
<div className={styles.micPanel}>
|
|
||||||
<div className={styles.micPanelInner}>
|
|
||||||
<MicrophoneMeter level={previewStream ? 1 : 0} />
|
|
||||||
<div className={styles.micStatus}>{micEnabled ? 'El micrófono está funcionando' : 'Micrófono desactivado'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.controlsRow}>
|
|
||||||
<ControlButton
|
|
||||||
className={styles.controlButtonLocal}
|
|
||||||
icon={<FiMic />}
|
|
||||||
label={micEnabled ? 'Desactivar audio' : 'Activar audio'}
|
|
||||||
active={micEnabled}
|
|
||||||
danger={!micEnabled}
|
|
||||||
layout="column"
|
|
||||||
variant="studio"
|
|
||||||
onClick={toggleMic}
|
|
||||||
hint={micHint}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ControlButton
|
|
||||||
className={styles.controlButtonLocal}
|
|
||||||
icon={<FiVideo />}
|
|
||||||
label={camEnabled ? 'Detener cámara' : 'Iniciar cámara'}
|
|
||||||
active={camEnabled}
|
|
||||||
danger={!camEnabled}
|
|
||||||
layout="column"
|
|
||||||
variant="studio"
|
|
||||||
onClick={toggleCam}
|
|
||||||
hint={camHint}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ControlButton
|
|
||||||
className={styles.controlButtonLocal}
|
|
||||||
icon={<FiSettings />}
|
|
||||||
label={'Configuración'}
|
|
||||||
active={true}
|
|
||||||
layout="column"
|
|
||||||
variant="studio"
|
|
||||||
onClick={() => { /* abrir modal de settings si aplica */ }}
|
|
||||||
size="md"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Leyenda de atajos: muestra las combinaciones detectadas (ej: ⌘ + D) */}
|
|
||||||
<div className={styles.shortcutsLegend} aria-hidden="true">
|
|
||||||
Atajos: <span className={styles.kbd}>{micHint}</span> mic · <span className={styles.kbd}>{camHint}</span> cámara
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.sideColumn}>
|
|
||||||
{error && <div className={styles.error}>{error}</div>}
|
|
||||||
|
|
||||||
<div className={styles.roomTitle}>Nombre para mostrar</div>
|
|
||||||
<input className={styles.input} value={name} onChange={e => setName(e.target.value)} placeholder="Tu nombre" />
|
|
||||||
|
|
||||||
<div className={styles.roomTitle}>Título (opcional)</div>
|
|
||||||
<input className={styles.input} placeholder="p. ej.: Founder of Creativity Inc" />
|
|
||||||
|
|
||||||
<div className={styles.checkboxRow}>
|
|
||||||
<input id="skipNext" type="checkbox" checked={skipNextTime} onChange={e => setSkipNextTime(e.target.checked)} />
|
|
||||||
<label htmlFor="skipNext">Omitir PreJoin la próxima vez</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button className={styles.cancelBtn} onClick={() => { onCancel?.() }}>Cancelar</button>
|
|
||||||
<button className={styles.primaryBtn} onClick={handleProceed} disabled={isChecking}>{isChecking ? 'Comprobando...' : 'Entrar al estudio'}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles['video-container']}>
|
||||||
|
<div className={styles['video-preview']}>
|
||||||
|
{/* Preview video */}
|
||||||
|
<video ref={videoRef} className={styles.videoEl} playsInline muted />
|
||||||
|
<div className={styles['user-badge']}>{name || 'Invitado'}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles['mic-status']}>
|
||||||
|
<div className={styles['mic-icon']} aria-hidden="true">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
|
||||||
|
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
|
||||||
|
<line x1="12" y1="19" x2="12" y2="23" />
|
||||||
|
<line x1="8" y1="23" x2="16" y2="23" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className={styles['mic-meter']}>
|
||||||
|
<div className={styles['mic-level']} style={{height: previewStream ? '60%' : '20%'}}></div>
|
||||||
|
</div>
|
||||||
|
<div className={styles['mic-status-text']}>{micEnabled ? 'El micrófono\nestá\nfuncionando' : 'Micrófono desactivado'}</div>
|
||||||
|
<div className={styles['mic-device']}>Microphone Array (R...)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles['controls-wrapper']}>
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<button className={`control-btn ${micEnabled ? '' : 'disabled'}`} onClick={toggleMic} aria-pressed={micEnabled}>
|
||||||
|
<span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>D</span></span>
|
||||||
|
<div className={styles['control-icon']}><FiMic /></div>
|
||||||
|
<span>{micEnabled ? 'Desactivar audio' : 'Activar audio'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className={`control-btn ${camEnabled ? '' : 'disabled'}`} onClick={toggleCam} aria-pressed={camEnabled}>
|
||||||
|
<span className={styles['control-hint']}>Presiona <span className={styles.kbd}>{isMac ? '⌘' : 'CTRL'}</span> + <span className={styles.kbd}>E</span></span>
|
||||||
|
<div className={styles['control-icon']}><FiVideo /></div>
|
||||||
|
<span>{camEnabled ? 'Detener cámara' : 'Iniciar cámara'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="control-btn" onClick={() => {}}>
|
||||||
|
<div className={styles['control-icon']}><FiSettings /></div>
|
||||||
|
<span>Configuración</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleProceed(); }}>
|
||||||
|
<div className={styles['form-group']}>
|
||||||
|
<label className={styles['form-label']} htmlFor="display-name">Nombre para mostrar</label>
|
||||||
|
<input id="display-name" className={styles['form-input']} value={name} onChange={e => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles['form-group']}>
|
||||||
|
<label className={styles['form-label']} htmlFor="title">Título <span className={styles.optional}>(opcional)</span> <span className={styles['info-icon']}>?</span></label>
|
||||||
|
<input id="title" className={styles['form-input']} placeholder="p. ej.: Founder of Creativity Inc" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" className={styles['submit-btn']}>{isChecking ? 'Comprobando...' : 'Entrar al estudio'}</button>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,13 +20,19 @@ export default function useStudioLauncher() {
|
|||||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// NOTE: mock mode removed. useStudioLauncher now always uses the real backend to create/obtain sessions.
|
||||||
|
|
||||||
async function openStudio(opts: OpenStudioOptions) {
|
async function openStudio(opts: OpenStudioOptions) {
|
||||||
const { room, username, ttl } = opts;
|
let { room, username, ttl } = opts as OpenStudioOptions;
|
||||||
|
|
||||||
|
setError(null);
|
||||||
|
setLoadingId(room);
|
||||||
|
|
||||||
if (!room || !username) {
|
if (!room || !username) {
|
||||||
setError("room and username are required");
|
setError("room and username are required");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
setError(null);
|
|
||||||
setLoadingId(room);
|
setLoadingId(room);
|
||||||
|
|
||||||
// Timeouts and retry config
|
// Timeouts and retry config
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig(({ mode }) => ({
|
||||||
plugins: [react()],
|
plugins: [react() as any],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, 'src'),
|
'@': path.resolve(__dirname, 'src'),
|
||||||
@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => ({
|
|||||||
},
|
},
|
||||||
// Allowlist hosts for preview/remote access
|
// Allowlist hosts for preview/remote access
|
||||||
allowedHosts: [
|
allowedHosts: [
|
||||||
|
'avanzacast-broadcastpanel.zuqtxy.easypanel.host',
|
||||||
'avanzacast-broadcastpanel.bfzqqk.easypanel.host',
|
'avanzacast-broadcastpanel.bfzqqk.easypanel.host',
|
||||||
'localhost',
|
'localhost',
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,20 +1,15 @@
|
|||||||
import React from 'react';
|
// Simple Storybook story for PreJoin (keeps it robust across envs)
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import PreJoin from '../../../../packages/broadcast-panel/src/features/studio/PreJoin'
|
||||||
import PreJoin from '../../../../packages/broadcast-panel/src/features/studio/PreJoin';
|
|
||||||
|
|
||||||
const meta: Meta<typeof PreJoin> = {
|
export default {
|
||||||
title: 'Broadcast/PreJoin',
|
title: 'Broadcast/PreJoin',
|
||||||
component: PreJoin,
|
component: PreJoin,
|
||||||
};
|
}
|
||||||
export default meta;
|
|
||||||
|
|
||||||
type Story = StoryObj<typeof PreJoin>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
args: {
|
|
||||||
roomName: 'Sala de prueba',
|
|
||||||
onProceed: () => alert('Proceed clicked'),
|
|
||||||
onCancel: () => alert('Cancel clicked'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export const Default = () => (
|
||||||
|
<PreJoin
|
||||||
|
roomName="Sala de prueba"
|
||||||
|
onProceed={() => { alert('Entrar al estudio') }}
|
||||||
|
onCancel={() => { alert('Cancel') }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|||||||
65
scripts/capture_and_diff_playwright.mjs
Normal file
65
scripts/capture_and_diff_playwright.mjs
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { PNG } from 'pngjs';
|
||||||
|
import pixelmatch from 'pixelmatch';
|
||||||
|
|
||||||
|
async function capture(url, outPath, width = 1280, height = 720) {
|
||||||
|
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||||
|
const page = await browser.newPage({ viewport: { width, height } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.screenshot({ path: outPath, fullPage: false });
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(imgAPath, imgBPath, diffOut) {
|
||||||
|
const imgA = PNG.sync.read(fs.readFileSync(imgAPath));
|
||||||
|
const imgB = PNG.sync.read(fs.readFileSync(imgBPath));
|
||||||
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
||||||
|
throw new Error('Images must have same dimensions');
|
||||||
|
}
|
||||||
|
const { width, height } = imgA;
|
||||||
|
const diff = new PNG({ width, height });
|
||||||
|
const mismatched = pixelmatch(imgA.data, imgB.data, diff.data, width, height, { threshold: 0.1 });
|
||||||
|
fs.writeFileSync(diffOut, PNG.sync.write(diff));
|
||||||
|
return { mismatched, total: width * height, ratio: mismatched / (width * height) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
const arg = process.argv[2] || `file://${path.resolve(process.cwd(), 'docs/prejoin_template.html')}`;
|
||||||
|
const outDir = '/tmp';
|
||||||
|
const outFile = path.join(outDir, 'prejoin_playwright_1280x720.png');
|
||||||
|
console.log('Capturing', arg, '->', outFile);
|
||||||
|
await capture(arg, outFile, 1280, 720);
|
||||||
|
console.log('Saved capture to', outFile);
|
||||||
|
|
||||||
|
const baselineA = path.resolve(process.cwd(), 'baselines/prejoin_regen_1280x720.png');
|
||||||
|
const baselineB = path.resolve(process.cwd(), 'baselines/prejoin_browserless_1280x720.png');
|
||||||
|
const report = { captured: outFile, compared: false };
|
||||||
|
|
||||||
|
if (fs.existsSync(baselineA)) {
|
||||||
|
const diffOut = path.join(outDir, 'prejoin_diff_1280x720.png');
|
||||||
|
const metrics = compare(outFile, baselineA, diffOut);
|
||||||
|
report.compared = true; report.baseline = baselineA; report.diff = diffOut; report.metrics = metrics;
|
||||||
|
console.log('Compared with', baselineA, 'metrics=', metrics);
|
||||||
|
} else if (fs.existsSync(baselineB)) {
|
||||||
|
const diffOut = path.join(outDir, 'prejoin_diff_1280x720.png');
|
||||||
|
const metrics = compare(outFile, baselineB, diffOut);
|
||||||
|
report.compared = true; report.baseline = baselineB; report.diff = diffOut; report.metrics = metrics;
|
||||||
|
console.log('Compared with', baselineB, 'metrics=', metrics);
|
||||||
|
} else {
|
||||||
|
console.log('No baseline found (checked baselines/prejoin_regen_1280x720.png and baselines/prejoin_browserless_1280x720.png)');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(outDir, 'prejoin_playwright_report.json'), JSON.stringify(report, null, 2));
|
||||||
|
console.log('Report written to /tmp/prejoin_playwright_report.json');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error:', e);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
|
|
||||||
64
scripts/capture_and_diff_prejoin.cjs
Normal file
64
scripts/capture_and_diff_prejoin.cjs
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const PNG = require('pngjs').PNG;
|
||||||
|
const pixelmatch = require('pixelmatch');
|
||||||
|
|
||||||
|
async function capture(fileUrl, outPath, width=1280, height=720) {
|
||||||
|
const browser = await puppeteer.launch({args: ['--no-sandbox','--disable-setuid-sandbox']});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width, height });
|
||||||
|
await page.goto(fileUrl, { waitUntil: 'networkidle0' });
|
||||||
|
// wait a bit for scripts to run
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.screenshot({ path: outPath, fullPage: false });
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(imgAPath, imgBPath, diffOut) {
|
||||||
|
const imgA = PNG.sync.read(fs.readFileSync(imgAPath));
|
||||||
|
const imgB = PNG.sync.read(fs.readFileSync(imgBPath));
|
||||||
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
||||||
|
throw new Error('Images must have same dimensions');
|
||||||
|
}
|
||||||
|
const { width, height } = imgA;
|
||||||
|
const diff = new PNG({ width, height });
|
||||||
|
const mismatched = pixelmatch(imgA.data, imgB.data, diff.data, width, height, { threshold: 0.1 });
|
||||||
|
fs.writeFileSync(diffOut, PNG.sync.write(diff));
|
||||||
|
return { mismatched, total: width*height, ratio: mismatched / (width*height) };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const outDir = '/tmp';
|
||||||
|
const fileArg = process.argv[2] || `file://${path.resolve(process.cwd(),'docs/prejoin_template.html')}`;
|
||||||
|
const outFile = path.join(outDir, 'prejoin_capture_1280x720.png');
|
||||||
|
console.log('Capturing', fileArg, '->', outFile);
|
||||||
|
await capture(fileArg, outFile, 1280, 720);
|
||||||
|
console.log('Saved capture to', outFile);
|
||||||
|
// If baseline exists in repo root under baselines/, compare
|
||||||
|
const baselineA = path.resolve(process.cwd(), 'baselines/prejoin_regen_1280x720.png');
|
||||||
|
const baselineB = path.resolve(process.cwd(), 'baselines/prejoin_browserless_1280x720.png');
|
||||||
|
let result = { captured: outFile, compared: false };
|
||||||
|
if (fs.existsSync(baselineA)) {
|
||||||
|
const diffOut = path.join(outDir, 'prejoin_diff_1280x720.png');
|
||||||
|
const metrics = compare(outFile, baselineA, diffOut);
|
||||||
|
result.compared = true; result.baseline = baselineA; result.diff = diffOut; result.metrics = metrics;
|
||||||
|
console.log('Compared with', baselineA, 'metrics=', metrics);
|
||||||
|
} else if (fs.existsSync(baselineB)) {
|
||||||
|
const diffOut = path.join(outDir, 'prejoin_diff_1280x720.png');
|
||||||
|
const metrics = compare(outFile, baselineB, diffOut);
|
||||||
|
result.compared = true; result.baseline = baselineB; result.diff = diffOut; result.metrics = metrics;
|
||||||
|
console.log('Compared with', baselineB, 'metrics=', metrics);
|
||||||
|
} else {
|
||||||
|
console.log('No baseline found (checked baselines/prejoin_regen_1280x720.png and baselines/prejoin_browserless_1280x720.png)');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join('/tmp','prejoin_capture_report.json'), JSON.stringify(result, null, 2));
|
||||||
|
console.log('Report written to /tmp/prejoin_capture_report.json');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed:', err);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
64
scripts/capture_and_diff_prejoin.js
Normal file
64
scripts/capture_and_diff_prejoin.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const PNG = require('pngjs').PNG;
|
||||||
|
const pixelmatch = require('pixelmatch');
|
||||||
|
|
||||||
|
async function capture(fileUrl, outPath, width=1280, height=720) {
|
||||||
|
const browser = await puppeteer.launch({args: ['--no-sandbox','--disable-setuid-sandbox']});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width, height });
|
||||||
|
await page.goto(fileUrl, { waitUntil: 'networkidle0' });
|
||||||
|
// wait a bit for scripts to run
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.screenshot({ path: outPath, fullPage: false });
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compare(imgAPath, imgBPath, diffOut) {
|
||||||
|
const imgA = PNG.sync.read(fs.readFileSync(imgAPath));
|
||||||
|
const imgB = PNG.sync.read(fs.readFileSync(imgBPath));
|
||||||
|
if (imgA.width !== imgB.width || imgA.height !== imgB.height) {
|
||||||
|
throw new Error('Images must have same dimensions');
|
||||||
|
}
|
||||||
|
const { width, height } = imgA;
|
||||||
|
const diff = new PNG({ width, height });
|
||||||
|
const mismatched = pixelmatch(imgA.data, imgB.data, diff.data, width, height, { threshold: 0.1 });
|
||||||
|
fs.writeFileSync(diffOut, PNG.sync.write(diff));
|
||||||
|
return { mismatched, total: width*height, ratio: mismatched / (width*height) };
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const outDir = '/tmp';
|
||||||
|
const fileArg = process.argv[2] || `file://${path.resolve(process.cwd(),'docs/prejoin_template.html')}`;
|
||||||
|
const outFile = path.join(outDir, 'prejoin_capture_1280x720.png');
|
||||||
|
console.log('Capturing', fileArg, '->', outFile);
|
||||||
|
await capture(fileArg, outFile, 1280, 720);
|
||||||
|
console.log('Saved capture to', outFile);
|
||||||
|
// If baseline exists in repo root under baselines/, compare
|
||||||
|
const baselineA = path.resolve(process.cwd(), 'baselines/prejoin_regen_1280x720.png');
|
||||||
|
const baselineB = path.resolve(process.cwd(), 'baselines/prejoin_browserless_1280x720.png');
|
||||||
|
let result = { captured: outFile, compared: false };
|
||||||
|
if (fs.existsSync(baselineA)) {
|
||||||
|
const diffOut = path.join(outDir, 'prejoin_diff_1280x720.png');
|
||||||
|
const metrics = compare(outFile, baselineA, diffOut);
|
||||||
|
result.compared = true; result.baseline = baselineA; result.diff = diffOut; result.metrics = metrics;
|
||||||
|
console.log('Compared with', baselineA, 'metrics=', metrics);
|
||||||
|
} else if (fs.existsSync(baselineB)) {
|
||||||
|
const diffOut = path.join(outDir, 'prejoin_diff_1280x720.png');
|
||||||
|
const metrics = compare(outFile, baselineB, diffOut);
|
||||||
|
result.compared = true; result.baseline = baselineB; result.diff = diffOut; result.metrics = metrics;
|
||||||
|
console.log('Compared with', baselineB, 'metrics=', metrics);
|
||||||
|
} else {
|
||||||
|
console.log('No baseline found (checked baselines/prejoin_regen_1280x720.png and baselines/prejoin_browserless_1280x720.png)');
|
||||||
|
}
|
||||||
|
fs.writeFileSync(path.join('/tmp','prejoin_capture_report.json'), JSON.stringify(result, null, 2));
|
||||||
|
console.log('Report written to /tmp/prejoin_capture_report.json');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed:', err);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
57
scripts/capture_regions_playwright.mjs
Normal file
57
scripts/capture_regions_playwright.mjs
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
async function captureRegions(url) {
|
||||||
|
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||||
|
await page.goto(url, { waitUntil: 'networkidle' });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
|
||||||
|
const regions = [
|
||||||
|
{ name: 'video_preview', selector: '.video-preview' },
|
||||||
|
{ name: 'mic_status', selector: '.mic-status' },
|
||||||
|
{ name: 'controls', selector: '.controls' },
|
||||||
|
{ name: 'form', selector: 'form' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const outDir = '/tmp';
|
||||||
|
const report = { url, captures: [] };
|
||||||
|
|
||||||
|
for (const r of regions) {
|
||||||
|
try {
|
||||||
|
const el = await page.$(r.selector);
|
||||||
|
if (!el) {
|
||||||
|
console.warn('Selector not found', r.selector);
|
||||||
|
report.captures.push({ name: r.name, selector: r.selector, found: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const box = await el.boundingBox();
|
||||||
|
const outPath = path.join(outDir, `prejoin_region_${r.name}.png`);
|
||||||
|
// use element screenshot to crop
|
||||||
|
await el.screenshot({ path: outPath });
|
||||||
|
report.captures.push({ name: r.name, selector: r.selector, found: true, box, path: outPath });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed capture for', r.selector, e);
|
||||||
|
report.captures.push({ name: r.name, selector: r.selector, found: false, error: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
const reportPath = path.join(outDir, 'prejoin_regions_report.json');
|
||||||
|
fs.writeFileSync(reportPath, JSON.stringify(report, null, 2));
|
||||||
|
console.log('Wrote report', reportPath);
|
||||||
|
return reportPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const url = process.argv[2] || 'http://127.0.0.1:8000/docs/prejoin_template.html';
|
||||||
|
const rp = await captureRegions(url);
|
||||||
|
console.log('Done, report at', rp);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error running captureRegions', e);
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
44
scripts/compare_regions.cjs
Normal file
44
scripts/compare_regions.cjs
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { PNG } = require('pngjs');
|
||||||
|
let pixelmatch = require('pixelmatch');
|
||||||
|
if (pixelmatch && typeof pixelmatch !== 'function' && pixelmatch.default && typeof pixelmatch.default === 'function') pixelmatch = pixelmatch.default;
|
||||||
|
|
||||||
|
const names = ['video_preview','mic_status','controls','form'];
|
||||||
|
const out = [];
|
||||||
|
for (const n of names) {
|
||||||
|
const base = `/tmp/baseline_prejoin_region_${n}.png`;
|
||||||
|
const cur = `/tmp/prejoin_region_${n}.png`;
|
||||||
|
if (!fs.existsSync(base) && !fs.existsSync(cur)) {
|
||||||
|
out.push({ region: n, foundBaseline: false, foundCurrent: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(base)) {
|
||||||
|
out.push({ region: n, foundBaseline: false, foundCurrent: true, current: cur });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(cur)) {
|
||||||
|
out.push({ region: n, foundBaseline: true, foundCurrent: false, baseline: base });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const A = PNG.sync.read(fs.readFileSync(base));
|
||||||
|
const B = PNG.sync.read(fs.readFileSync(cur));
|
||||||
|
if (A.width !== B.width || A.height !== B.height) {
|
||||||
|
out.push({ region: n, error: 'dim_mismatch', baseline: base, current: cur, baseSize: [A.width,A.height], curSize: [B.width,B.height] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const diff = new PNG({ width: A.width, height: A.height });
|
||||||
|
const mismatched = pixelmatch(A.data, B.data, diff.data, A.width, A.height, { threshold: 0.1 });
|
||||||
|
const diffPath = `/tmp/prejoin_region_diff_${n}.png`;
|
||||||
|
fs.writeFileSync(diffPath, PNG.sync.write(diff));
|
||||||
|
out.push({ region: n, baseline: base, current: cur, diff: diffPath, mismatched, total: A.width*A.height, ratio: mismatched/(A.width*A.height) });
|
||||||
|
} catch (e) {
|
||||||
|
out.push({ region: n, error: String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const report = { generatedAt: new Date().toISOString(), report: out };
|
||||||
|
fs.writeFileSync('/tmp/prejoin_regions_compare_report.json', JSON.stringify(report, null, 2));
|
||||||
|
console.log('Wrote /tmp/prejoin_regions_compare_report.json');
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
53
scripts/visual_test_prejoin.cjs
Normal file
53
scripts/visual_test_prejoin.cjs
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
let url = 'http://127.0.0.1:8000/docs/prejoin_template.html';
|
||||||
|
for (let i=0;i<args.length;i++){
|
||||||
|
if ((args[i]==='--url' || args[i]==='-u') && args[i+1]) { url = args[i+1]; i++; }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[visual-test] using URL:', url);
|
||||||
|
|
||||||
|
// 1) run capture
|
||||||
|
console.log('[visual-test] capturing regions...');
|
||||||
|
let r = spawnSync('node', ['scripts/capture_regions_playwright.mjs', url], { stdio: 'inherit' });
|
||||||
|
if (r.error) { console.error('[visual-test] capture failed', r.error); process.exit(2); }
|
||||||
|
if (r.status !== 0) { console.error('[visual-test] capture script exited with', r.status); /* continue to comparison if images may exist */ }
|
||||||
|
|
||||||
|
// 2) run compare
|
||||||
|
console.log('[visual-test] comparing regions...');
|
||||||
|
let c = spawnSync('node', ['scripts/compare_regions.cjs'], { stdio: 'inherit' });
|
||||||
|
if (c.error) { console.error('[visual-test] compare failed', c.error); process.exit(2); }
|
||||||
|
if (c.status !== 0) { console.error('[visual-test] compare script exited with', c.status); }
|
||||||
|
|
||||||
|
// 3) read report
|
||||||
|
const reportPath = '/tmp/prejoin_regions_compare_report.json';
|
||||||
|
if (!fs.existsSync(reportPath)) { console.error('[visual-test] report not found at', reportPath); process.exit(2); }
|
||||||
|
const report = JSON.parse(fs.readFileSync(reportPath,'utf8'));
|
||||||
|
console.log('[visual-test] report generated at', report.generatedAt);
|
||||||
|
|
||||||
|
// 4) evaluate metrics against threshold
|
||||||
|
const THRESHOLD = 0.001; // fail if any region has ratio > 0.1%
|
||||||
|
let failed = false;
|
||||||
|
for (const item of report.report) {
|
||||||
|
if (item.ratio && item.ratio > THRESHOLD) {
|
||||||
|
console.error(`[visual-test] REGION ${item.region} exceeded threshold: ratio=${item.ratio} mismatched=${item.mismatched} total=${item.total}`);
|
||||||
|
failed = true;
|
||||||
|
} else {
|
||||||
|
console.log(`[visual-test] REGION ${item.region}: ratio=${item.ratio || 0} mismatched=${item.mismatched || 0}/${item.total || 0}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const outJson = '/tmp/prejoin_visual_test_summary.json';
|
||||||
|
fs.writeFileSync(outJson, JSON.stringify({ url, threshold: THRESHOLD, generatedAt: new Date().toISOString(), report }, null, 2));
|
||||||
|
console.log('[visual-test] summary written to', outJson);
|
||||||
|
if (failed) {
|
||||||
|
console.error('[visual-test] visual test FAILED');
|
||||||
|
process.exit(3);
|
||||||
|
}
|
||||||
|
console.log('[visual-test] visual test PASSED');
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user