[object Object]

← back to Dw Photo Capture

Cody re-gate #2 hardening: conditional shutter-arm + flip double-tap guard + injected-failure negative test

635c96e84d85e0d546bb6b99d0fc0b8bd14eb077 · 2026-09-20 12:05:50 -0700 · steve

- openTwoShotCam only arms the shutter if _tsLive (never re-arm a dead shutter)
- #tsFlip handler ignores a 2nd tap mid-acquire (no camFacing desync)
- 5x harness gains a fail-path test: injected getUserMedia rejection proves a failed flip
  reverts camFacing, keeps _tsLive false + shutter disabled (no frozen-frame save), toasts,
  and recovers when the camera returns — 51/51 PASS iPhone+iPad+negative

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QKdG2eRwfVYSA7CwxzYAXr

Files touched

Diff

commit 635c96e84d85e0d546bb6b99d0fc0b8bd14eb077
Author: steve <steve@designerwallcoverings.com>
Date:   Sun Sep 20 12:05:50 2026 -0700

    Cody re-gate #2 hardening: conditional shutter-arm + flip double-tap guard + injected-failure negative test
    
    - openTwoShotCam only arms the shutter if _tsLive (never re-arm a dead shutter)
    - #tsFlip handler ignores a 2nd tap mid-acquire (no camFacing desync)
    - 5x harness gains a fail-path test: injected getUserMedia rejection proves a failed flip
      reverts camFacing, keeps _tsLive false + shutter disabled (no frozen-frame save), toasts,
      and recovers when the camera returns — 51/51 PASS iPhone+iPad+negative
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QKdG2eRwfVYSA7CwxzYAXr
---
 5x/twoshot-result.json | 42 ++++++++++++++++++++++++++++++++++++++++++
 5x/verify-twoshot.mjs  | 43 +++++++++++++++++++++++++++++++++++++++++++
 public/index.html      |  5 +++--
 3 files changed, 88 insertions(+), 2 deletions(-)

diff --git a/5x/twoshot-result.json b/5x/twoshot-result.json
index 501e9f9..b944b17 100644
--- a/5x/twoshot-result.json
+++ b/5x/twoshot-result.json
@@ -262,5 +262,47 @@
     "name": "zero console/page errors",
     "ok": true,
     "detail": ""
+  },
+  {
+    "vp": "neg",
+    "name": "baseline: live + shutter armed before fault",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "neg",
+    "name": "FAIL-PATH: camFacing reverted to known-good lens",
+    "ok": true,
+    "detail": "cam=environment c0=environment"
+  },
+  {
+    "vp": "neg",
+    "name": "FAIL-PATH: _tsLive=false (frozen frame cannot be saved)",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "neg",
+    "name": "FAIL-PATH: shutter disabled",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "neg",
+    "name": "FAIL-PATH: toast fired (\"Could not switch camera\")",
+    "ok": true,
+    "detail": "\"Could not switch camera\""
+  },
+  {
+    "vp": "neg",
+    "name": "RECOVERY: live + shutter re-armed after camera returns",
+    "ok": true,
+    "detail": ""
+  },
+  {
+    "vp": "neg",
+    "name": "double flip-tap → one flip applied (no camFacing desync)",
+    "ok": true,
+    "detail": "pre=user post=environment"
   }
 ]
\ No newline at end of file
diff --git a/5x/verify-twoshot.mjs b/5x/verify-twoshot.mjs
index 7197523..0d8639e 100644
--- a/5x/verify-twoshot.mjs
+++ b/5x/verify-twoshot.mjs
@@ -94,6 +94,49 @@ for (const vp of viewports) {
   } 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+')':''}`));
diff --git a/public/index.html b/public/index.html
index d4095a0..1a0cd4a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2222,7 +2222,7 @@ async function openTwoShotCam(startPhase){
   if(startPhase==='back'){ _tsFrontImg=await tsLoadImg(_frontPhoto); }
   tsSetStep();
   $('#twoShotCam').hidden=false; document.body.style.overflow='hidden';
-  $('#tsShutterBtn').disabled=false;
+  if(_tsLive) $('#tsShutterBtn').disabled=false;   // only arm the shutter if a live frame is confirmed (startTsStream owns the enable; never re-arm a dead shutter)
   if(_tsPvTimer) clearInterval(_tsPvTimer); _tsPvTimer=setInterval(tsPreviewTick,80);
   requestAnimationFrame(drawTsGhost);
   tsRequestWake();
@@ -2266,7 +2266,8 @@ async function tsShutter(){
 $('#tsShutterBtn').addEventListener('click',tsShutter);
 $('#tsClose').addEventListener('click',closeTwoShotCam);
 $('#tsReset').addEventListener('click',tsResetColour);
-$('#tsFlip').addEventListener('click',async()=>{ const _prev=camFacing; camFacing=(camFacing==='user')?'environment':'user'; setLS('camFacing',camFacing); applyCamCapture();
+$('#tsFlip').addEventListener('click',async()=>{ if(_tsAcquiring) return;   // ignore a 2nd fast tap mid-acquire → prevents camFacing desync (Cody re-gate #2)
+  const _prev=camFacing; camFacing=(camFacing==='user')?'environment':'user'; setLS('camFacing',camFacing); applyCamCapture();
   try{ await startTsStream(); }
   catch(e){ // switch failed → revert to the known-good lens and re-acquire so the feed never stays frozen (shutter stays disabled until live)
     camFacing=_prev; setLS('camFacing',_prev); applyCamCapture();

← 1878910 5x fix sweep: guard camera race + fail-safe liveness + cap c  ·  back to Dw Photo Capture  ·  5x REPORT: TK-11947 capture rebuild — 51/51, Cody SHIP IT, d 7d98690 →