← back to Dw Photo Capture
5x sweep 2: camera-timeout retry tap now opens the snap camera (as the toast promises)
4fd7deeb03c0918c24558acd7132dc233565ae67 · 2026-09-25 10:48:37 -0700 · Steve Abrams
- index.html: after the live camera times out (8s), the next Photo 1/2 tap goes to the native <input capture>
inside the fresh tap's user activation instead of re-running a hung getUserMedia forever (pre-existing since
TK-12124; reproduced 4/4 in WebKit with a cold mock camera). Toast names Photo 1 or Photo 2.
- e2e: asserts whichever capture path the engine takes (LIVE shutter, or timeout toast -> snap file chooser ->
shotAdd); WARM_CAMERA=1 forces LIVE; cam.html check fails loudly if the phone camera never goes live.
- 5x/tools/sweep.sh sweep driver.
TK-12228
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yv665HsXLibk8kGheHT1P
Files touched
A 5x/sweep-2.mdM 5x/tools/console-probe.cjsA 5x/tools/sweep.shM public/index.htmlM scripts/e2e-front-back-display.cjs
Diff
commit 4fd7deeb03c0918c24558acd7132dc233565ae67
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 25 10:48:37 2026 -0700
5x sweep 2: camera-timeout retry tap now opens the snap camera (as the toast promises)
- index.html: after the live camera times out (8s), the next Photo 1/2 tap goes to the native <input capture>
inside the fresh tap's user activation instead of re-running a hung getUserMedia forever (pre-existing since
TK-12124; reproduced 4/4 in WebKit with a cold mock camera). Toast names Photo 1 or Photo 2.
- e2e: asserts whichever capture path the engine takes (LIVE shutter, or timeout toast -> snap file chooser ->
shotAdd); WARM_CAMERA=1 forces LIVE; cam.html check fails loudly if the phone camera never goes live.
- 5x/tools/sweep.sh sweep driver.
TK-12228
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017yv665HsXLibk8kGheHT1P
---
5x/sweep-2.md | 44 ++++++++++++++++++++++++
5x/tools/console-probe.cjs | 5 +--
5x/tools/sweep.sh | 19 +++++++++++
public/index.html | 12 +++++--
scripts/e2e-front-back-display.cjs | 68 +++++++++++++++++++++++++++++++-------
5 files changed, 131 insertions(+), 17 deletions(-)
diff --git a/5x/sweep-2.md b/5x/sweep-2.md
new file mode 100644
index 0000000..8a35bf5
--- /dev/null
+++ b/5x/sweep-2.md
@@ -0,0 +1,44 @@
+# /5x sweep 2 — after 15beb44 (test server :9987 `--test`, restarted)
+
+## Verify (5x/tools/sweep.sh 2 → 5x/out/sweep-2/, plus diagnostic re-runs)
+| check | result |
+|---|---|
+| E2E journey Chromium | 19/19 PASS (LIVE path) |
+| E2E journey WebKit (cold camera) | **CRASH ×4 in a row** before the fix (see #1) → after fix 21/21 PASS via SNAP front + SNAP back; in the full sweep run the camera happened to start fast → 19/19 LIVE |
+| E2E journey Firefox | 19/19 PASS (LIVE path) |
+| E2E journey WebKit, WARM_CAMERA=1 (forces LIVE path) | r0 CRASH (#3) · r1 FAIL (#3, now asserted) · r2–r5 19/19 PASS |
+| /3x `/` `/captures` `/batch` | M1 M2 M3 B4 PASS (favicon fix held: M3 now PASS on /captures + /batch) |
+| /3x `/cam` | M1 M3 B4 PASS · M2 FAIL = known SSE/virtual-time harness artifact (sweep-1 #4) |
+| 3-engine console/4xx/broken-img probe | PASS (0 errors, final document status 200 on all 12) |
+| index launcher + toolbar clickthrough ×3 engines | 33/33 PASS |
+| history thumbs after scroll ×3 engines | PASS |
+| missing-side rule API + UI ×3 engines | PASS |
+
+## Caught → diagnosis
+1. **REAL DEFECT (pre-existing since 9f4d2c2 / TK-12124, present at 126f917): the camera-timeout recovery
+ never reaches the snap camera.** When the live camera doesn't answer within 8s, index.html toasts
+ "Camera didn't respond — tap Photo 1 again to use the snap camera", but the next tap called
+ `openTwoShotCam()` → `startTsStream()` again — another live getUserMedia and another 8s wait, never the
+ native snap input the toast promises. On a phone with a hung camera the user loops forever and can't capture
+ a FRONT/BACK at all — which blocks every downstream display this feature adds. Reproduced in WebKit
+ (mock 4K capture device cold-starts in ~16s under host load; retry tap opened nothing for 20s, 4/4 runs).
+ Small + directly in the capture path the feature depends on → fixed.
+2. Harness: the E2E assumed a warm camera. It now takes whichever path the engine gives it and asserts that path:
+ LIVE (shutter FRONT→BACK) or cold (timeout toast names Photo 1/2 → tap → native file chooser → real JPEG via
+ `setFiles` → shotAdd → tile shows the photo). `WARM_CAMERA=1` forces the LIVE path so WebKit covers both.
+3. Harness/env: cam.html remote shot in WebKit — the harness polled only 20s for `cam_live` and then fired the
+ shoot anyway (silently). It now waits up to 45s and FAILS LOUDLY (with a screenshot + page text) if the
+ phone camera never reports live. 1 of 5 warm WebKit runs still had the phone camera never come up
+ (`cam_connected:false`) — WebKit mock-capture flake under load; not reproduced in 4 further runs. **Open,
+ environmental**; listed in REPORT.
+ Pre-existing observation (not fixed, out of scope): `pairStatus().cam_connected` goes false 20s after the phone
+ opens /cam if the camera hasn't started, because `lastSeen` is only refreshed by camera-status posts, not by
+ the live SSE connection.
+4. Runner B5/B6 still SKIP: tried installing the runner's playwright-1.57 WebKit/Firefox builds; the download
+ hung during extraction (killed, partial webkit-2227 removed). Engine coverage comes from the 1.61-based tools.
+
+## Fixes (commit below)
+- public/index.html: `_tsSnapNext` — set on camera-timeout; the next Photo 1/2 tap goes straight to the native
+ `<input capture>` inside the fresh user activation; toast names the right photo (1 or 2).
+- scripts/e2e-front-back-display.cjs: live/snap path handling + assertions, `WARM_CAMERA`, loud cam-not-live check.
+- 5x/tools/sweep.sh (sweep driver), console-probe final-status check.
diff --git a/5x/tools/console-probe.cjs b/5x/tools/console-probe.cjs
index 02c13dd..941177d 100644
--- a/5x/tools/console-probe.cjs
+++ b/5x/tools/console-probe.cjs
@@ -15,8 +15,9 @@ const ENGINES = (process.env.ENGINES || 'chromium,webkit,firefox').split(',');
page.on('console', m => m.type() === 'error' && errs.push('console: ' + m.text() + ' @' + (m.location().url||'') + ':' + m.location().lineNumber));
page.on('pageerror', x => errs.push('pageerror: ' + x.message));
page.on('requestfailed', r => reqf.push(r.url() + ' ' + (r.failure() && r.failure().errorText)));
- page.on('response', r => { if (r.status() >= 400) reqf.push(r.status() + ' ' + r.url()); });
- await page.goto(BASE + p, { waitUntil: 'load', timeout: 20000 }).catch(x => errs.push('goto: ' + x.message));
+ page.on('response', r => { if (r.status() >= 400 && !(r.status() === 401 && r.request().isNavigationRequest())) reqf.push(r.status() + ' ' + r.url()); });
+ const resp = await page.goto(BASE + p, { waitUntil: 'load', timeout: 20000 }).catch(x => { errs.push('goto: ' + x.message); return null; });
+ if (resp && resp.status() !== 200) errs.push('final document status ' + resp.status()); // the auth-challenge 401 is only OK if the retry lands 200
await page.waitForTimeout(2500);
const imgs = await page.$$eval('img', l => l.filter(i => i.complete && i.getAttribute('src') && !i.closest('[hidden]') && i.naturalWidth === 0).map(i => i.src.slice(0, 100))).catch(() => []);
const r = { errs, reqf, brokenImgs: imgs };
diff --git a/5x/tools/sweep.sh b/5x/tools/sweep.sh
new file mode 100755
index 0000000..5b62098
--- /dev/null
+++ b/5x/tools/sweep.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+# One /5x sweep against the --test server on :9987. Usage: 5x/tools/sweep.sh <n>
+N=${1:?sweep number}; R=/Users/macstudio3/Projects/dw-photo-capture; O=$R/5x/out/sweep-$N; mkdir -p $O
+cd $R
+grep -q "STUBBED (--test)" /tmp/dwp-e2e-server.log || { echo "ABORT: server not in --test"; exit 3; }
+S=0; T=0; tally(){ T=$((T+1)); if [ "$1" = 0 ]; then S=$((S+1)); echo " PASS $2"; else echo " FAIL $2"; fi; }
+for E in chromium webkit firefox; do
+ ENGINE=$E TESTROOT=/tmp/dwp-e2e OUT=/tmp/dwp-e2e/shots-sweep$N-$E node scripts/e2e-front-back-display.cjs > $O/e2e-$E.log 2>&1; tally $? "e2e front/back journey [$E] ($(tail -1 $O/e2e-$E.log); $(grep -m1 "capture path" $O/e2e-$E.log))"
+done
+WARM_CAMERA=1 ENGINE=webkit TESTROOT=/tmp/dwp-e2e OUT=/tmp/dwp-e2e/shots-sweep$N-webkit-warm node scripts/e2e-front-back-display.cjs > $O/e2e-webkit-warm.log 2>&1; tally $? "e2e front/back journey [webkit, warm camera → LIVE path] ($(tail -1 $O/e2e-webkit-warm.log))"
+for p in / /captures /batch /cam; do n=$(echo $p|tr -d /); n=${n:-root}
+ node ~/.claude/skills/3x/run.js --url "http://127.0.0.1:9987$p" --user admin --pass 'DW2024!' --paths "/,/captures,/batch,/cam" --no-open --clickthrough 1 > $O/3x-$n.log 2>&1
+ echo " 3x $p: $(grep -E '^\s+\[(PASS|FAIL|SKIP)\] (M|B)' $O/3x-$n.log | awk '{print $1,$2}' | tr '\n' ' ') | $(grep 'CLICK-THROUGH' $O/3x-$n.log | sed 's/ *CLICK-THROUGH → //')"
+done
+JSON=$O/probe.json node 5x/tools/console-probe.cjs > $O/probe.log 2>&1; tally $? "console/pageerror/4xx/broken-img probe 3 engines x 4 pages"
+node 5x/tools/index-toolbar-click.cjs > $O/toolbar.log 2>&1; tally $? "index launcher+toolbar clickthrough 3 engines ($(tail -1 $O/toolbar.log))"
+node 5x/tools/history-scroll-check.cjs > $O/history.log 2>&1; tally $? "history thumbs load after scroll 3 engines"
+node 5x/tools/captures-missing-check.cjs > $O/missing.log 2>&1; tally $? "missing-side rule api+ui 3 engines"
+echo "TARGETED: $S/$T passed"
diff --git a/public/index.html b/public/index.html
index 963b2aa..21698d8 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2210,6 +2210,9 @@ function wbSampleAt(clientX,clientY){ const v=$('#wbVideo'); if(!v.videoWidth)re
// The colour is corrected on the LIVE feed and BAKED into the saved JPEG. ONE manual-getImageData bake
// (tsPipeline) runs on BOTH the ~80ms live preview AND the full-res shutter, so preview == export.
let _tsStream=null,_tsTrack=null,_tsPhase='front',_tsBusy=false,_tsLive=false,_tsFrontImg=null,_tsPvTimer=null,_tsWake=null,_tsOpening=false,_tsAcquiring=false;
+// 5x/TK-12228: set when the live camera timed out; the user's NEXT Photo 1/2 tap goes to the native snap camera
+// (what the timeout toast promises) instead of re-running a live getUserMedia that just hung for 8s.
+let _tsSnapNext=false;
let _tsStreamGen=0; // bumped every startTsStream() call — lets a late/orphaned getUserMedia resolution (after a timeout bail-out) be detected and released instead of silently adopted
// TK-12124: getUserMedia() can hang forever on WebKit/Safari (denied-but-not-rejected permission, backgrounded
// prompt, MDM camera restriction) — bound the wait so the UI never bricks. 8s ~= comfortably above how long a
@@ -2430,8 +2433,10 @@ async function openTwoShotCam(startPhase){
try{
startPhase = startPhase||'front';
if(startPhase==='back' && !_frontPhoto){ toast('Take Photo 1 (the FRONT) first'); startPhase='front'; }
- // no live camera (HTTP / unsupported) → fall back to the native <input capture> handoff
- if(!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
+ // no live camera (HTTP / unsupported), or the live camera just timed out (toast asked for this fresh tap)
+ // → fall back to the native <input capture> handoff. This runs inside the tap's user activation, so .click() works.
+ if(_tsSnapNext || !(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
+ _tsSnapNext=false;
return $(startPhase==='back'?'#backInput':'#frontInput').click();
}
try{ await startTsStream(); }
@@ -2440,7 +2445,8 @@ async function openTwoShotCam(startPhase){
// TK-12124 Cody hole 3: this fires ~8-14s after the original tap — iOS Safari (and usually Chrome >~5s)
// has already expired the user-activation window by then, so an auto .click() on the native file
// input would silently no-op. Ask for a fresh tap instead of pretending the fallback ran.
- toast('Camera didn’t respond — tap Photo 1 again to use the snap camera');
+ _tsSnapNext=true;
+ toast('Camera didn’t respond — tap Photo '+(startPhase==='back'?'2':'1')+' again to use the snap camera');
return;
}
toast('Camera blocked — using snap'); // instant permission denial — still inside the original tap's activation window, auto-click is safe
diff --git a/scripts/e2e-front-back-display.cjs b/scripts/e2e-front-back-display.cjs
index 9bac259..eec9857 100644
--- a/scripts/e2e-front-back-display.cjs
+++ b/scripts/e2e-front-back-display.cjs
@@ -41,18 +41,55 @@ const allLoaded = l => l.length > 0 && l.every(x => x.w > 0 && x.h > 0);
// ── 1. index.html ADD flow: live two-shot camera → FRONT then BACK ──
await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' });
+ await page.evaluate(() => { window.__toasts = []; const t = document.querySelector('#toast');
+ new MutationObserver(() => window.__toasts.push(t.textContent)).observe(t, { childList: true, characterData: true, subtree: true }); });
+ // WARM_CAMERA=1: start+stop the camera once first (a phone whose camera is already awake) so engines with a slow
+ // cold mock device (WebKit) also cover the LIVE two-shot path; unset = cold start → exercises the snap fallback.
+ if (process.env.WARM_CAMERA === '1') await page.evaluate(async () => { const st = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 4096 }, height: { ideal: 3072 } } }); st.getTracks().forEach(t => t.stop()); });
await page.click('#homeScreen [data-act="add"]');
- await page.waitForSelector('#twoShotCam:not([hidden])', { timeout: 15000 });
- await page.waitForFunction(() => !document.querySelector('#tsShutterBtn').disabled, null, { timeout: 20000 });
- await page.waitForTimeout(600);
- await page.click('#tsShutterBtn'); // FRONT
- await page.waitForFunction(() => document.querySelector('#tsStepName').textContent === 'BACK', null, { timeout: 15000 });
- await page.waitForFunction(() => !document.querySelector('#tsShutterBtn').disabled, null, { timeout: 15000 });
- const tsThumb = await imgs(page, '#tsThumb');
- check('live cam: FRONT thumbnail shows while shooting BACK', allLoaded(tsThumb), tsThumb);
- await page.screenshot({ path: path.join(OUT, '01-livecam-front-thumb.png') });
- await page.click('#tsShutterBtn'); // BACK
- await page.waitForSelector('#twoShotCam', { state: 'hidden', timeout: 15000 });
+ const opened = await page.waitForSelector('#twoShotCam:not([hidden])', { timeout: 12000 }).then(() => true).catch(() => false);
+ // real JPEG files for the native snap-camera fallback (<input type=file capture> → shotAdd)
+ const snapFile = async (label, color) => { const d = await page.evaluate(([label, color]) => { const c = document.createElement('canvas'); c.width = 1200; c.height = 900;
+ const g = c.getContext('2d'); g.fillStyle = color; g.fillRect(0, 0, 1200, 900); g.fillStyle = '#fff'; g.font = 'bold 120px sans-serif'; g.fillText(label, 300, 480); return c.toDataURL('image/jpeg', .9); }, [label, color]);
+ const f = path.join(OUT, `snap-${label}.jpg`); fs.writeFileSync(f, Buffer.from(d.split(',')[1], 'base64')); return f; };
+ const snapSide = async side => { // tap the tile → native file chooser → pick a photo
+ const fc = page.waitForEvent('filechooser', { timeout: 8000 });
+ await page.click(side === 'front' ? '#frontBtn' : '#backBtn');
+ await (await fc).setFiles(await snapFile(side.toUpperCase(), side === 'front' ? '#2a7' : '#a42'));
+ await page.waitForSelector(`#${side}Img:not([hidden])`, { timeout: 15000 });
+ };
+ const shootLiveBack = async () => {
+ await page.waitForFunction(() => !document.querySelector('#tsShutterBtn').disabled, null, { timeout: 15000 });
+ const tsThumb = await imgs(page, '#tsThumb');
+ check('live cam: FRONT thumbnail shows while shooting BACK', allLoaded(tsThumb), tsThumb);
+ await page.screenshot({ path: path.join(OUT, '01-livecam-front-thumb.png') });
+ await page.click('#tsShutterBtn'); // BACK
+ await page.waitForSelector('#twoShotCam', { state: 'hidden', timeout: 15000 });
+ };
+ const toastsSeen = () => page.evaluate(() => window.__toasts.splice(0));
+ if (opened) {
+ console.log('capture path: LIVE camera (front + back)');
+ await page.waitForFunction(() => !document.querySelector('#tsShutterBtn').disabled, null, { timeout: 20000 });
+ await page.waitForTimeout(600);
+ await page.click('#tsShutterBtn'); // FRONT
+ await page.waitForFunction(() => document.querySelector('#tsStepName').textContent === 'BACK', null, { timeout: 15000 });
+ await shootLiveBack();
+ } else {
+ // A COLD camera can exceed the app's 8s getUserMedia bound (WebKit's mock 4K capture device takes ~16s to
+ // start under host load). Exercise the designed recovery: timeout toast → the user's next tap opens the
+ // native SNAP camera (file chooser) → shotAdd → the photo shows in its tile.
+ check('cold camera: timeout toast asks for a fresh Photo 1 tap', (await toastsSeen()).some(t => /didn.t respond.*Photo 1/i.test(t)));
+ await snapSide('front');
+ check('after the timeout, the Photo 1 tap opens the SNAP camera and the FRONT tile shows the photo', true);
+ await page.click('#backBtn'); // BACK: live camera (now warm) or another timeout → snap
+ const live2 = await page.waitForSelector('#twoShotCam:not([hidden])', { timeout: 12000 }).then(() => true).catch(() => false);
+ if (live2) { console.log('capture path: SNAP front + LIVE back'); await shootLiveBack(); }
+ else {
+ console.log('capture path: SNAP front + SNAP back');
+ check('cold camera on BACK: timeout toast asks for a fresh Photo 2 tap', (await toastsSeen()).some(t => /didn.t respond.*Photo 2/i.test(t)));
+ await snapSide('back');
+ }
+ }
await page.waitForSelector('#frontImg:not([hidden])'); await page.waitForSelector('#backImg:not([hidden])');
const tiles = await imgs(page, '#frontImg, #backImg');
check('immediate preview: FRONT + BACK tiles both render', tiles.length === 2 && allLoaded(tiles), tiles);
@@ -138,7 +175,14 @@ const allLoaded = l => l.length > 0 && l.every(x => x.w > 0 && x.h > 0);
cam.on('pageerror', e => console.log('[cam pageerror]', e.message));
await cam.goto(BASE + '/cam', { waitUntil: 'domcontentloaded' });
await cam.click('#gBtn').catch(() => {});
- for (let i = 0; i < 40; i++) { const st = await cam.evaluate(async () => (await fetch('/api/pair/status')).json()); if (st.cam_connected && st.cam_live) break; await cam.waitForTimeout(500); }
+ // the desktop only shoots once the phone reports a LIVE camera (a cold WebKit mock camera can take >20s under load)
+ let camSt = {};
+ for (let i = 0; i < 90; i++) { camSt = await cam.evaluate(async () => (await fetch('/api/pair/status')).json()); if (camSt.cam_connected && camSt.cam_live) break; await cam.waitForTimeout(500); }
+ if (!(camSt.cam_connected && camSt.cam_live)) {
+ const why = await cam.evaluate(() => ({ body: document.body.innerText.slice(0, 300), gate: !document.querySelector('#gate').hidden })).catch(e => e.message);
+ await cam.screenshot({ path: path.join(OUT, '09-cam-NOT-LIVE.png') }).catch(() => {});
+ check('cam.html: phone camera reports live before the remote shot', false, { camSt, why });
+ }
const sh = await post('/api/pair/shoot', {});
await cam.waitForSelector('#lastShot:not([hidden])', { timeout: 20000 });
const cl = await imgs(cam, '#lastShotImg');
← b49fdf9 deploy: exclude tracked schu-publish-audit.jsonl (identical
·
back to Dw Photo Capture
·
5x sweep 3: hung-camera e2e mode (all engines), WebKit stub bf95f56 →