← 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
M 5x/twoshot-result.jsonM 5x/verify-twoshot.mjsM public/index.html
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 →