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] || '';
|
||||
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) {
|
||||
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' } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
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) {
|
||||
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' } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.mic-icon::before {
|
||||
content: '🎤';
|
||||
font-size: 24px;
|
||||
.mic-icon svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.mic-meter {
|
||||
@ -337,7 +340,15 @@
|
||||
</div>
|
||||
|
||||
<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-level"></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": {
|
||||
"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",
|
||||
@ -37,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": {
|
||||
@ -45,7 +49,6 @@
|
||||
"npm": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"puppeteer": "^19.11.1",
|
||||
"puppeteer-core": "^24.30.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 */
|
||||
:root{
|
||||
--card-bg: #ffffff;
|
||||
--muted: #666666;
|
||||
--accent: #6366f1;
|
||||
--badge-bg: rgba(99,102,241,0.9);
|
||||
--danger: #dc2626;
|
||||
}
|
||||
/* filepath: packages/broadcast-panel/src/features/studio/PreJoin.module.css */
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
.prejoinContainer{
|
||||
.container {
|
||||
max-width: 628px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
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{
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.header{
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.header > div:first-child{
|
||||
.header h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.note{
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
color: #666666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.contentRow{
|
||||
.video-container {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.previewColumn{ flex: 1 }
|
||||
|
||||
.previewCard{
|
||||
.video-preview {
|
||||
flex: 1;
|
||||
background-color: #0a0a1a;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #0a0a1a;
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.videoEl{
|
||||
width:100%;
|
||||
height:100%;
|
||||
object-fit:cover;
|
||||
background:#0b0b0b;
|
||||
}
|
||||
|
||||
.badge{
|
||||
.user-badge {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
background: var(--badge-bg);
|
||||
color: #fff;
|
||||
background-color: rgba(99, 102, 241, 0.9);
|
||||
color: white;
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.micPanel{
|
||||
.mic-status {
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 12px;
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
/* ensure controls row centers on small screens */
|
||||
@media (max-width:800px){
|
||||
.contentRow{ flex-direction:column }
|
||||
.micPanel{ min-width:unset; width:100% }
|
||||
.sideColumn{ width:100% }
|
||||
.controlsRow{ width:100%; justify-content:space-around }
|
||||
.mic-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: #e8e8e8;
|
||||
border-radius: 50%;
|
||||
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 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'
|
||||
|
||||
type Props = {
|
||||
@ -11,25 +13,19 @@ type Props = {
|
||||
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 [name, setName] = useState(() => {
|
||||
try { return localStorage.getItem('avanzacast_user') || '' } catch { return '' }
|
||||
})
|
||||
const [micEnabled, setMicEnabled] = useState(true)
|
||||
const [camEnabled, setCamEnabled] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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
|
||||
const [previewStream, setPreviewStream] = useState<MediaStream | null>(null)
|
||||
|
||||
// Use shared platform utils
|
||||
const isMac = isMacPlatform()
|
||||
const modLabel = modifierKeyLabel()
|
||||
const micHint = `${modLabel.display} + D`
|
||||
const camHint = `${modLabel.display} + E`
|
||||
|
||||
useEffect(() => {
|
||||
// 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])
|
||||
|
||||
const handleProceed = async () => {
|
||||
setError(null)
|
||||
setIsChecking(true)
|
||||
try {
|
||||
// request permissions explicitly
|
||||
@ -112,7 +107,7 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel }: Pr
|
||||
// proceed to connect
|
||||
onProceed()
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'No se pudo acceder a la cámara/micrófono')
|
||||
console.log(e)
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
}
|
||||
@ -127,95 +122,70 @@ export default function PreJoin({ roomName: _roomName, onProceed, onCancel }: Pr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.prejoinContainer}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div>Configura tu estudio</div>
|
||||
<div className={styles.note}>Entrar al estudio no iniciará automáticamente la transmisión.</div>
|
||||
<h1>Configura tu estudio</h1>
|
||||
<p>Entrar al estudio no iniciará automáticamente<br/>la transmisión.</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.contentRow}>
|
||||
<div className={styles.previewColumn}>
|
||||
<div className={styles.previewCard}>
|
||||
<div className={styles['video-container']}>
|
||||
<div className={styles['video-preview']}>
|
||||
{/* Preview video */}
|
||||
<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 className={styles['user-badge']}>{name || 'Invitado'}</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 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>
|
||||
|
||||
{/* 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 className={styles['mic-meter']}>
|
||||
<div className={styles['mic-level']} style={{height: previewStream ? '60%' : '20%'}}></div>
|
||||
</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 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -20,13 +20,19 @@ export default function useStudioLauncher() {
|
||||
const [loadingId, setLoadingId] = 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) {
|
||||
const { room, username, ttl } = opts;
|
||||
let { room, username, ttl } = opts as OpenStudioOptions;
|
||||
|
||||
setError(null);
|
||||
setLoadingId(room);
|
||||
|
||||
if (!room || !username) {
|
||||
setError("room and username are required");
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
|
||||
setLoadingId(room);
|
||||
|
||||
// Timeouts and retry config
|
||||
|
||||
@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
plugins: [react()],
|
||||
plugins: [react() as any],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
@ -43,6 +43,7 @@ export default defineConfig(({ mode }) => ({
|
||||
},
|
||||
// Allowlist hosts for preview/remote access
|
||||
allowedHosts: [
|
||||
'avanzacast-broadcastpanel.zuqtxy.easypanel.host',
|
||||
'avanzacast-broadcastpanel.bfzqqk.easypanel.host',
|
||||
'localhost',
|
||||
],
|
||||
|
||||
@ -1,20 +1,15 @@
|
||||
import React from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import PreJoin from '../../../../packages/broadcast-panel/src/features/studio/PreJoin';
|
||||
// Simple Storybook story for PreJoin (keeps it robust across envs)
|
||||
import PreJoin from '../../../../packages/broadcast-panel/src/features/studio/PreJoin'
|
||||
|
||||
const meta: Meta<typeof PreJoin> = {
|
||||
export default {
|
||||
title: 'Broadcast/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