← back to Dw Photo Capture
5x/verify-twoshot.mjs
145 lines
// Targeted /5x verification for #twoShotCam live-adjust ordered two-photo capture (TK-11947)
// Fake camera so getUserMedia grants; drives the real global functions the buttons call.
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
const pw = require('/Users/macstudio3/.npm-global/lib/node_modules/playwright');
const fs = require('fs');
const CHROME = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium'].find(p => fs.existsSync(p));
const APP = 'http://localhost:9890/';
const CREDS = { username: 'admin', password: 'DW2024!' };
const viewports = [
{ name: 'iPhone', width: 390, height: 844, dsf: 3 },
{ name: 'iPad', width: 1024, height: 1366, dsf: 2 },
];
const results = [];
const rec = (vp, name, ok, detail='') => { results.push({ vp, name, ok, detail }); console.log(` [${ok?'PASS':'FAIL'}] ${vp} · ${name}${detail?' — '+detail:''}`); };
for (const vp of viewports) {
console.log(`\n=== ${vp.name} (${vp.width}x${vp.height}) ===`);
const browser = await pw.chromium.launch({
executablePath: CHROME,
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'],
});
const ctx = await browser.newContext({
httpCredentials: CREDS, viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: vp.dsf, isMobile: true, hasTouch: true, permissions: ['camera'],
});
const errs = [];
const page = await ctx.newPage();
page.on('console', m => { if (m.type() === 'error') errs.push(m.text()); });
page.on('pageerror', e => errs.push('PAGEERROR: ' + e.message));
try {
await page.goto(APP, { waitUntil: 'networkidle', timeout: 20000 });
// instrument getUserMedia to count real acquisitions
await page.evaluate(() => {
window.__gum = 0; const orig = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = (...a) => { window.__gum++; return orig(...a); };
});
rec(vp.name, 'twoShotCam element present', !!(await page.$('#twoShotCam')));
rec(vp.name, 'hidden until opened', (await page.$eval('#twoShotCam', el => el.hidden)) === true);
// ORDER GATE: request BACK with no FRONT → must downgrade to FRONT phase, never open a back capture
const gate = await page.evaluate(async () => { _frontPhoto=null; _backPhoto=null; await openTwoShotCam('back'); return { phase:(()=>_tsPhase)(), visible: !document.querySelector('#twoShotCam').hidden }; });
rec(vp.name, 'BACK request downgraded to FRONT (order enforced)', gate.phase === 'front' && gate.visible === true, `phase=${gate.phase}`);
await page.evaluate(() => closeTwoShotCam());
await page.waitForTimeout(200);
// DOUBLE-TAP RACE (Cody fix #1): two openTwoShotCam in the same tick → exactly ONE getUserMedia (guard holds, no orphan hot stream)
const gumBefore = await page.evaluate(() => window.__gum);
await page.evaluate(async () => { _frontPhoto=null; const a=openTwoShotCam('front'); const b=openTwoShotCam('front'); await Promise.all([a,b]); });
await page.waitForTimeout(500);
const gumAfter = await page.evaluate(() => window.__gum);
rec(vp.name, 'double-tap → single getUserMedia (no orphan stream)', (gumAfter - gumBefore) === 1, `${gumAfter-gumBefore} calls`);
// live camera playing + controls
rec(vp.name, 'opens in FRONT phase', (await page.evaluate(() => (()=>_tsPhase)())) === 'front');
rec(vp.name, 'live camera playing (fake device)', (await page.$eval('#tsVideo', v => v.readyState>=2 && v.videoWidth>0).catch(()=>false)) === true);
rec(vp.name, '_tsLive true only after live frame', (await page.evaluate(() => (()=>_tsLive)())) === true);
const sliders = await page.$$eval('#twoShotCam input[type=range]', els => els.length);
rec(vp.name, '3 live color sliders (Bri/Sat/Hue)', sliders >= 3, `${sliders}`);
const shut = await page.$eval('#tsShutterBtn', el => { const r=el.getBoundingClientRect(); return {w:r.width,h:r.height}; });
rec(vp.name, 'shutter >=44px', shut.w>=44 && shut.h>=44, `${Math.round(shut.w)}x${Math.round(shut.h)}`);
rec(vp.name, 'shutter enabled when live', (await page.$eval('#tsShutterBtn', el => !el.disabled)) === true);
rec(vp.name, 'flip visible on FRONT', (await page.$eval('#tsFlip', el => getComputedStyle(el).display !== 'none')) === true);
rec(vp.name, 'step chip shows FRONT/1', /front|1 of 2/i.test(await page.$eval('#twoShotCam', el => el.innerText)));
// capture FRONT → advance to BACK, ghost loaded, resolution capped
const front = await page.evaluate(async () => { await tsShutter(); return { phase:(()=>_tsPhase)(), front:!!(()=>_frontPhoto)(), ghostImg:!!(()=>_tsFrontImg)() }; });
rec(vp.name, 'FRONT captured → _frontPhoto set', front.front === true);
rec(vp.name, 'advances to BACK phase', front.phase === 'back');
rec(vp.name, 'ghost source loaded for BACK', front.ghostImg === true);
// resolution cap (Cody fix #2): decode the captured JPEG, longest edge must be <=2000
const cap = await page.evaluate(() => new Promise(res => { const im=new Image(); im.onload=()=>res(Math.max(im.naturalWidth,im.naturalHeight)); im.onerror=()=>res(-1); im.src=(()=>_frontPhoto)(); }));
rec(vp.name, 'captured photo longest edge <=2000 (server 25MB cap safe)', cap>0 && cap<=2000, `${cap}px`);
// flip hidden on BACK (Cody edge: lens locked for the pair)
rec(vp.name, 'flip HIDDEN on BACK (lens locked for pair)', (await page.$eval('#tsFlip', el => getComputedStyle(el).display === 'none')) === true);
// ghost canvas painted
const ghost = await page.evaluate(() => { const c=document.querySelector('#tsGhost'); if(!c) return null; try{ const d=c.getContext('2d').getImageData(0,0,Math.min(c.width,40),Math.min(c.height,40)).data; return d.some(v=>v!==0);}catch(e){return 'err:'+e.message;} });
rec(vp.name, 'ghost canvas rendered on BACK', ghost === true, String(ghost));
// capture BACK → closes + stream stopped (no leaked camera)
const back = await page.evaluate(async () => { await tsShutter(); const v=document.querySelector('#tsVideo'); const tr=v&&v.srcObject?v.srcObject.getTracks():[]; return { back:!!(()=>_backPhoto)(), streamNull:(()=>_tsStream)()===null, ended:tr.every(t=>t.readyState==='ended'), hidden:document.querySelector('#twoShotCam').hidden }; });
rec(vp.name, 'BACK captured → _backPhoto set', back.back === true);
rec(vp.name, 'view closes after pair', back.hidden === true);
rec(vp.name, 'camera stream stopped on close (no leak)', back.streamNull === true && back.ended === true, `null=${back.streamNull} ended=${back.ended}`);
rec(vp.name, 'zero console/page errors', errs.length === 0, errs.slice(0,3).join(' | '));
} catch (e) {
rec(vp.name, 'RUN', false, e.message);
} finally { await browser.close(); }
}
// ── NEGATIVE / FAIL-PATH TEST (Cody re-gate #2 + CLAUDE.md negative-test rule) ──
// Inject a camera failure and prove the fail-safe goes RED: a failed flip must NOT be able to
// save a frozen frame. Without a rejecting getUserMedia the happy-path 44/44 proves nothing here.
console.log(`\n=== NEGATIVE: injected camera-flip failure (iPhone) ===`);
{
const browser = await pw.chromium.launch({ executablePath: CHROME,
args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'] });
const ctx = await browser.newContext({ httpCredentials: CREDS, viewport: { width:390, height:844 }, deviceScaleFactor:3, isMobile:true, hasTouch:true, permissions:['camera'] });
const page = await ctx.newPage();
try {
await page.goto(APP, { waitUntil:'networkidle', timeout:20000 });
await page.evaluate(() => { window.__failGUM=false; const o=navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia=(...a)=> window.__failGUM ? Promise.reject(new DOMException('NotReadableError','NotReadableError')) : o(...a); });
// open FRONT (camera works) → confirm live + shutter armed
await page.evaluate(async () => { _frontPhoto=null; await openTwoShotCam('front'); });
await page.waitForTimeout(500);
const c0 = await page.evaluate(() => camFacing);
rec('neg', 'baseline: live + shutter armed before fault', (await page.evaluate(()=>(()=>_tsLive)())) === true && (await page.$eval('#tsShutterBtn',e=>!e.disabled)) === true);
// INJECT: flip now fails on every getUserMedia
await page.evaluate(() => { window.__failGUM=true; });
await page.click('#tsFlip');
await page.waitForTimeout(800);
const st = await page.evaluate(() => ({ cam:camFacing, live:(()=>_tsLive)(), disabled:document.querySelector('#tsShutterBtn').disabled, toast:document.querySelector('#toast').textContent, shown:document.querySelector('#toast').classList.contains('show') }));
rec('neg', 'FAIL-PATH: camFacing reverted to known-good lens', st.cam === c0, `cam=${st.cam} c0=${c0}`);
rec('neg', 'FAIL-PATH: _tsLive=false (frozen frame cannot be saved)', st.live === false);
rec('neg', 'FAIL-PATH: shutter disabled', st.disabled === true);
rec('neg', 'FAIL-PATH: toast fired ("Could not switch camera")', /switch camera/i.test(st.toast) && st.shown === true, JSON.stringify(st.toast));
// RECOVERY: camera returns → flip succeeds, shutter re-arms
await page.evaluate(() => { window.__failGUM=false; });
await page.click('#tsFlip');
await page.waitForTimeout(600);
const rc = await page.evaluate(() => ({ live:(()=>_tsLive)(), disabled:document.querySelector('#tsShutterBtn').disabled }));
rec('neg', 'RECOVERY: live + shutter re-armed after camera returns', rc.live === true && rc.disabled === false);
// DOUBLE FLIP-TAP DESYNC (Cody residual crack): two synchronous taps must apply ONE flip, not toggle-back-to-same
const camPre = await page.evaluate(() => camFacing);
await page.evaluate(() => { const b=document.querySelector('#tsFlip'); b.click(); b.click(); });
await page.waitForTimeout(700);
const camPost = await page.evaluate(() => camFacing);
rec('neg', 'double flip-tap → one flip applied (no camFacing desync)', camPost !== camPre, `pre=${camPre} post=${camPost}`);
} catch (e) { rec('neg', 'NEG RUN', false, e.message); }
finally { await browser.close(); }
}
const fails = results.filter(r => !r.ok);
console.log(`\n==== ${results.length - fails.length}/${results.length} PASS · ${fails.length} FAIL ====`);
if (fails.length) fails.forEach(f => console.log(` FAIL: ${f.vp} · ${f.name}${f.detail?' ('+f.detail+')':''}`));
fs.writeFileSync('5x/twoshot-result.json', JSON.stringify(results, null, 2));
process.exit(fails.length ? 1 : 0);