// Canvas cameras and intercepted auth; no personal images, writes or paid API. import assert from 'node:assert/strict'; import fs from 'node:fs'; import { execSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; const { chromium } = await import(pathToFileURL(execSync('npm root -g', { encoding: 'utf8' }).trim() + '/playwright/index.mjs').href); const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] }); try { const page = await browser.newPage({ viewport: { width: 375, height: 726 } }); const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.route('**/api/**', route => { assert.equal(route.request().method(), 'GET', 'no account/report writes'); return route.fulfill({ json: { ok: true, user: { id: 'camera-fixture', kind: 'browser' } } }); }); const source = fs.readFileSync(fileURLToPath(new URL('../js/app.js', import.meta.url)), 'utf8'); await page.route('**/js/app.js*', route => route.fulfill({ contentType: 'text/javascript', body: source.replace(/\}\)\(\);\s*$/, ` window.__cameraTest = { enter: function() { showScreen('s-cam'); startCamera(); renderCamStep(); }, stop: stopCamera, capture: function(value) { shutterLock = value; }, startDetection: function() { modelsOk = true; detectDisabled = false; IS_MOCK = false; maybeStartDetect(); }, state: function() { return { live: camLive, busy: camBusy, facing: camFacing, fallback: camFallback }; } }; })();`) })); await page.addInitScript(() => { const nativeTimeout = window.setTimeout.bind(window); window.setTimeout = (fn, ms, ...args) => nativeTimeout(fn, window.__shortCameraTimeout && ms === 12000 ? 200 : ms, ...args); window.__calls = []; window.__delay = 0; window.__fail = false; window.__ignoreFacing = false; window.__streams = []; window.__frameAt = 0; navigator.mediaDevices.getUserMedia = async constraints => { const facing = constraints.video.facingMode.ideal; window.__calls.push(facing); const fail = window.__fail, delay = window.__delay; window.__fail = false; await new Promise(r => setTimeout(r, delay)); if (fail) throw new Error('fixture camera unavailable'); const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 480; const ctx = canvas.getContext('2d'); ctx.fillStyle = facing === 'user' ? '#316a94' : '#d18e48'; ctx.fillRect(0, 0, 640, 480); const stream = canvas.captureStream(20), track = stream.getVideoTracks()[0]; const settings = track.getSettings.bind(track); track.getSettings = () => ({ ...settings(), facingMode: window.__ignoreFacing ? 'user' : facing }); const timer = setInterval(() => ctx.fillRect(0, 0, 640, 480), 40); const stop = track.stop.bind(track); track.stop = () => { clearInterval(timer); stop(); }; window.__streams.push(stream); return stream; }; document.addEventListener('DOMContentLoaded', () => { document.querySelector('#cam-video').addEventListener('loadeddata', () => { window.__frameAt = performance.now(); }); }); }); await page.goto((process.env.H5_BASE_URL || 'http://127.0.0.1:8080/') + '?mock=1'); await page.evaluate(() => window.__cameraTest.enter()); const live = () => page.waitForFunction(() => window.__cameraTest.state().live); await live(); for (const delay of [0, 1000]) { await page.evaluate(delay => { window.__delay = delay; document.querySelector('.cam-flip').click(); }, delay); assert(await page.locator('#cam-frozen').isVisible(), 'frozen preview during switching'); const count = await page.evaluate(() => window.__calls.length); await page.evaluate(() => document.querySelector('.cam-flip').click()); assert.equal(await page.evaluate(() => window.__calls.length), count, 'rapid click ignored'); await live(); const extra = await page.evaluate(() => performance.now() - window.__frameAt); assert(extra < 500, `first-frame to visible latency ${extra}ms, no fixed 900ms wait`); assert(!(await page.locator('#cam-frozen').isVisible())); console.log('flip delay', delay, 'first-frame to visible', Math.round(extra), 'ms'); } const before = await page.evaluate(() => window.__cameraTest.state().facing); await page.evaluate(() => { window.__delay = 0; window.__fail = true; document.querySelector('.cam-flip').click(); }); await live(); assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'failed flip restores camera'); assert.equal(await page.evaluate(() => window.__cameraTest.state().fallback), false); await page.evaluate(() => { window.__ignoreFacing = true; document.querySelector('.cam-flip').click(); }); await live(); assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'ignored facing is detected/restored'); await page.evaluate(() => { window.__ignoreFacing = false; window.__shortCameraTimeout = true; window.__delay = 600; document.querySelector('.cam-flip').click(); window.__delay = 0; // restoration succeeds while the original request is still pending }); await live(); assert.equal(await page.evaluate(() => window.__cameraTest.state().facing), before, 'timeout restores original camera'); await page.waitForTimeout(700); assert(await page.evaluate(() => window.__streams.slice(0, -2).every(s => s.getTracks().every(t => t.readyState === 'ended')))); await page.evaluate(() => { window.__shortCameraTimeout = false; }); const count = await page.evaluate(() => window.__calls.length); await page.evaluate(() => { window.__cameraTest.capture(true); document.querySelector('.cam-flip').click(); window.__cameraTest.capture(false); }); assert.equal(await page.evaluate(() => window.__calls.length), count, 'capture blocks flip'); // Pending inference from the old camera must neither update UI nor fork loops. await page.evaluate(() => { window.__detectCalls = 0; window.__concurrent = 0; window.__maxConcurrent = 0; window.faceapi = { nets: { tinyFaceDetector: { loadFromUri: () => Promise.resolve() }, faceLandmark68Net: { loadFromUri: () => Promise.resolve() } }, TinyFaceDetectorOptions: function() {}, detectAllFaces: function() { return { withFaceLandmarks: function() { window.__detectCalls++; window.__concurrent++; window.__maxConcurrent = Math.max(window.__maxConcurrent, window.__concurrent); return new Promise(resolve => { window.__resolveDetect = () => { window.__concurrent--; resolve([]); }; }); } }; } }; window.__cameraTest.startDetection(); }); await page.waitForFunction(() => window.__detectCalls === 1); await page.evaluate(() => { window.__ignoreFacing = false; document.querySelector('.cam-flip').click(); }); await live(); await page.waitForTimeout(600); assert.equal(await page.evaluate(() => window.__detectCalls), 1, 'do not overlap old inference'); await page.evaluate(() => window.__resolveDetect()); await page.waitForFunction(() => window.__detectCalls === 2); await page.evaluate(() => window.__resolveDetect()); await page.waitForFunction(() => window.__detectCalls === 3); assert.equal(await page.evaluate(() => window.__maxConcurrent), 1, 'single detection loop'); await page.evaluate(() => { window.__delay = 600; document.querySelector('.cam-flip').click(); window.__cameraTest.stop(); }); await page.waitForTimeout(900); assert.equal(await page.evaluate(() => window.__cameraTest.state().live), false, 'late stream cannot revive stopped camera'); assert(await page.evaluate(() => window.__streams.every(s => s.getTracks().every(t => t.readyState === 'ended'))), 'all streams released'); assert.equal(await page.evaluate(() => document.querySelector('#cam-frozen').width), 0, 'frozen face released'); assert.deepEqual(errors, []); console.log('PASS camera flip: first frame, frozen preview, rapid clicks, recovery/timeout, capture lock, stale inference, cleanup'); } finally { await browser.close(); }