diff --git a/app/api/session/proxy/[id]/route.ts b/app/api/session/proxy/[id]/route.ts
index 09c9478..f4003aa 100644
--- a/app/api/session/proxy/[id]/route.ts
+++ b/app/api/session/proxy/[id]/route.ts
@@ -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' } });
}
}
-
diff --git a/app/api/session/proxy/route.ts b/app/api/session/proxy/route.ts
index 0652929..e326e48 100644
--- a/app/api/session/proxy/route.ts
+++ b/app/api/session/proxy/route.ts
@@ -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' } });
}
}
-
diff --git a/app/rooms/[roomName]/StudioReceiver.tsx b/app/rooms/[roomName]/StudioReceiver.tsx
new file mode 100644
index 0000000..39c4818
--- /dev/null
+++ b/app/rooms/[roomName]/StudioReceiver.tsx
@@ -0,0 +1,3 @@
+// file removed - StudioReceiver replaced by real studio flow
+// This file was intentionally removed when reverting mock changes.
+
diff --git a/app/rooms/[roomName]/page.tsx b/app/rooms/[roomName]/page.tsx
index c857f4d..89c3e13 100644
--- a/app/rooms/[roomName]/page.tsx
+++ b/app/rooms/[roomName]/page.tsx
@@ -15,4 +15,3 @@ export default function RoomPage({ params }: { params: { roomName: string } }) {
);
}
-
diff --git a/docs/mock-studio.html b/docs/mock-studio.html
new file mode 100644
index 0000000..7c03505
--- /dev/null
+++ b/docs/mock-studio.html
@@ -0,0 +1,130 @@
+
+
+
+
-
+
diff --git a/package-lock.json b/package-lock.json
index 146d9a8..ee5c8df 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,13 +20,15 @@
"shared/*"
],
"dependencies": {
- "puppeteer": "^19.11.1",
"puppeteer-core": "^24.30.0",
"react-icons": "^5.5.0"
},
"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": {
@@ -4307,53 +4309,6 @@
"node": ">=18"
}
},
- "node_modules/@playwright/test/node_modules/fsevents": {
- "version": "2.3.2",
- "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
- "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
- "dev": true,
- "hasInstallScript": true,
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
- }
- },
- "node_modules/@playwright/test/node_modules/playwright": {
- "version": "1.56.1",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
- "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "playwright-core": "1.56.1"
- },
- "bin": {
- "playwright": "cli.js"
- },
- "engines": {
- "node": ">=18"
- },
- "optionalDependencies": {
- "fsevents": "2.3.2"
- }
- },
- "node_modules/@playwright/test/node_modules/playwright-core": {
- "version": "1.56.1",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
- "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
- "dev": true,
- "license": "Apache-2.0",
- "bin": {
- "playwright-core": "cli.js"
- },
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz",
@@ -8566,17 +8521,6 @@
"file-uri-to-path": "1.0.0"
}
},
- "node_modules/bl": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
- "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
- "license": "MIT",
- "dependencies": {
- "buffer": "^5.5.0",
- "inherits": "^2.0.4",
- "readable-stream": "^3.4.0"
- }
- },
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
@@ -13385,12 +13329,6 @@
"node": ">= 0.6"
}
},
- "node_modules/fs-constants": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
- "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
- "license": "MIT"
- },
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@@ -19432,6 +19370,19 @@
"node": ">= 6"
}
},
+ "node_modules/pixelmatch": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz",
+ "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "pngjs": "^7.0.0"
+ },
+ "bin": {
+ "pixelmatch": "bin/pixelmatch"
+ }
+ },
"node_modules/pkg-dir": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
@@ -19537,13 +19488,13 @@
}
},
"node_modules/playwright": {
- "version": "1.51.0",
- "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.51.0.tgz",
- "integrity": "sha512-442pTfGM0xxfCYxuBa/Pu6B2OqxqqaYq39JS8QDMGThUvIOCd6s0ANDog3uwA0cHavVlnTQzGCN7Id2YekDSXA==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
+ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "playwright-core": "1.51.0"
+ "playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
@@ -19556,9 +19507,9 @@
}
},
"node_modules/playwright-core": {
- "version": "1.51.0",
- "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.51.0.tgz",
- "integrity": "sha512-x47yPE3Zwhlil7wlNU/iktF7t2r/URR3VLbH6EknJd/04Qc/PSJ0EY3CMXipmglLG+zyRxW6HNo2EGbKLHPWMg==",
+ "version": "1.56.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
+ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -19582,6 +19533,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/pngjs": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
+ "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.19.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -21313,25 +21274,30 @@
}
},
"node_modules/puppeteer": {
- "version": "19.11.1",
- "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-19.11.1.tgz",
- "integrity": "sha512-39olGaX2djYUdhaQQHDZ0T0GwEp+5f9UB9HmEP0qHfdQHIq0xGQZuAZ5TLnJIc/88SrPLpEflPC+xUqOTv3c5g==",
- "deprecated": "< 24.15.0 is no longer supported",
+ "version": "24.31.0",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.31.0.tgz",
+ "integrity": "sha512-q8y5yLxLD8xdZdzNWqdOL43NbfvUOp60SYhaLZQwHC9CdKldxQKXOyJAciOr7oUJfyAH/KgB2wKvqT2sFKoVXA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
- "@puppeteer/browsers": "0.5.0",
- "cosmiconfig": "8.1.3",
- "https-proxy-agent": "5.0.1",
- "progress": "2.0.3",
- "proxy-from-env": "1.1.0",
- "puppeteer-core": "19.11.1"
+ "@puppeteer/browsers": "2.10.13",
+ "chromium-bidi": "11.0.0",
+ "cosmiconfig": "^9.0.0",
+ "devtools-protocol": "0.0.1521046",
+ "puppeteer-core": "24.31.0",
+ "typed-query-selector": "^2.12.0"
+ },
+ "bin": {
+ "puppeteer": "lib/cjs/puppeteer/node/cli.js"
+ },
+ "engines": {
+ "node": ">=18"
}
},
"node_modules/puppeteer-core": {
- "version": "24.30.0",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz",
- "integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==",
+ "version": "24.31.0",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.31.0.tgz",
+ "integrity": "sha512-pnAohhSZipWQoFpXuGV7xCZfaGhqcBR9C4pVrU0QSrcMi7tQMH9J9lDBqBvyMAHQqe8HCARuREqFuVKRQOgTvg==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "2.10.13",
@@ -21339,7 +21305,7 @@
"debug": "^4.4.3",
"devtools-protocol": "0.0.1521046",
"typed-query-selector": "^2.12.0",
- "webdriver-bidi-protocol": "0.3.8",
+ "webdriver-bidi-protocol": "0.3.9",
"ws": "^8.18.3"
},
"engines": {
@@ -21367,125 +21333,38 @@
}
}
},
- "node_modules/puppeteer/node_modules/@puppeteer/browsers": {
- "version": "0.5.0",
- "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-0.5.0.tgz",
- "integrity": "sha512-Uw6oB7VvmPRLE4iKsjuOh8zgDabhNX67dzo8U/BB0f9527qx+4eeUs+korU98OhG5C4ubg7ufBgVi63XYwS6TQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "debug": "4.3.4",
- "extract-zip": "2.0.1",
- "https-proxy-agent": "5.0.1",
- "progress": "2.0.3",
- "proxy-from-env": "1.1.0",
- "tar-fs": "2.1.1",
- "unbzip2-stream": "1.4.3",
- "yargs": "17.7.1"
- },
- "bin": {
- "browsers": "lib/cjs/main-cli.js"
- },
- "engines": {
- "node": ">=14.1.0"
- },
- "peerDependencies": {
- "typescript": ">= 4.7.4"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/puppeteer/node_modules/ansi-regex": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
- "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/puppeteer/node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
- "node_modules/puppeteer/node_modules/chownr": {
- "version": "1.1.4",
- "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
- "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
- "license": "ISC"
- },
- "node_modules/puppeteer/node_modules/chromium-bidi": {
- "version": "0.4.7",
- "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.4.7.tgz",
- "integrity": "sha512-6+mJuFXwTMU6I3vYLs6IL8A1DyQTPjCfIL971X0aMPVGRbGnNfl6i6Cl0NMbxi2bRYLGESt9T2ZIMRM5PAEcIQ==",
- "license": "Apache-2.0",
- "dependencies": {
- "mitt": "3.0.0"
- },
- "peerDependencies": {
- "devtools-protocol": "*"
- }
- },
"node_modules/puppeteer/node_modules/cosmiconfig": {
- "version": "8.1.3",
- "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.1.3.tgz",
- "integrity": "sha512-/UkO2JKI18b5jVMJUp0lvKFMpa/Gye+ZgZjKD+DGEN9y7NRcf/nK1A0sp67ONmKtnDCNMS44E6jrk0Yc3bDuUw==",
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
+ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"license": "MIT",
"dependencies": {
- "import-fresh": "^3.2.1",
+ "env-paths": "^2.2.1",
+ "import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
- "parse-json": "^5.0.0",
- "path-type": "^4.0.0"
+ "parse-json": "^5.2.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
- }
- },
- "node_modules/puppeteer/node_modules/cross-fetch": {
- "version": "3.1.5",
- "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.5.tgz",
- "integrity": "sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw==",
- "license": "MIT",
- "dependencies": {
- "node-fetch": "2.6.7"
- }
- },
- "node_modules/puppeteer/node_modules/debug": {
- "version": "4.3.4",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
- "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
- "license": "MIT",
- "dependencies": {
- "ms": "2.1.2"
},
- "engines": {
- "node": ">=6.0"
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
- "supports-color": {
+ "typescript": {
"optional": true
}
}
},
- "node_modules/puppeteer/node_modules/devtools-protocol": {
- "version": "0.0.1107588",
- "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1107588.tgz",
- "integrity": "sha512-yIR+pG9x65Xko7bErCUSQaDLrO/P1p3JUzEk7JCU4DowPcGHkTGUGQapcfcLc4qj0UaALwZ+cr0riFgiqpixcg==",
- "license": "BSD-3-Clause"
- },
- "node_modules/puppeteer/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "license": "MIT"
- },
"node_modules/puppeteer/node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
@@ -21498,155 +21377,6 @@
"js-yaml": "bin/js-yaml.js"
}
},
- "node_modules/puppeteer/node_modules/ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
- "license": "MIT"
- },
- "node_modules/puppeteer/node_modules/node-fetch": {
- "version": "2.6.7",
- "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz",
- "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==",
- "license": "MIT",
- "dependencies": {
- "whatwg-url": "^5.0.0"
- },
- "engines": {
- "node": "4.x || >=6.0.0"
- },
- "peerDependencies": {
- "encoding": "^0.1.0"
- },
- "peerDependenciesMeta": {
- "encoding": {
- "optional": true
- }
- }
- },
- "node_modules/puppeteer/node_modules/puppeteer-core": {
- "version": "19.11.1",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-19.11.1.tgz",
- "integrity": "sha512-qcuC2Uf0Fwdj9wNtaTZ2OvYRraXpAK+puwwVW8ofOhOgLPZyz1c68tsorfIZyCUOpyBisjr+xByu7BMbEYMepA==",
- "license": "Apache-2.0",
- "dependencies": {
- "@puppeteer/browsers": "0.5.0",
- "chromium-bidi": "0.4.7",
- "cross-fetch": "3.1.5",
- "debug": "4.3.4",
- "devtools-protocol": "0.0.1107588",
- "extract-zip": "2.0.1",
- "https-proxy-agent": "5.0.1",
- "proxy-from-env": "1.1.0",
- "tar-fs": "2.1.1",
- "unbzip2-stream": "1.4.3",
- "ws": "8.13.0"
- },
- "engines": {
- "node": ">=14.14.0"
- },
- "peerDependencies": {
- "typescript": ">= 4.7.4"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/puppeteer/node_modules/string-width": {
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "license": "MIT",
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/puppeteer/node_modules/strip-ansi": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/puppeteer/node_modules/tar-fs": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
- "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==",
- "license": "MIT",
- "dependencies": {
- "chownr": "^1.1.1",
- "mkdirp-classic": "^0.5.2",
- "pump": "^3.0.0",
- "tar-stream": "^2.1.4"
- }
- },
- "node_modules/puppeteer/node_modules/tar-stream": {
- "version": "2.2.0",
- "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
- "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
- "license": "MIT",
- "dependencies": {
- "bl": "^4.0.3",
- "end-of-stream": "^1.4.1",
- "fs-constants": "^1.0.0",
- "inherits": "^2.0.3",
- "readable-stream": "^3.1.1"
- },
- "engines": {
- "node": ">=6"
- }
- },
- "node_modules/puppeteer/node_modules/ws": {
- "version": "8.13.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
- "integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/puppeteer/node_modules/yargs": {
- "version": "17.7.1",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.1.tgz",
- "integrity": "sha512-cwiTb08Xuv5fqF4AovYacTFNxk62th7LKJ6BL9IGUpTJrWoU7/7WdQGTP2SjKf1dUNBGzDd28p/Yfs/GI6JrLw==",
- "license": "MIT",
- "dependencies": {
- "cliui": "^8.0.1",
- "escalade": "^3.1.1",
- "get-caller-file": "^2.0.5",
- "require-directory": "^2.1.1",
- "string-width": "^4.2.3",
- "y18n": "^5.0.5",
- "yargs-parser": "^21.1.1"
- },
- "engines": {
- "node": ">=12"
- }
- },
"node_modules/q": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz",
@@ -29528,9 +29258,9 @@
"license": "Apache-2.0"
},
"node_modules/webdriver-bidi-protocol": {
- "version": "0.3.8",
- "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.8.tgz",
- "integrity": "sha512-21Yi2GhGntMc671vNBCjiAeEVknXjVRoyu+k+9xOMShu+ZQfpGQwnBqbNz/Sv4GXZ6JmutlPAi2nIJcrymAWuQ==",
+ "version": "0.3.9",
+ "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.9.tgz",
+ "integrity": "sha512-uIYvlRQ0PwtZR1EzHlTMol1G0lAlmOe6wPykF9a77AK3bkpvZHzIVxRE2ThOx5vjy2zISe0zhwf5rzuUfbo1PQ==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
@@ -31686,38 +31416,6 @@
"node": ">=8"
}
},
- "packages/broadcast-panel/node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "license": "Python-2.0"
- },
- "packages/broadcast-panel/node_modules/cosmiconfig": {
- "version": "9.0.0",
- "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
- "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
- "license": "MIT",
- "dependencies": {
- "env-paths": "^2.2.1",
- "import-fresh": "^3.3.0",
- "js-yaml": "^4.1.0",
- "parse-json": "^5.2.0"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/d-fischer"
- },
- "peerDependencies": {
- "typescript": ">=4.9.5"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
"packages/broadcast-panel/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -31810,18 +31508,6 @@
"node": ">= 14"
}
},
- "packages/broadcast-panel/node_modules/js-yaml": {
- "version": "4.1.1",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
- "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
- }
- },
"packages/broadcast-panel/node_modules/lru-cache": {
"version": "7.18.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
@@ -31869,27 +31555,6 @@
"node": ">= 14"
}
},
- "packages/broadcast-panel/node_modules/puppeteer": {
- "version": "24.30.0",
- "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.30.0.tgz",
- "integrity": "sha512-A5OtCi9WpiXBQgJ2vQiZHSyrAzQmO/WDsvghqlN4kgw21PhxA5knHUaUQq/N3EMt8CcvSS0RM+kmYLJmedR3TQ==",
- "hasInstallScript": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@puppeteer/browsers": "2.10.13",
- "chromium-bidi": "11.0.0",
- "cosmiconfig": "^9.0.0",
- "devtools-protocol": "0.0.1521046",
- "puppeteer-core": "24.30.0",
- "typed-query-selector": "^2.12.0"
- },
- "bin": {
- "puppeteer": "lib/cjs/puppeteer/node/cli.js"
- },
- "engines": {
- "node": ">=18"
- }
- },
"packages/broadcast-panel/node_modules/puppeteer-core": {
"version": "20.9.0",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-20.9.0.tgz",
@@ -31979,45 +31644,6 @@
"integrity": "sha512-hyWmRrexdhbZ1tcJUGpO95ivbRhWXz++F4Ko+n21AY5PNln2ovoJw+8ZMNDTtip+CNFQfrtLVh/w4009dXO/eQ==",
"license": "BSD-3-Clause"
},
- "packages/broadcast-panel/node_modules/puppeteer/node_modules/puppeteer-core": {
- "version": "24.30.0",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.30.0.tgz",
- "integrity": "sha512-2S3Smy0t0W4wJnNvDe7W0bE7wDmZjfZ3ljfMgJd6hn2Hq/f0jgN+x9PULZo2U3fu5UUIJ+JP8cNUGllu8P91Pg==",
- "license": "Apache-2.0",
- "dependencies": {
- "@puppeteer/browsers": "2.10.13",
- "chromium-bidi": "11.0.0",
- "debug": "^4.4.3",
- "devtools-protocol": "0.0.1521046",
- "typed-query-selector": "^2.12.0",
- "webdriver-bidi-protocol": "0.3.8",
- "ws": "^8.18.3"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "packages/broadcast-panel/node_modules/puppeteer/node_modules/ws": {
- "version": "8.18.3",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
- "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
"packages/broadcast-panel/node_modules/rollup": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
diff --git a/package.json b/package.json
index e499b6b..8e79f27 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
diff --git a/packages/broadcast-panel/README-MOCK.md b/packages/broadcast-panel/README-MOCK.md
new file mode 100644
index 0000000..1cb39f3
--- /dev/null
+++ b/packages/broadcast-panel/README-MOCK.md
@@ -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.
diff --git a/packages/broadcast-panel/src/components/MockToggle/MockToggle.tsx b/packages/broadcast-panel/src/components/MockToggle/MockToggle.tsx
new file mode 100644
index 0000000..28e6d00
--- /dev/null
+++ b/packages/broadcast-panel/src/components/MockToggle/MockToggle.tsx
@@ -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 }
+
diff --git a/packages/broadcast-panel/src/features/studio/PreJoin.module.css b/packages/broadcast-panel/src/features/studio/PreJoin.module.css
index a8781bc..ce05182 100644
--- a/packages/broadcast-panel/src/features/studio/PreJoin.module.css
+++ b/packages/broadcast-panel/src/features/studio/PreJoin.module.css
@@ -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%; }
}
diff --git a/packages/broadcast-panel/src/features/studio/PreJoin.tsx b/packages/broadcast-panel/src/features/studio/PreJoin.tsx
index df9f1a6..c9927a5 100644
--- a/packages/broadcast-panel/src/features/studio/PreJoin.tsx
+++ b/packages/broadcast-panel/src/features/studio/PreJoin.tsx
@@ -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
(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(null)
const [isChecking, setIsChecking] = useState(false)
- // checkbox state is local only; do NOT persist skip preference so PreJoin always appears
- const [skipNextTime, setSkipNextTime] = useState(false)
// keep preview stream active for meter and preview
const [previewStream, setPreviewStream] = useState(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 (
-
-
-
-
Configura tu estudio
-
Entrar al estudio no iniciará automáticamente la transmisión.
-
-
-
-
-
-
-
{name || 'Invitado'}
-
-
-
-
{micEnabled ? 'El micrófono está funcionando' : 'Micrófono desactivado'}
-
-
-
-
-
- }
- label={micEnabled ? 'Desactivar audio' : 'Activar audio'}
- active={micEnabled}
- danger={!micEnabled}
- layout="column"
- variant="studio"
- onClick={toggleMic}
- hint={micHint}
- size="md"
- />
-
- }
- label={camEnabled ? 'Detener cámara' : 'Iniciar cámara'}
- active={camEnabled}
- danger={!camEnabled}
- layout="column"
- variant="studio"
- onClick={toggleCam}
- hint={camHint}
- size="md"
- />
-
- }
- label={'Configuración'}
- active={true}
- layout="column"
- variant="studio"
- onClick={() => { /* abrir modal de settings si aplica */ }}
- size="md"
- />
-
-
- {/* Leyenda de atajos: muestra las combinaciones detectadas (ej: ⌘ + D) */}
-
- Atajos: {micHint} mic · {camHint} cámara
-
-
-
-
-
- {error &&
{error}
}
-
-
Nombre para mostrar
-
setName(e.target.value)} placeholder="Tu nombre" />
-
-
Título (opcional)
-
-
-
- setSkipNextTime(e.target.checked)} />
- Omitir PreJoin la próxima vez
-
-
-
- { onCancel?.() }}>Cancelar
- {isChecking ? 'Comprobando...' : 'Entrar al estudio'}
-
-
-
-
-
+
+
+
Configura tu estudio
+
Entrar al estudio no iniciará automáticamente la transmisión.
+
+
+
+ {/* Preview video */}
+
+
{name || 'Invitado'}
+
+
+
+
+
+
{micEnabled ? 'El micrófono\nestá\nfuncionando' : 'Micrófono desactivado'}
+
Microphone Array (R...)
+
+
+
+
+
+
+ Presiona {isMac ? '⌘' : 'CTRL'} + D
+
+ {micEnabled ? 'Desactivar audio' : 'Activar audio'}
+
+
+
+ Presiona {isMac ? '⌘' : 'CTRL'} + E
+
+ {camEnabled ? 'Detener cámara' : 'Iniciar cámara'}
+
+
+
{}}>
+
+ Configuración
+
+
+
+
+
)
}
diff --git a/packages/broadcast-panel/src/hooks/useStudioLauncher.ts b/packages/broadcast-panel/src/hooks/useStudioLauncher.ts
index 3bd20ff..a473edb 100644
--- a/packages/broadcast-panel/src/hooks/useStudioLauncher.ts
+++ b/packages/broadcast-panel/src/hooks/useStudioLauncher.ts
@@ -20,13 +20,19 @@ export default function useStudioLauncher() {
const [loadingId, setLoadingId] = useState
(null);
const [error, setError] = useState(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
diff --git a/packages/broadcast-panel/vite.config.ts b/packages/broadcast-panel/vite.config.ts
index 96c9e82..fc90e29 100644
--- a/packages/broadcast-panel/vite.config.ts
+++ b/packages/broadcast-panel/vite.config.ts
@@ -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',
],
diff --git a/packages/studio-panel/src/stories/PreJoin.stories.tsx b/packages/studio-panel/src/stories/PreJoin.stories.tsx
index 3bf7993..41a0703 100644
--- a/packages/studio-panel/src/stories/PreJoin.stories.tsx
+++ b/packages/studio-panel/src/stories/PreJoin.stories.tsx
@@ -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 = {
+export default {
title: 'Broadcast/PreJoin',
component: PreJoin,
-};
-export default meta;
-
-type Story = StoryObj;
-
-export const Default: Story = {
- args: {
- roomName: 'Sala de prueba',
- onProceed: () => alert('Proceed clicked'),
- onCancel: () => alert('Cancel clicked'),
- },
-};
+}
+export const Default = () => (
+ { alert('Entrar al estudio') }}
+ onCancel={() => { alert('Cancel') }}
+ />
+)
diff --git a/scripts/capture_and_diff_playwright.mjs b/scripts/capture_and_diff_playwright.mjs
new file mode 100644
index 0000000..e2b9986
--- /dev/null
+++ b/scripts/capture_and_diff_playwright.mjs
@@ -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();
+
diff --git a/scripts/capture_and_diff_prejoin.cjs b/scripts/capture_and_diff_prejoin.cjs
new file mode 100644
index 0000000..a31b496
--- /dev/null
+++ b/scripts/capture_and_diff_prejoin.cjs
@@ -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);
+ }
+})();
+
diff --git a/scripts/capture_and_diff_prejoin.js b/scripts/capture_and_diff_prejoin.js
new file mode 100644
index 0000000..a31b496
--- /dev/null
+++ b/scripts/capture_and_diff_prejoin.js
@@ -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);
+ }
+})();
+
diff --git a/scripts/capture_regions_playwright.mjs b/scripts/capture_regions_playwright.mjs
new file mode 100644
index 0000000..c6d588b
--- /dev/null
+++ b/scripts/capture_regions_playwright.mjs
@@ -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);
+ }
+})();
+
diff --git a/scripts/compare_regions.cjs b/scripts/compare_regions.cjs
new file mode 100644
index 0000000..74c619f
--- /dev/null
+++ b/scripts/compare_regions.cjs
@@ -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));
diff --git a/scripts/visual_test_prejoin.cjs b/scripts/visual_test_prejoin.cjs
new file mode 100644
index 0000000..cf72842
--- /dev/null
+++ b/scripts/visual_test_prejoin.cjs
@@ -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 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);
+