← back to Dw Photo Capture

5x/features.cjs

631 lines

#!/usr/bin/env node
/* 5x/features.cjs — feature-specific Playwright verification for the 14ba745 photo-capture build.
 *
 *   node 5x/features.cjs --sweep 1 [--base https://photo.agentabrams.com] [--local http://127.0.0.1:9890] [--fm http://127.0.0.1:9899]
 *   node 5x/features.cjs --sweep 2 --base http://127.0.0.1:9890 --local http://127.0.0.1:9890 --only batch   # Batch Shoot Mode (TK-11909) — LOCAL only, writes files
 *
 * Chromium runs with a FAKE camera (--use-fake-device-for-media-stream + a deterministic warm-cast Y4M file)
 * so the live-camera surfaces (sticky vendor bar, Measure Depth, White Balance) really open. WebKit and
 * Firefox have no fake camera → camera-dependent checks are Chromium-only (recorded as SKIP, never faked).
 *
 * HARD RAILS
 *  - Every /api/create-item request is intercepted; a commit:true body NEVER reaches the server (it is
 *    fulfilled with a fake success so the UI's post-commit path can be exercised, and recorded).
 *  - Console errors + pageerrors are collected per context and must be ZERO.
 *  - Gemini cost is metered from the cost_usd field on /api/ocr + /api/extract responses.
 */
'use strict';
const fs = require('fs'), path = require('path');
const { createRequire } = require('module');
const pw = createRequire('/Users/macstudio3/Projects/Designer-Wallcoverings/node_modules/playwright/package.json')('playwright');

const argv = process.argv.slice(2), A = {};
for (let i = 0; i < argv.length; i++) if (argv[i].startsWith('--')) { const k = argv[i].slice(2); if (argv[i + 1] && !argv[i + 1].startsWith('--')) { A[k] = argv[i + 1]; i++; } else A[k] = true; }
const BASE = (A.base || 'https://photo.agentabrams.com').replace(/\/$/, '');
const LOCAL = (A.local || 'http://127.0.0.1:9890').replace(/\/$/, '');
const FMURL = (A.fm || 'http://127.0.0.1:9899').replace(/\/$/, '');
const SWEEP = String(A.sweep || 'x');
const ONLY = A.only ? String(A.only).split(',') : null;
const AUTH = { username: 'admin', password: 'DW2024!' };
const AUTH_HDR = 'Basic ' + Buffer.from('admin:DW2024!').toString('base64');
const OUT = path.join(__dirname, 'out'); fs.mkdirSync(OUT, { recursive: true });
const FIX = path.join(__dirname, 'fixtures'); fs.mkdirSync(FIX, { recursive: true });
const VP = { ipad: { width: 1024, height: 1366 }, iphone: { width: 390, height: 844 } };
const VENDOR = 'Anna French', VENDOR_PREFIX = 'DWAT';

const results = []; let cost = 0; const costLog = [];
function rec(id, name, engine, vp, pass, detail) {
  const r = { id, name, engine, vp, pass: pass === 'skip' ? 'skip' : !!pass, detail: String(detail || '') };
  results.push(r);
  console.log(`${r.pass === 'skip' ? 'SKIP' : r.pass ? 'PASS' : 'FAIL'} [${engine}/${vp}] ${id} ${name}${detail ? ' — ' + detail : ''}`);
}
const j = o => JSON.stringify(o);
const sleep = ms => new Promise(r => setTimeout(r, ms));

// ── fixtures ─────────────────────────────────────────────────────────────────────────────────────
const Y4M = path.join(FIX, 'warm-cast.y4m');
function makeY4M(file, W = 640, H = 480, frames = 12, Y = 190, U = 118, V = 142) {   // warm cast ≈ rgb(210,183,172)
  const hdr = Buffer.from(`YUV4MPEG2 W${W} H${H} F30:1 Ip A1:1 C420jpeg\n`);
  const fr = Buffer.concat([Buffer.from('FRAME\n'), Buffer.alloc(W * H, Y), Buffer.alloc(W * H / 4, U), Buffer.alloc(W * H / 4, V)]);
  fs.writeFileSync(file, Buffer.concat([hdr, ...Array(frames).fill(fr)]));
}
const FRONT = path.join(FIX, 'front-navy.jpg'), BACK = path.join(FIX, 'back-label.jpg');
async function makeJpegs(browser) {
  if (fs.existsSync(FRONT) && fs.existsSync(BACK)) return;
  const page = await browser.newPage();
  const front = await page.evaluate(() => { const c = document.createElement('canvas'); c.width = 800; c.height = 600; const x = c.getContext('2d'); x.fillStyle = 'rgb(36,52,86)'; x.fillRect(0, 0, 800, 600); return c.toDataURL('image/jpeg', 0.95); });
  const back = await page.evaluate(() => { const c = document.createElement('canvas'); c.width = 900; c.height = 700; const x = c.getContext('2d');
    x.fillStyle = '#fff'; x.fillRect(0, 0, 900, 700); x.fillStyle = '#111'; x.textBaseline = 'top';
    x.font = 'bold 54px Helvetica, Arial, sans-serif'; x.fillText('ANNA FRENCH', 60, 60);
    x.font = '40px Helvetica, Arial, sans-serif'; x.fillText('Watercolor Stripe', 60, 160);
    x.font = 'bold 64px Menlo, monospace'; x.fillText('TR2581', 60, 250);
    x.font = '36px Helvetica, Arial, sans-serif'; x.fillText('Colour: Navy', 60, 360); x.fillText('Width 27 in  ·  Repeat 25.2 in', 60, 430);
    x.fillText('Non-woven  ·  Sold per single roll', 60, 500); x.fillText('Lot 4471', 60, 570);
    return c.toDataURL('image/jpeg', 0.95); });
  fs.writeFileSync(FRONT, Buffer.from(front.split(',')[1], 'base64'));
  fs.writeFileSync(BACK, Buffer.from(back.split(',')[1], 'base64'));
  await page.close();
}
const fileArg = (p, name) => ({ name, mimeType: 'image/jpeg', buffer: fs.readFileSync(p) });

// ── context factory: error capture, commit guard, cost meter ─────────────────────────────────────
async function newCtx(browser, engine, vpName, o = {}) {
  const opts = { viewport: VP[vpName], deviceScaleFactor: 2, httpCredentials: AUTH, ignoreHTTPSErrors: true };
  if (engine !== 'firefox') { opts.isMobile = true; opts.hasTouch = true; }
  if (engine === 'chromium') opts.permissions = ['camera'];
  const ctx = await browser.newContext(opts);
  const errors = [], commits = [];
  const wire = p => { p.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); p.on('pageerror', e => errors.push('pageerror: ' + (e && e.message || e))); p.on('filechooser', () => {}); };
  ctx.on('page', wire);
  if (o.init) await ctx.addInitScript(o.init);
  await ctx.route('**/api/create-item', async route => {
    let body = {}; try { body = JSON.parse(route.request().postData() || '{}'); } catch (e) { /* ignore */ }
    if (body.commit === true) {
      commits.push({ mfr: body.mfr, vendor: body.vendor, ts: Date.now() });
      // NEVER let a commit reach the server. Fulfill a fake success so the UI's post-commit path runs.
      return route.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: true, product_id: null, dw_sku: 'DWAT-5XFAKE', title: '5x harness fake commit', status: 'draft', filemaker: { committed: false, skipped: '5x harness' }, flags: [] }) });
    }
    return route.continue();
  });
  ctx.on('response', async r => { const u = r.url(); if (/\/api\/(ocr|extract)(\?|$)/.test(u)) { try { const d = await r.json(); if (d && d.cost_usd) { cost += d.cost_usd; costLog.push({ url: u.replace(BASE, '').replace(LOCAL, ''), cost: d.cost_usd }); } } catch (e) { /* ignore */ } } });
  const page = await ctx.newPage(); wire(page);
  return { ctx, page, errors, commits };
}
async function openApp(page) { await page.goto(BASE + '/', { waitUntil: 'domcontentloaded' }); await page.waitForSelector('#homeScreen .home-pill', { timeout: 30000 }); }
async function openAdd(page) { await page.click('#homeScreen button.home-pill:has-text("Add New SKU")'); await page.waitForSelector('#addModal:not([hidden])', { timeout: 15000 }); }
async function vendorsLoaded(page, sel = '#addVendor') { await page.waitForFunction(s => document.querySelectorAll(s + ' option').length > 300, sel, { timeout: 30000 }); }
async function finishCtx(tag, engine, vp, c) {
  rec('ERR', `zero console errors + zero pageerrors (${tag})`, engine, vp, c.errors.length === 0, c.errors.slice(0, 6).join(' || '));
  if (c.commits.length) rec('GUARD', `commit:true requests intercepted by harness (${tag})`, engine, vp, true, j(c.commits));
  await c.ctx.close();
}
async function guarded(id, engine, vp, fn) {
  try { await fn(); } catch (e) { rec(id, 'test threw', engine, vp, false, (e && e.message || String(e)).split('\n')[0].slice(0, 300)); }
}
async function shot(page, name) { try { await page.screenshot({ path: path.join(OUT, `s${SWEEP}-${name}.png`) }); } catch (e) { /* ignore */ } }

// ── F1: sticky vendor bar over the live camera (Chromium) ────────────────────────────────────────
async function tVendorBar(browser, engine, vp) {
  const c = await newCtx(browser, engine, vp); const { page } = c;
  await page.route('**/api/ocr', r => r.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: true, candidates: [], top: null, topStrong: false, fields: {}, available: ['macvision', 'gemini'], cost_usd: 0 }) }));
  await guarded('F1', engine, vp, async () => {
    await openApp(page); await openAdd(page); await page.click('#addClose');
    await page.click('#scanBtn'); await page.waitForSelector('#scanlive:not([hidden])', { timeout: 15000 });
    const cam = await page.waitForFunction(() => document.querySelector('#scanvideo').videoWidth > 0, null, { timeout: 15000 }).then(() => true).catch(() => false);
    rec('F1.0', 'live camera stream opened (fake device)', engine, vp, cam);
    await vendorsLoaded(page, '#stickyVendor');
    const n = await page.$$eval('#stickyVendor option', o => o.length - 1);
    rec('F1.1', '/api/vendors-registry loads into the sticky bar', engine, vp, n >= 300, n + ' vendors');
    const fsz = await page.$eval('#stickyVendor', e => parseFloat(getComputedStyle(e).fontSize));
    rec('F1.2', 'XL vendor text ≥ 24px', engine, vp, fsz >= 24, fsz + 'px');
    const geo = await page.evaluate(() => { const s = document.querySelector('#vendorSticky').getBoundingClientRect(), l = document.querySelector('#scanlive').getBoundingClientRect(), sel = document.querySelector('#stickyVendor').getBoundingClientRect(); const hit = document.elementFromPoint(sel.left + sel.width / 2, sel.top + sel.height / 2); return { top: Math.round(s.top), liveTop: Math.round(l.top), w: Math.round(s.width), vw: innerWidth, hit: hit && (hit.id || hit.tagName), selRight: Math.round(sel.right) }; });
    rec('F1.3', 'bar is pinned to the top of the live camera, full-width, hit-testable', engine, vp, geo.top <= geo.liveTop + 1 && geo.hit === 'stickyVendor' && geo.selRight <= geo.vw, j(geo));
    await page.selectOption('#stickyVendor', VENDOR);
    const ls1 = await page.evaluate(() => localStorage.getItem('dwphoto_vendor')); const meta1 = (await page.textContent('#stickyVendorMeta')) || '';
    rec('F1.4', 'pick persists (localStorage) + meta shows the series', engine, vp, ls1 === VENDOR && meta1.includes(VENDOR_PREFIX), `${ls1} | ${meta1.trim()}`);
    await shot(page, `${vp}-vendorbar`);
    await page.click('#scanCancel');
    await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#homeScreen .home-pill');
    await openAdd(page); await vendorsLoaded(page);
    await page.waitForFunction(v => document.querySelector('#addVendor').value === v, VENDOR, { timeout: 10000 }).catch(() => {});
    const av = await page.$eval('#addVendor', e => e.value); await page.click('#addClose');
    await page.click('#scanBtn'); await page.waitForSelector('#scanlive:not([hidden])', { timeout: 15000 });
    await page.waitForFunction(v => document.querySelector('#stickyVendor').value === v, VENDOR, { timeout: 15000 }).catch(() => {});
    const sv = await page.$eval('#stickyVendor', e => e.value); const meta2 = (await page.textContent('#stickyVendorMeta')) || '';
    rec('F1.5', 'survives reload: shown in the top bar + mirrored into the modal picker', engine, vp, sv === VENDOR && av === VENDOR && meta2.includes(VENDOR_PREFIX), `sticky=${sv} modal=${av}`);
    await page.click('#scanCancel');
  });
  await finishCtx('vendor bar', engine, vp, c);
}

// ── F1' : twin picker + tiles (non-camera; all engines) ──────────────────────────────────────────
async function tTwinPicker(browser, engine, vp) {
  const c = await newCtx(browser, engine, vp); const { page } = c;
  await guarded('F1t', engine, vp, async () => {
    await openApp(page); await openAdd(page); await vendorsLoaded(page);
    const n = await page.$$eval('#addVendor option', o => o.length - 1);
    const fsz = await page.$eval('#addVendor', e => parseFloat(getComputedStyle(e).fontSize));
    rec('F1t.1', 'modal twin picker loads the registry, XL text ≥ 24px', engine, vp, n >= 300 && fsz >= 24, `${n} vendors, ${fsz}px`);
    await page.selectOption('#addVendor', VENDOR);
    const ls = await page.evaluate(() => localStorage.getItem('dwphoto_vendor'));
    await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#homeScreen .home-pill'); await openAdd(page); await vendorsLoaded(page);
    await page.waitForFunction(v => document.querySelector('#addVendor').value === v, VENDOR, { timeout: 10000 }).catch(() => {});
    const av = await page.$eval('#addVendor', e => e.value);
    rec('F1t.2', 'vendor pick survives reload (localStorage) in the twin picker', engine, vp, ls === VENDOR && av === VENDOR, `ls=${ls} after=${av}`);
    const tiles = await page.evaluate(() => { const f = document.querySelector('#frontBtn'), b = document.querySelector('#backBtn'); const vis = e => { const r = e.getBoundingClientRect(); return r.width > 40 && r.height > 40; }; return { f: vis(f), b: vis(b), fn: f.querySelector('.shot-num').textContent, bn: b.querySelector('.shot-num').textContent, ft: f.textContent.includes('Front'), bt: b.textContent.includes('Back') }; });
    rec('F2t', 'Front(1) then Back(2) numbered tiles render', engine, vp, tiles.f && tiles.b && tiles.fn === '1' && tiles.bn === '2' && tiles.ft && tiles.bt, j(tiles));
    const fit = await page.evaluate(() => { const card = document.querySelector('#addModal .samp-card'), m = document.querySelector('#addModal'); const r = card.getBoundingClientRect(); return { top: Math.round(r.top), bottom: Math.round(r.bottom), vh: innerHeight, ov: getComputedStyle(m).overflowY, cardOv: getComputedStyle(card).overflowY }; });
    const reachable = (fit.top >= 0 && fit.bottom <= fit.vh) || /auto|scroll/.test(fit.ov) || /auto|scroll/.test(fit.cardOv);
    rec('F2m', 'add modal fits the viewport or is scrollable (every control reachable)', engine, vp, reachable, j(fit));
    await shot(page, `${engine}-${vp}-addmodal`);
  });
  await finishCtx('twin picker', engine, vp, c);
}

// ── F2 + F4 + F5 + F7: the linear flow with WB + Measure, real OCR, dry-run, fake commit, Next ───
async function tFlow(browser, engine, vp) {
  const c = await newCtx(browser, engine, vp); const { page } = c;
  await guarded('F2', engine, vp, async () => {
    await openApp(page); await openAdd(page); await vendorsLoaded(page);
    const st = async () => ((await page.textContent('#shotStatus')) || '').trim();
    const note = async () => ((await page.textContent('#addNote')) || '').trim();
    const hasCommit = async () => !!(await page.$('#addCommit'));
    const tiles = await page.evaluate(() => ({ fn: document.querySelector('#frontBtn .shot-num').textContent, bn: document.querySelector('#backBtn .shot-num').textContent }));
    rec('F2.0', 'numbered Front(1)/Back(2) tiles', engine, vp, tiles.fn === '1' && tiles.bn === '2', j(tiles));
    const s0 = await st();
    rec('F2.1', 'gate text: empty → "take Photo 1 / FRONT"', engine, vp, /step 1|photo 1/i.test(s0) && /front/i.test(s0), s0);
    await page.selectOption('#addVendor', VENDOR);
    await page.click('#addPreview'); await sleep(200);
    const n0 = await note();
    rec('F2.2', 'create blocked with no photos ("Take Photo 1")', engine, vp, /take photo 1/i.test(n0) && !(await hasCommit()), n0);

    // ── F5 White Balance (warm-cast fake camera → gains must neutralise: R×<1, B×>1)
    await page.click('#addWB'); await page.waitForSelector('#wbModal:not([hidden])', { timeout: 10000 });
    const wbCam = await page.waitForFunction(() => { const v = document.querySelector('#wbVideo'); return v.videoWidth > 0 && v.currentTime > 0.15; }, null, { timeout: 15000 }).then(() => true).catch(() => false);
    rec('F5.0', 'WB live camera opened', engine, vp, wbCam);
    const vb = await page.$eval('#wbVideo', e => { const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; });
    await page.mouse.click(vb.x, vb.y); await sleep(300);
    const wb1 = await page.evaluate(() => ({ ro: document.querySelector('#wbReadout').textContent, ls: JSON.parse(localStorage.getItem('dwWhiteBalance') || 'null'), clr: document.querySelector('#wbClear').hidden }));
    const g = wb1.ls || {};
    rec('F5.1', 'tap-a-white-point sets numeric gains that neutralise the warm cast', engine, vp, /white point set/i.test(wb1.ro) && g.rGain > 0 && g.bGain > 0 && g.rGain < 1 && g.bGain > 1 && !wb1.clr, `${wb1.ro.slice(0, 70)} | ${j(g)}`);
    await page.click('#wbAuto'); await sleep(200);
    const wb2 = await page.evaluate(() => ({ ro: document.querySelector('#wbReadout').textContent, ls: JSON.parse(localStorage.getItem('dwWhiteBalance') || 'null') }));
    rec('F5.2', '✨ Auto (gray-world) sets gains', engine, vp, /auto/i.test(wb2.ro) && wb2.ls && wb2.ls.rGain > 0 && isFinite(wb2.ls.bGain), `${wb2.ro.slice(0, 60)} | ${j(wb2.ls)}`);
    await page.click('#wbClear'); await sleep(200);
    const wb3 = await page.evaluate(() => ({ ro: document.querySelector('#wbReadout').textContent, ls: localStorage.getItem('dwWhiteBalance'), clr: document.querySelector('#wbClear').hidden }));
    rec('F5.3', '↺ Clear resets gains + hides Clear', engine, vp, /cleared/i.test(wb3.ro) && wb3.ls === null && wb3.clr, wb3.ro.slice(0, 60));
    await page.mouse.click(vb.x, vb.y); await sleep(300);      // set again so it persists into Next-item + reload checks
    const wbSet = await page.evaluate(() => JSON.parse(localStorage.getItem('dwWhiteBalance') || 'null'));
    await page.click('#wbClose'); await page.waitForSelector('#wbModal', { state: 'hidden' });
    await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('#homeScreen .home-pill'); await openAdd(page); await vendorsLoaded(page);
    await page.click('#addWB'); await page.waitForSelector('#wbModal:not([hidden])', { timeout: 10000 }); await sleep(300);
    const wb4 = await page.evaluate(() => ({ ro: document.querySelector('#wbReadout').textContent, clr: document.querySelector('#wbClear').hidden, ls: JSON.parse(localStorage.getItem('dwWhiteBalance') || 'null') }));
    rec('F5.4', 'WB gains persist across reload (readout says SET, Clear offered)', engine, vp, /is set/i.test(wb4.ro) && !wb4.clr && wbSet && wb4.ls && wb4.ls.rGain === wbSet.rGain, wb4.ro.slice(0, 70));
    await page.click('#wbClose'); await page.waitForSelector('#wbModal', { state: 'hidden' });

    // ── F4 Measure Depth: set-scale must never produce NaN / 0 ppi; readout shows numeric W/L inches
    await page.click('#addMeasure'); await page.waitForSelector('#measureModal:not([hidden])', { timeout: 10000 });
    const mCam = await page.waitForFunction(() => document.querySelector('#mVideo').videoWidth > 0, null, { timeout: 15000 }).then(() => true).catch(() => false);
    rec('F4.0', 'Measure live camera opened', engine, vp, mCam);
    await page.waitForFunction(() => !document.querySelector('#mSaveCal').hidden, null, { timeout: 5000 });
    const m1 = await page.textContent('#mReadout');
    rec('F4.1', 'first open → set-scale mode with a numeric px/in (not "…", not NaN)', engine, vp, /\d+ px\/in/.test(m1) && !/NaN|Infinity|…/.test(m1), m1.trim());
    await page.click('#mSaveCal'); await sleep(150);
    const m2 = await page.textContent('#mReadout'); const numWL = s => /W\s*[\d.]+"/.test(s) && /L\s*[\d.]+"/.test(s) && !/NaN|Infinity/.test(s);
    rec('F4.2', 'Set scale → measure mode shows numeric W/L inches', engine, vp, numWL(m2) && !/set the scale first/i.test(m2), m2.trim());
    await page.click('#mRecal'); await sleep(150);
    const m3 = await page.textContent('#mReadout');
    rec('F4.3', '↺ Set scale (card) re-enters calibrate with numeric ppi', engine, vp, /\d+ px\/in/.test(m3) && !/NaN|Infinity|…/.test(m3), m3.trim());
    await page.click('#mSaveCal'); await sleep(150);
    const m4 = await page.textContent('#mReadout'); const cal = await page.evaluate(() => JSON.parse(localStorage.getItem('dwMeasureCal') || 'null'));
    rec('F4.4', 'REGRESSION: recal → set-scale never dead-ends; W/L numeric, ppi>0 saved', engine, vp, numWL(m4) && !/set the scale first/i.test(m4) && cal && cal.ppi > 0 && isFinite(cal.ppi), `${m4.trim()} | ppi=${cal && cal.ppi}`);
    await page.click('#mUse'); await page.waitForSelector('#measureModal', { state: 'hidden' });
    const specs = await page.textContent('#addSpecs');
    rec('F4.5', 'Use W×L writes the measured spec into the item', engine, vp, /Measured/.test(specs) && /[\d.]+"\s*×\s*[\d.]+"/.test(specs), specs.trim().slice(0, 60));
    await shot(page, `${vp}-measure`);

    // ── F2 continued: Photo 1 → Photo 2 → mfr gate → dry-run
    await page.setInputFiles('#frontInput', fileArg(FRONT, 'front.jpg'));
    await page.waitForFunction(() => !!_frontPhoto, null, { timeout: 15000 });
    await sleep(300);
    const s1 = await st(); const haveF = await page.$eval('#frontBtn', e => e.classList.contains('have'));
    rec('F2.3', 'after Photo 1: tile ✓ + gate says "take Photo 2 / BACK"', engine, vp, haveF && /step 2|photo 2/i.test(s1) && /back/i.test(s1), s1);
    await page.click('#addPreview'); await sleep(200);
    const n1 = await note();
    rec('F2.4', 'create blocked with one photo ("Take Photo 2")', engine, vp, /take photo 2/i.test(n1) && !(await hasCommit()), n1);
    const cr = await page.evaluate(() => { const e = document.querySelector('#colorRead'); return { show: e.classList.contains('show') && !e.hidden, txt: e.textContent.trim(), col: document.querySelector('#addColor').value }; });
    rec('F6.0', 'colour chip + HEX + colourway name after the front photo (WB applied)', engine, vp, cr.show && /#[0-9a-f]{6}/i.test(cr.txt) && cr.col.length > 0, `${cr.txt.slice(0, 50)} | field=${cr.col}`);
    await page.setInputFiles('#backInput', fileArg(BACK, 'back.jpg'));
    await page.waitForFunction(() => !!_backPhoto && !_autoBusy, null, { timeout: 150000 });
    await sleep(300);
    const after = await page.evaluate(() => ({ st: document.querySelector('#shotStatus').textContent.trim(), mfr: document.querySelector('#addMfr').value, cand: !document.querySelector('#candPick').hidden, nCand: document.querySelectorAll('#candPick .cp-chip').length, vendor: document.querySelector('#addVendor').value, name: document.querySelector('#addName').value, src: _idSource, haveB: document.querySelector('#backBtn').classList.contains('have') }));
    rec('F2.5', 'after Photo 2: tile ✓ + mfr info determined (auto | candidates | manual — never silent)', engine, vp, after.haveB && (after.mfr || after.cand || /type the mfr/i.test(after.st)), j(after));
    rec('F3.real', 'real OCR path outcome (recorded, informational)', engine, vp, true, `src=${after.src} mfr=${after.mfr} cand=${after.nCand} name=${after.name}`);
    if (after.cand && after.nCand) { await page.click('#candPick .cp-chip'); await sleep(200); }
    await page.fill('#addMfr', ''); await sleep(200);
    const s3 = await st();
    rec('F2.6', 'gate: both photos, no mfr# → "Pick or type mfr#"', engine, vp, /pick or type/i.test(s3), s3);
    await page.click('#addPreview'); await sleep(300);
    const n3 = await note();
    rec('F2.7', 'create BLOCKED until an mfr# exists', engine, vp, /pick or type/i.test(n3) && !(await hasCommit()), n3);
    await page.fill('#addMfr', 'ZZTEST-5X'); await sleep(200);
    const s4 = await st();
    rec('F2.8', 'gate OK once mfr# typed ("✓ mfr# … review & Preview")', engine, vp, /✓/.test(s4) && /ZZTEST-5X/.test(s4), s4);
    await page.fill('#addName', 'ZZ 5X TEST');
    await page.click('#addPreview');
    await page.waitForSelector('#addCommit', { timeout: 90000 });
    const pv = await page.evaluate(() => ({ note: document.querySelector('#addNote').textContent.trim(), res: document.querySelector('#addResult').textContent }));
    rec('F2.9', 'DRY-RUN preview: DWAT- DW#, Shopify DRAFT, staging + FileMaker sections, nothing created', engine, vp, /nothing created/i.test(pv.note) && new RegExp(VENDOR_PREFIX + '-\\d+').test(pv.res) && /DRAFT/.test(pv.res) && /new_items_staging/.test(pv.res) && /FileMaker/.test(pv.res), pv.note + ' | ' + (pv.res.match(new RegExp(VENDOR_PREFIX + '-\\d+')) || [''])[0]);
    await shot(page, `${vp}-preview`);

    // ── F7 Next item (commit is intercepted by the harness → fake success → real post-commit UI path)
    const keep0 = await page.evaluate(() => ({ v: document.querySelector('#addVendor').value, cal: localStorage.getItem('dwMeasureCal'), wb: localStorage.getItem('dwWhiteBalance') }));
    await page.click('#addCommit');
    await page.waitForSelector('#addNext', { timeout: 15000 });
    const sc1 = await page.evaluate(() => ({ hid: document.querySelector('#sessCount').hidden, txt: document.querySelector('#sessCount').textContent }));
    rec('F7.0', 'session count increments after create', engine, vp, !sc1.hid && /^1 this session/.test(sc1.txt), sc1.txt);
    await page.click('#addNext'); await sleep(400);
    const nx = await page.evaluate(() => ({ mfr: document.querySelector('#addMfr').value, name: document.querySelector('#addName').value, col: document.querySelector('#addColor').value, f: !!_frontPhoto, b: !!_backPhoto, fh: document.querySelector('#frontBtn').classList.contains('have'), crHidden: document.querySelector('#colorRead').hidden, cand: document.querySelector('#candPick').hidden, st: document.querySelector('#shotStatus').textContent.trim(), v: document.querySelector('#addVendor').value, cal: localStorage.getItem('dwMeasureCal'), wb: localStorage.getItem('dwWhiteBalance'), sess: document.querySelector('#sessCount').textContent, modal: !document.querySelector('#addModal').hidden }));
    rec('F7.1', 'Next item clears photos + mfr + name + colour and jumps to Photo 1', engine, vp, nx.mfr === '' && nx.name === '' && nx.col === '' && !nx.f && !nx.b && !nx.fh && nx.crHidden && nx.cand && /step 1|photo 1/i.test(nx.st) && nx.modal, j({ mfr: nx.mfr, name: nx.name, col: nx.col, f: nx.f, b: nx.b, st: nx.st }));
    rec('F7.2', 'Next item KEEPS sticky vendor + Measure-Depth scale + White-Balance; count kept', engine, vp, nx.v === VENDOR && nx.v === keep0.v && nx.cal === keep0.cal && !!nx.cal && nx.wb === keep0.wb && !!nx.wb && /^1 this session/.test(nx.sess), j({ v: nx.v, cal: !!nx.cal, wb: !!nx.wb, sess: nx.sess }));
  });
  await finishCtx('linear flow', engine, vp, c);
}

// ── F3: mfr# fallback — OCR candidate chips + manual (deterministic via mocked OCR/extract) ───────
async function tChips(browser, engine, vp) {
  const c = await newCtx(browser, engine, vp); const { page } = c;
  await page.route('**/api/extract', r => r.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: true, fields: {}, vendor_matched: null, cost_usd: 0 }) }));
  await page.route('**/api/ocr', r => r.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: true, candidates: ['AB-1234', 'CD-5678', 'AB-1234'], top: 'AB-1234', topStrong: false, fields: {}, available: ['macvision'], cost_usd: 0 }) }));
  await page.route('**/api/identify-multi', r => r.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: false, found: false, visual: [] }) }));
  await guarded('F3', engine, vp, async () => {
    await openApp(page); await openAdd(page); await vendorsLoaded(page); await page.selectOption('#addVendor', VENDOR);
    const both = async () => { await page.setInputFiles('#frontInput', fileArg(FRONT, 'front.jpg')); await page.waitForFunction(() => !!_frontPhoto); await page.setInputFiles('#backInput', fileArg(BACK, 'back.jpg')); await page.waitForFunction(() => !!_backPhoto && !_autoBusy, null, { timeout: 30000 }); await sleep(200); };
    await both();
    const cp = await page.evaluate(() => ({ hid: document.querySelector('#candPick').hidden, chips: [...document.querySelectorAll('#candPick .cp-chip')].map(b => b.textContent), st: document.querySelector('#shotStatus').textContent.trim(), block: document.querySelector('#shotStatus').classList.contains('block'), manual: !!document.querySelector('#candManual') }));
    rec('F3.1', 'no confident read → OCR candidate chips render (deduped) + "none — type it"', engine, vp, !cp.hid && cp.chips.length === 2 && cp.chips.includes('CD-5678') && cp.manual && cp.block, j(cp));
    await page.click('#candPick .cp-chip:has-text("CD-5678")'); await sleep(200);
    const sel = await page.evaluate(() => ({ mfr: document.querySelector('#addMfr').value, hid: document.querySelector('#candPick').hidden, st: document.querySelector('#shotStatus').textContent.trim(), src: _idSource, need: document.querySelector('#addMfr').classList.contains('need') }));
    rec('F3.2', 'tapping a chip fills mfr# (id_source=select), chips clear, gate OK', engine, vp, sel.mfr === 'CD-5678' && sel.hid && /✓/.test(sel.st) && sel.src === 'select' && !sel.need, j(sel));
    await page.fill('#addMfr', ''); await sleep(100); await page.fill('#addMfr', 'ZZ-MANUAL'); await sleep(200);
    const man = await page.evaluate(() => ({ mfr: document.querySelector('#addMfr').value, st: document.querySelector('#shotStatus').textContent.trim() }));
    rec('F3.3', 'manual typing also satisfies the gate', engine, vp, man.mfr === 'ZZ-MANUAL' && /✓/.test(man.st) && /ZZ-MANUAL/.test(man.st), man.st);
    // "✎ none — type it" path
    await page.click('#addClose'); await page.click('#addBtn'); await page.waitForSelector('#addModal:not([hidden])'); await sleep(100);
    await both();
    await page.waitForSelector('#candManual', { timeout: 5000 }); await page.click('#candManual'); await sleep(200);
    const pm = await page.evaluate(() => ({ st: document.querySelector('#shotStatus').textContent.trim(), need: document.querySelector('#addMfr').classList.contains('need'), hid: document.querySelector('#candPick').hidden, src: _idSource, focused: document.activeElement && document.activeElement.id }));
    rec('F3.4', '"none — type it" → manual prompt (field flagged + focused, chips cleared)', engine, vp, /type the mfr/i.test(pm.st) && pm.need && pm.hid && pm.src === 'manual', j(pm));
    await page.fill('#addMfr', 'ZZ-TYPED'); await sleep(200);
    const ok = await page.evaluate(() => ({ st: document.querySelector('#shotStatus').textContent.trim(), need: document.querySelector('#addMfr').classList.contains('need') }));
    rec('F3.5', 'typed mfr# after manual prompt clears the flag + passes the gate', engine, vp, /✓/.test(ok.st) && !ok.need, ok.st);
    await shot(page, `${vp}-chips`);
  });
  await finishCtx('mfr candidates', engine, vp, c);
}

// ── F6: colour hue (no WB in this context) ───────────────────────────────────────────────────────
async function tColour(browser, engine, vp) {
  const c = await newCtx(browser, engine, vp); const { page } = c;
  await guarded('F6', engine, vp, async () => {
    await openApp(page); await openAdd(page); await vendorsLoaded(page);
    await page.setInputFiles('#frontInput', fileArg(FRONT, 'front.jpg'));
    await page.waitForFunction(() => document.querySelector('#colorRead').classList.contains('show'), null, { timeout: 15000 });
    const c1 = await page.evaluate(() => ({ name: (document.querySelector('#colorRead .cr-name') || {}).textContent, hex: (document.querySelector('#colorRead .cr-hex') || {}).textContent, sw: (document.querySelector('#colorRead .cr-sw') || { style: {} }).style.background, field: document.querySelector('#addColor').value, use: !!document.querySelector('#crUse') }));
    rec('F6.1', 'navy swatch → chip + HEX + nearest colourway name; auto-fills the colour field', engine, vp, c1.name === 'Navy' && /^#[0-9a-f]{6}$/i.test(c1.hex) && c1.field === 'Navy' && c1.use && c1.sw, j(c1));
    await page.click('#addClose'); await page.click('#addBtn'); await page.waitForSelector('#addModal:not([hidden])');
    await page.fill('#addColor', 'Custom Red');
    await page.setInputFiles('#frontInput', fileArg(FRONT, 'front.jpg'));
    await page.waitForFunction(() => document.querySelector('#colorRead').classList.contains('show'), null, { timeout: 15000 }); await sleep(200);
    const c2 = await page.evaluate(() => ({ name: (document.querySelector('#colorRead .cr-name') || {}).textContent, field: document.querySelector('#addColor').value }));
    rec('F6.2', 'does NOT overwrite a colour the user already typed (chip still offers it)', engine, vp, c2.field === 'Custom Red' && c2.name === 'Navy', j(c2));
    await page.click('#crUse'); await sleep(100);
    const c3 = await page.$eval('#addColor', e => e.value);
    rec('F6.3', '"Use <name>" button explicitly applies the detected colour', engine, vp, c3 === 'Navy', c3);
  });
  await finishCtx('colour hue', engine, vp, c);
}

// ── F8: mic-every-field (present vs absent Web Speech) ───────────────────────────────────────────
const NO_SR = () => { try { Object.defineProperty(window, 'SpeechRecognition', { value: undefined, configurable: true, writable: true }); Object.defineProperty(window, 'webkitSpeechRecognition', { value: undefined, configurable: true, writable: true }); } catch (e) { /* ignore */ } };
async function tMic(browser, engine, vp) {
  const audit = () => { const fields = [...document.querySelectorAll('#addModal .add-field')].filter(f => f.querySelector('input')); const vis = e => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && getComputedStyle(e).display !== 'none'; };
    return { hasSR: !!(window.SpeechRecognition || window.webkitSpeechRecognition), nFields: fields.length, withMic: fields.filter(f => f.querySelector('.mic-btn')).length, visibleMics: [...document.querySelectorAll('#addModal .mic-btn')].filter(vis).length, offClass: [...document.querySelectorAll('.mic-btn')].filter(b => b.classList.contains('mic-off')).length, total: document.querySelectorAll('.mic-btn').length }; };
  let c = await newCtx(browser, engine, vp);
  await guarded('F8', engine, vp, async () => {
    await openApp(c.page); await openAdd(c.page); const a = await c.page.evaluate(audit);
    if (a.hasSR) rec('F8.1', 'Web Speech present → a mic next to EVERY editable field, all visible', engine, vp, a.nFields >= 10 && a.withMic === a.nFields && a.visibleMics === a.total && a.offClass === 0, j(a));
    else rec('F8.1', 'engine has no Web Speech → every mic HIDDEN (native behaviour, not faked)', engine, vp, a.withMic === a.nFields && a.visibleMics === 0 && a.offClass === a.total, j(a));
    await c.page.click('#addVoice'); await sleep(300);   // must never throw either way
  });
  await finishCtx('mic present', engine, vp, c);
  c = await newCtx(browser, engine, vp, { init: NO_SR });
  await guarded('F8b', engine, vp, async () => {
    await openApp(c.page); await openAdd(c.page); const a = await c.page.evaluate(audit);
    rec('F8.2', 'Web Speech deleted → every mic HIDDEN (not broken), controls still wired', engine, vp, !a.hasSR && a.withMic === a.nFields && a.visibleMics === 0 && a.offClass === a.total && a.total >= 10, j(a));
    await c.page.click('#addVoice'); await sleep(300);
    const t = await c.page.textContent('#addNote');
    rec('F8.3', 'voice-fill without Web Speech degrades gracefully (no error, field prompt shown)', engine, vp, true, (t || '').trim().slice(0, 60));
  });
  await finishCtx('mic absent', engine, vp, c);
}

// ── F9: no body-level horizontal scroll at 390px ─────────────────────────────────────────────────
async function tHscroll(browser, engine, vp) {
  const c = await newCtx(browser, engine, vp); const { page } = c;
  const meas = () => ({ sw: document.scrollingElement.scrollWidth, cw: document.scrollingElement.clientWidth, dw: document.documentElement.scrollWidth, iw: innerWidth });
  const ok = m => m.sw <= m.cw && m.dw <= m.iw;
  await guarded('F9', engine, vp, async () => {
    await openApp(page); const m0 = await page.evaluate(meas);
    await openAdd(page); await vendorsLoaded(page); const m1 = await page.evaluate(meas);
    await page.click('#addClose'); await sleep(100); const m2 = await page.evaluate(meas);
    await page.click('#fbBtn'); await page.waitForSelector('#fbModal:not([hidden])'); const m3 = await page.evaluate(meas); await page.click('#fbClose');
    rec('F9.1', 'no body horizontal scroll: home / add modal / header+grid / ID modal', engine, vp, ok(m0) && ok(m1) && ok(m2) && ok(m3), j({ home: m0, add: m1, grid: m2, fb: m3 }));
    if (engine === 'chromium') {
      await page.route('**/api/ocr', r => r.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: true, candidates: [], top: null, fields: {}, cost_usd: 0 }) }));
      await page.click('#scanBtn'); await page.waitForSelector('#scanlive:not([hidden])', { timeout: 15000 }); await vendorsLoaded(page, '#stickyVendor'); const m4 = await page.evaluate(meas); await page.click('#scanCancel');
      rec('F9.2', 'no body horizontal scroll with the live scanner + sticky bar open', engine, vp, ok(m4), j(m4));
    }
  });
  await finishCtx('h-scroll', engine, vp, c);
}

// ── F12: header controls — the generic /3x click-through can't reach these (it reloads before every
//   button and lands on the intentional onload chooser overlay), so exercise each one for real here.
async function tHeader(browser, engine, vp) {
  const c = await newCtx(browser, engine, vp); const { page } = c;
  await page.route('**/api/ocr', r => r.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: true, candidates: [], top: null, fields: {}, cost_usd: 0 }) }));
  await guarded('F12', engine, vp, async () => {
    await openApp(page); await openAdd(page); await page.click('#addClose');
    const covered = await page.evaluate(() => { const b = document.querySelector('#scanBtn').getBoundingClientRect(); const hit = document.elementFromPoint(b.left + b.width / 2, b.top + b.height / 2); return hit && (hit.id || hit.tagName); });
    rec('F12.0', 'after dismissing the onload chooser the header is hit-testable', engine, vp, covered === 'scanBtn', String(covered));
    await page.click('#homeBtn'); const home = await page.$eval('#homeScreen', e => !e.hidden); await openAdd(page); await page.click('#addClose');
    rec('F12.1', '⌂ reopens the chooser', engine, vp, home);
    const camOk = engine === 'chromium';
    if (camOk) { await page.click('#scanBtn'); const live = await page.waitForSelector('#scanlive:not([hidden])', { timeout: 15000 }).then(() => true).catch(() => false); await page.click('#scanCancel'); rec('F12.2', '🔢 Scan opens the live scanner', engine, vp, live); }
    else rec('F12.2', '🔢 Scan (camera) — Chromium-only', engine, vp, 'skip');
    await page.click('#micBtn'); await sleep(400); const mic = await page.evaluate(() => ({ toast: document.querySelector('#toast').textContent, cls: document.querySelector('#micBtn').className, txt: document.querySelector('#micBtn').textContent }));
    rec('F12.3', '🎤 voice search responds (listening or a graceful "not supported" toast)', engine, vp, /listening|mic|voice/i.test(mic.toast) || /on/.test(mic.cls), j(mic));
    await page.click('#camBtn'); await sleep(100); const c1 = await page.textContent('#camBtn'); await page.click('#camBtn'); await sleep(100); const c2 = await page.textContent('#camBtn');
    rec('F12.4', '📷 Back/Front toggles + persists', engine, vp, /Front/.test(c1) && /Back/.test(c2), `${c1} → ${c2}`);
    const fcP = page.waitForEvent('filechooser', { timeout: 5000 }).then(() => true).catch(() => false); await page.click('#simBtn'); const fc = await fcP;
    rec('F12.5', '🎨 Similar opens the photo picker', engine, vp, fc);
    await page.click('#fbBtn'); const fb = await page.waitForSelector('#fbModal:not([hidden])', { timeout: 5000 }).then(() => true).catch(() => false); await page.click('#fbClose');
    rec('F12.6', '🔎 ID F+B opens the identify modal', engine, vp, fb);
    await page.click('#addBtn'); const ad = await page.waitForSelector('#addModal:not([hidden])', { timeout: 5000 }).then(() => true).catch(() => false); await page.click('#addClose');
    rec('F12.7', '➕ New item opens the add modal', engine, vp, ad);
  });
  await finishCtx('header controls', engine, vp, c);
}

// ── F10 + F11: HTTP contract + pipeline dry-run ──────────────────────────────────────────────────
async function tHttp() {
  const get = async u => { const r = await fetch(u, { headers: { Authorization: AUTH_HDR } }); return { status: r.status, type: r.headers.get('content-type') || '', len: +(r.headers.get('content-length') || 0) }; };
  const m = await get(BASE + '/manifest.webmanifest'), i = await get(BASE + '/apple-touch-icon.png');
  rec('F10.1', '/manifest.webmanifest 200 (manifest+json)', 'http', BASE, m.status === 200 && /manifest|json/.test(m.type), j(m));
  rec('F10.2', '/apple-touch-icon.png 200 (image/png)', 'http', BASE, i.status === 200 && /png/.test(i.type), j(i));
  const front = 'data:image/jpeg;base64,' + fs.readFileSync(FRONT).toString('base64');
  for (const [tag, origin] of [['9899-FM', FMURL], ['9890', LOCAL]]) {
    try {
      const body = { vendor: 'Anna French', vid: 'anna_french', mfr: 'ZZTEST-5X', name: 'ZZ 5X TEST', color: '5x', commit: false, dataUrl: front, photos: [front], require_two: true, front_present: true, back_present: false, id_source: 'manual' };
      const r = await fetch(origin + '/api/create-item', { method: 'POST', headers: { Authorization: AUTH_HDR, 'Content-Type': 'application/json' }, body: j(body) });
      const d = await r.json(); const p = d.preview || {};
      const pass = d.ok === true && d.dryRun === true && !d.product_id && typeof p.dw_sku === 'string' && p.dw_sku.startsWith(VENDOR_PREFIX + '-') && p.shopify && p.shopify.status === 'draft' && p.staging && p.staging.table === 'new_items_staging' && p.filemaker && typeof p.filemaker === 'object';
      const fmFields = p.filemaker && p.filemaker.fieldData ? Object.keys(p.filemaker.fieldData).length : 0;
      rec('F11.' + tag, `dry-run create-item (${origin}) → ok, DWAT- preview, draft, staging + filemaker, nothing created`, 'http', tag, pass, `dw_sku=${p.dw_sku} draft=${p.shopify && p.shopify.status} fm=${p.filemaker && (p.filemaker.db || '-')}/${p.filemaker && (p.filemaker.layout || '-')} fmFields=${fmFields} flags=${j(p.flags || [])}`);
      fs.writeFileSync(path.join(OUT, `s${SWEEP}-dryrun-${tag}.json`), j(d));
    } catch (e) { rec('F11.' + tag, `dry-run create-item (${origin})`, 'http', tag, false, e.message); }
  }
}

// ── B: Batch Shoot Mode (/batch + /api/batch-shot) — TK-11909 ────────────────────────────────────
//   LOCAL-ONLY by construction: batch-shot WRITES files, so these never run against a prod host.
const BATCH_ORIGIN = LOCAL;
const BATCH_SKU = 'ZZ5X-BATCH';
const CFG_TICK_GUESS = 400;   // gate loop tick — long enough for one #calTxt refresh after the camera opens
const batchIsProd = () => /designerwallcoverings|agentabrams|45\.61\.58\.125/.test(BATCH_ORIGIN);
const bAuth = { Authorization: AUTH_HDR };
const bHead = async p => (await fetch(BATCH_ORIGIN + p, { headers: bAuth })).status;
const bBytes = async p => { const r = await fetch(BATCH_ORIGIN + p, { headers: bAuth }); return r.status === 200 ? (await r.arrayBuffer()).byteLength : -1; };
const bPost = async body => { const r = await fetch(BATCH_ORIGIN + '/api/batch-shot', { method: 'POST', headers: { ...bAuth, 'Content-Type': 'application/json' }, body: j(body) }); let d = null; try { d = await r.json(); } catch (e) { /* ignore */ } return { status: r.status, d }; };
const REPO = path.join(__dirname, '..');
function batchCleanup(sess) {   // local repo artifacts only (PHOTOS=repo/photos, DATA=repo/data); prod is never touched
  try { fs.rmSync(path.join(REPO, 'photos', 'batch', sess), { recursive: true, force: true }); } catch (e) { /* ignore */ }
  try { fs.rmSync(path.join(REPO, 'data', 'batch-sessions', sess + '.jsonl'), { force: true }); } catch (e) { /* ignore */ }
}
async function tBatchApi() {
  if (batchIsProd()) { rec('B', 'batch-shot API tests REFUSED — origin looks like prod (writes files)', 'http', BATCH_ORIGIN, false, BATCH_ORIGIN); return; }
  const sess = `5x-${SWEEP}-${Date.now()}`;
  const front = 'data:image/jpeg;base64,' + fs.readFileSync(FRONT).toString('base64');
  const back = 'data:image/jpeg;base64,' + fs.readFileSync(BACK).toString('base64');
  const V = ['original', 'master', 'web'];
  const P = (side, k) => `/photos/batch/${sess}/${BATCH_SKU}_${side ? side + '_' : ''}${k}.jpg`;
  let accepted = 0;
  const track = r => { if (r.d && r.d.ok) accepted++; return r; };   // counts accepted bPost calls (read once, by the B5 manifest-line check)
  try {
    // B0 — page + auth gate
    const a = await fetch(BATCH_ORIGIN + '/batch', { headers: bAuth }), n = await fetch(BATCH_ORIGIN + '/batch');
    const html = a.status === 200 ? await a.text() : '';
    rec('B0', '/batch serves 200 text/html with auth (toolpanel + pv canvas in markup), 401 without', 'http', 'local',
      a.status === 200 && /text\/html/.test(a.headers.get('content-type') || '') && n.status === 401 && /id="tools"/.test(html) && /id="pv"/.test(html) && /id="sideBadge"/.test(html),
      `auth=${a.status} noauth=${n.status} tools=${/id="tools"/.test(html)} pv=${/id="pv"/.test(html)}`);
    // B1 — psku side → <SKU>_psku_<variant>.jpg, read-back 200
    const b1 = track(await bPost({ sessionId: sess, sku: BATCH_SKU, seq: 1, vendor: '5x', collection: 'harness', original: front, master: front, web: front, meta: { side: 'psku', via: '5x' } }));
    const rb1 = await Promise.all(V.map(k => bHead(P('psku', k))));
    rec('B1', 'meta.side=psku → <SKU>_psku_{original,master,web}.jpg written + served', 'http', 'local',
      b1.status === 200 && b1.d && b1.d.ok === true && b1.d.sku === BATCH_SKU && V.every(k => b1.d.paths[k] === P('psku', k)) && rb1.every(s => s === 200) && (b1.d.errors || []).length === 0,
      `status=${b1.status} paths=${j(b1.d && b1.d.paths)} readback=${j(rb1)} errors=${j(b1.d && b1.d.errors)}`);
    const pskuBytesBefore = await bBytes(P('psku', 'original'));
    // B2 — info side, SAME sku, different image → both sets present, psku untouched
    const b2 = track(await bPost({ sessionId: sess, sku: BATCH_SKU, seq: 1, vendor: '5x', collection: 'harness', original: back, master: back, web: back, meta: { side: 'info', via: '5x' } }));
    const rb2 = await Promise.all(V.map(k => bHead(P('info', k))));
    const pskuBytesAfter = await bBytes(P('psku', 'original')), infoBytes = await bBytes(P('info', 'original'));
    rec('B2', 'meta.side=info on the SAME sku → <SKU>_info_*.jpg written; psku files NOT overwritten (both sets, distinct bytes)', 'http', 'local',
      b2.status === 200 && b2.d && b2.d.ok && V.every(k => b2.d.paths[k] === P('info', k)) && rb2.every(s => s === 200) && pskuBytesBefore > 0 && pskuBytesAfter === pskuBytesBefore && infoBytes > 0 && infoBytes !== pskuBytesBefore,
      `info=${j(rb2)} psku_orig ${pskuBytesBefore}→${pskuBytesAfter}B info_orig ${infoBytes}B`);
    // B3 — unsided → legacy naming
    const b3 = track(await bPost({ sessionId: sess, sku: BATCH_SKU, seq: 2, original: front, master: front, web: front, meta: { via: '5x', single: true, side: null } }));
    const rb3 = await Promise.all(V.map(k => bHead(P('', k))));
    rec('B3', 'no side (single shot) → legacy <SKU>_{original,master,web}.jpg (back-compat)', 'http', 'local',
      b3.status === 200 && b3.d && b3.d.ok && V.every(k => b3.d.paths[k] === P('', k)) && rb3.every(s => s === 200), `paths=${j(b3.d && b3.d.paths)} readback=${j(rb3)}`);
    // B4 — unknown / hostile side → legacy naming, never in the filename
    const b4a = track(await bPost({ sessionId: sess, sku: BATCH_SKU + '-B4', seq: 3, web: front, meta: { side: 'zzz' } }));
    const b4b = track(await bPost({ sessionId: sess, sku: BATCH_SKU + '-B4', seq: 3, web: front, meta: { side: '../../evil' } }));
    const b4c = track(await bPost({ sessionId: sess, sku: BATCH_SKU + '-B4', seq: 3, web: front, meta: { side: 42 } }));
    const legacy = `/photos/batch/${sess}/${BATCH_SKU}-B4_web.jpg`;
    const okB4 = [b4a, b4b, b4c].every(r => r.status === 200 && r.d && r.d.ok && r.d.paths.web === legacy) && (await bHead(legacy)) === 200 && ![b4a, b4b, b4c].some(r => /zzz|evil|\.\.|42_/.test(j(r.d.paths)));
    rec('B4', 'unknown side (zzz / ../../evil / 42) → falls back to legacy naming; side never reaches the filename', 'http', 'local', okB4, `${j(b4a.d && b4a.d.paths)} | ${j(b4b.d && b4b.d.paths)} | ${j(b4c.d && b4c.d.paths)}`);
    // B5 — idempotent re-POST of the same side: same paths, still 200, manifest appends exactly one line per accepted POST
    const b5 = track(await bPost({ sessionId: sess, sku: BATCH_SKU, seq: 1, original: front, master: front, web: front, meta: { side: 'psku' } }));
    const manifest = path.join(REPO, 'data', 'batch-sessions', sess + '.jsonl');
    let lines = null, parsed = true, sides = [];
    if (fs.existsSync(manifest)) { lines = fs.readFileSync(manifest, 'utf8').split('\n').filter(Boolean); for (const l of lines) { try { const o = JSON.parse(l); sides.push(o.meta && o.meta.side); if (o.sessionId !== sess) parsed = false; } catch (e) { parsed = false; } } }
    rec('B5', 're-shoot same sku+side → 200 ok, identical paths (overwrite, no dup files); manifest = one clean JSON line per accepted POST', 'http', 'local',
      b5.status === 200 && b5.d && b5.d.ok && j(b5.d.paths) === j(b1.d && b1.d.paths) && (lines === null ? false : (lines.length === accepted && parsed)),
      `paths_same=${j(b5.d && b5.d.paths) === j(b1.d && b1.d.paths)} manifest=${lines === null ? 'MISSING at ' + manifest : lines.length + ' lines / ' + accepted + ' accepted'} sides=${j(sides)}`);
    if (lines === null) rec('B5m', 'manifest not found under repo data/batch-sessions (DATA dir differs?) — informational', 'http', 'local', 'skip', manifest);
    // B6 — degenerate blobs + traversal
    const tiny = 'data:image/jpeg;base64,' + Buffer.from('tiny').toString('base64');
    const b6a = await bPost({ sessionId: sess, sku: BATCH_SKU + '-B6', original: tiny, master: front, meta: { side: 'psku' } });   // not tracked: `accepted` is only consumed by the B5 check above
    const b6b = await bPost({ sessionId: sess, sku: BATCH_SKU + '-B6b', original: tiny });
    const b6c = await bPost({ sessionId: '../../../etc', sku: '../../server.js', web: front });
    const b6d = await bPost({ sessionId: '', sku: BATCH_SKU, web: front });
    const b6e = await bPost({ sessionId: sess, sku: BATCH_SKU });
    const trav1 = await bHead('/photos/batch/%2e%2e/%2e%2e/server.js'), trav2 = await bHead('/photos/%2e%2e/server.js'), trav3 = await bHead('/photos/batch/' + sess + '/%2e%2e/%2e%2e/%2e%2e/package.json');
    const c6 = b6c.d || {}; const c6paths = j(c6.paths || {});
    rec('B6', 'degenerate <200B blob rejected per-variant (master still saved); all-degenerate → 500 ok:false; traversal in session/sku sanitized + /photos/.. → 404; empty/no-variant → 400', 'http', 'local',
      b6a.status === 200 && b6a.d && b6a.d.ok && !b6a.d.paths.original && b6a.d.paths.master && (b6a.d.errors || []).some(e => /^original: size/.test(e)) &&
      b6b.status === 500 && b6b.d && b6b.d.ok === false &&
      b6c.status === 200 && /^\/photos\/batch\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+_web\.jpg$/.test((c6.paths || {}).web || '') && !((c6.paths || {}).web || '').split('/').some(seg => seg === '.' || seg === '..') && path.posix.normalize((c6.paths || {}).web || '/x').startsWith('/photos/batch/') &&
      b6d.status === 400 && b6e.status === 400 && trav1 === 404 && trav2 === 404 && trav3 === 404,
      `b6a=${b6a.status}/${j(b6a.d && b6a.d.errors)} b6b=${b6b.status} b6c=${b6c.status}:${c6paths} b6d=${b6d.status} b6e=${b6e.status} trav=${trav1}/${trav2}/${trav3}`);
    // hostile-session cleanup (sanitized name lands under photos/batch/ as a dotted dir)
    if (c6.paths && c6.paths.web) { const m = c6.paths.web.match(/^\/photos\/batch\/([^/]+)\//); if (m) batchCleanup(m[1]); }
  } catch (e) { rec('B', 'batch API block threw', 'http', 'local', false, (e.message || String(e)).slice(0, 300)); }
  finally { batchCleanup(sess); }
}
async function tBatchUi(browser, engine, vp) {
  if (engine !== 'chromium') { rec('B7', 'batch shoot UI (needs fake camera) — Chromium-only', engine, vp, 'skip', 'no fake camera in this engine'); return; }
  if (batchIsProd()) { rec('B7', 'batch UI test REFUSED — origin looks like prod', engine, vp, false, BATCH_ORIGIN); return; }
  const sid = '5x-ui-' + Date.now();
  const sess = { id: sid, vendor: '5x Harness', collection: 'ui', date: '2026-09-19', total: 3, n: 0 };
  // seed ONCE per tab (sessionStorage guard) so a reload keeps whatever the app persisted (tune / toolpanel)
  const init = `(function(){ try { if (sessionStorage.getItem('fivex_seeded')) return; sessionStorage.setItem('fivex_seeded','1'); } catch (e) { return; }
    localStorage.setItem('dwbatch.session', ${JSON.stringify(JSON.stringify(sess))});
    /* NO synthetic dwbatch.cal seed: a fake cal never matches the fake camera's reference band, so the
       app (correctly) fires DRIFT + the RE-CALIBRATE overlay and blocks every click. The harness instead
       calibrates through the REAL UI (#cSave) on the first resume, so the stored cal is self-consistent. */
    localStorage.removeItem('dwbatch.cal.${sid}'); localStorage.removeItem('dwbatch.tune'); localStorage.removeItem('dwbatch.toolpanel'); })();`;
  const c = await newCtx(browser, engine, vp, { init }); const { page, ctx } = c;
  const writes = [], ocr = [];
  await ctx.route('**/api/batch-shot', r => { writes.push(1); return r.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: true, sku: 'FAKE', paths: {}, errors: [] }) }); });
  await ctx.route('**/api/identify**', r => { ocr.push(1); return r.fulfill({ status: 200, contentType: 'application/json', body: j({ ok: true, sku: null, confidence: 0, cost_usd: 0 }) }); });
  // Resume → either the CALIB view (no stored cal → app falls through to enterCalib, per 5f0e9fc) or
  // straight to SHOOT (stored cal loaded). Returns which path the app took so B7.10/B7.11 can assert it.
  const resumePaths = [];
  const resume = async () => {
    await page.goto(BATCH_ORIGIN + '/batch', { waitUntil: 'domcontentloaded' });
    await page.waitForSelector('#bResume:not([hidden])', { timeout: 15000 }); await page.click('#bResume');
    await page.waitForFunction(() => !document.querySelector('#vShoot').hidden || !document.querySelector('#vCalib').hidden, null, { timeout: 10000 });
    const inCalib = await page.$eval('#vCalib', e => !e.hidden);
    resumePaths.push(inCalib ? 'calib' : 'shoot');
    if (inCalib) {   // real calibration with the fake camera: start cam → wait for frames → Capture calibration (#cSave)
      if (await page.$eval('#cGate', e => !e.hidden)) { await page.click('#cStartCam'); }
      await page.waitForFunction(() => { const v = document.querySelector('#cv'); return v && v.videoWidth > 0 && document.querySelector('#cGate').hidden; }, null, { timeout: 15000 });
      await sleep(300); await page.click('#cSave');
      await page.waitForSelector('#vShoot:not([hidden])', { timeout: 10000 });
    }
    if (await page.$eval('#gate', e => !e.hidden)) { await page.click('#startCam'); await page.waitForFunction(() => document.querySelector('#gate').hidden, null, { timeout: 15000 }).catch(() => {}); }
    // if a stale/mismatched cal ever trips DRIFT here the RE-CAL overlay would block every click — surface it, never hang
    await sleep(500);
    if (await page.$eval('#bigRecal', e => !e.hidden)) throw new Error('RE-CALIBRATE overlay shown after resume: ' + await page.$eval('#recalMeta', e => e.textContent));
  };
  await guarded('B7', engine, vp, async () => {
    await resume();
    const s = await page.evaluate(() => { const q = s => document.querySelector(s); const vis = e => { if (!e) return false; const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && getComputedStyle(e).display !== 'none'; };
      const tp = q('#tools'), r = tp && tp.getBoundingClientRect();
      return { shoot: !q('#vShoot').hidden, tools: vis(tp), head: vis(q('#tpHead')), tog: vis(q('#tpToggle')), togTxt: q('#tpToggle') && q('#tpToggle').textContent.trim(),
        sliders: ['sBright', 'sWarm', 'sHue'].map(id => q('#' + id) && q('#' + id).value), labels: ['vBright', 'vWarm', 'vHue'].map(id => q('#' + id) && q('#' + id).textContent.trim()),
        reset: vis(q('#sReset')), autoFire: q('#autoFire') && q('#autoFire').checked, badge: q('#sideBadge') && q('#sideBadge').textContent.trim(), pv: !!q('canvas#pv.pv'), ov: !!q('canvas#ov.ov'),
        collapsed: tp && tp.classList.contains('collapsed'), sku: !!q('#skuInput'), fields: ['fVendorField', 'fPattern', 'fColor'].every(id => !!q('#' + id)), shutter: vis(q('#shutter')),
        onScreen: r && r.left >= 0 && r.right <= innerWidth + 1 && r.bottom <= innerHeight + 1 && r.top >= 0, centered: r && Math.abs((r.left + r.right) / 2 - innerWidth / 2) < 8,
        stageH: q('#vShoot').getBoundingClientRect().height, ih: innerHeight, gateShown: !q('#gate').hidden, hBatch: q('#hBatch') && q('#hBatch').textContent.trim() }; });
    rec('B7.0', 'Resume → shoot view; movable tools panel renders center-bottom, on-screen, expanded', engine, vp, s.shoot && s.tools && s.head && s.tog && s.togTxt === '▾' && !s.collapsed && s.onScreen && s.centered && /5x Harness/.test(s.hBatch), j({ shoot: s.shoot, tools: s.tools, tog: s.togTxt, onScreen: s.onScreen, centered: s.centered, hBatch: s.hBatch }));
    rec('B7.1', 'Brightness/Warmth/Hue sliders at 0 (+ labels), Reset present; SKU + vendor/pattern/colour fields + shutter in the panel', engine, vp, s.sliders.every(v => v === '0') && s.labels.every(v => v === '0') && s.reset && s.sku && s.fields && s.shutter, j({ sliders: s.sliders, labels: s.labels, reset: s.reset, fields: s.fields }));
    rec('B7.2', 'Auto-snap is UNCHECKED by default (operator-paced)', engine, vp, s.autoFire === false, `checked=${s.autoFire}`);
    rec('B7.3', 'side badge starts on "PSku Photo"; WYSIWYG preview canvas #pv + overlay #ov both present', engine, vp, /^PSku/.test(s.badge) && s.pv && s.ov, j({ badge: s.badge, pv: s.pv, ov: s.ov }));
    rec('B7.4', 'shoot view fills the viewport (100dvh)', engine, vp, Math.abs(s.stageH - s.ih) <= 2, `stage=${s.stageH} inner=${s.ih}`);
    // fake camera → preview canvas gets frames
    const cam = await page.waitForFunction(() => document.querySelector('#gate').hidden && (() => { const v = document.querySelector('#v'); return v && v.videoWidth > 0; })(), null, { timeout: 15000 }).then(() => true).catch(() => false);
    await sleep(600);
    const pv = await page.evaluate(() => { const c = document.querySelector('#pv'); let nonzero = false; try { const x = c.getContext('2d'); const d = x.getImageData(0, 0, Math.min(8, c.width || 0), Math.min(8, c.height || 0)).data; nonzero = Array.from(d).some((v, i) => i % 4 !== 3 && v > 0); } catch (e) { /* ignore */ } return { w: c.width, h: c.height, nonzero }; });
    rec('B7.5', 'fake camera opens → live WYSIWYG preview canvas is sized + painting frames', engine, vp, cam && pv.w > 0 && pv.h > 0 && pv.nonzero, j({ cam, ...pv }));
    await shot(page, `batch-shoot-${vp}`);
    // collapse / expand
    await page.click('#tpToggle'); await sleep(80);
    const col = await page.evaluate(() => ({ c: document.querySelector('#tools').classList.contains('collapsed'), t: document.querySelector('#tpToggle').textContent.trim(), bodyShown: getComputedStyle(document.querySelector('#tpBody')).display !== 'none', ls: JSON.parse(localStorage.getItem('dwbatch.toolpanel') || '{}') }));
    await page.click('#tpToggle'); await sleep(80);
    const exp = await page.evaluate(() => ({ c: document.querySelector('#tools').classList.contains('collapsed'), t: document.querySelector('#tpToggle').textContent.trim(), bodyShown: getComputedStyle(document.querySelector('#tpBody')).display !== 'none' }));
    rec('B7.6', 'panel toggle collapses (body hidden, ▸, persisted) and re-expands (▾)', engine, vp, col.c && col.t === '▸' && !col.bodyShown && col.ls.collapsed === true && !exp.c && exp.t === '▾' && exp.bodyShown, j({ col, exp }));
    // slider → persists; survives reload; reset clears
    await page.$eval('#sBright', e => { e.value = '25'; e.dispatchEvent(new Event('input', { bubbles: true })); });
    await page.$eval('#sWarm', e => { e.value = '-40'; e.dispatchEvent(new Event('input', { bubbles: true })); });
    await sleep(60);
    const t1 = await page.evaluate(() => ({ ls: JSON.parse(localStorage.getItem('dwbatch.tune') || 'null'), lb: document.querySelector('#vBright').textContent.trim(), lw: document.querySelector('#vWarm').textContent.trim() }));
    await resume();
    const t2 = await page.evaluate(() => ({ b: document.querySelector('#sBright').value, w: document.querySelector('#sWarm').value, h: document.querySelector('#sHue').value, lb: document.querySelector('#vBright').textContent.trim() }));
    rec('B7.7', 'slider changes persist to localStorage dwbatch.tune and survive a reload (sticky to the next photo)', engine, vp, t1.ls && t1.ls.bright === 25 && t1.ls.warm === -40 && t1.lb === '25' && t1.lw === '-40' && t2.b === '25' && t2.w === '-40' && t2.h === '0' && t2.lb === '25', j({ t1, t2 }));
    await page.click('#sReset'); await sleep(60);
    const t3 = await page.evaluate(() => ({ ls: JSON.parse(localStorage.getItem('dwbatch.tune') || 'null'), v: ['sBright', 'sWarm', 'sHue'].map(id => document.querySelector('#' + id).value) }));
    rec('B7.8', '↺ Reset colour → all sliders 0 and persisted 0', engine, vp, t3.ls && t3.ls.bright === 0 && t3.ls.warm === 0 && t3.ls.hue === 0 && t3.v.every(v => v === '0'), j(t3));
    // Resume path: the calibration IS persisted (dwbatch.cal.<id>), but does #bResume load it? (bStart does.)
    await sleep(CFG_TICK_GUESS);
    const calTxt = await page.evaluate(() => ({ chip: (document.querySelector('#calTxt') || {}).textContent, cls: (document.querySelector('#dCal') || {}).className }));
    const calStored = await page.evaluate(sid => !!localStorage.getItem('dwbatch.cal.' + sid), sid);
    rec('B7.10', 'Resume ("Pick up where I left off") reloads the persisted gray-card calibration → straight to SHOOT, CAL chip = ok (not none), no DRIFT overlay', engine, vp,
      /^ok$/i.test((calTxt.chip || '').trim()) && calStored && resumePaths.length >= 2 && resumePaths.slice(1).every(pth => pth === 'shoot'), j({ ...calTxt, calStored, resumePaths }));
    rec('B7.11', 'Resume with NO stored calibration falls through to the CALIB view (same as Start) and #cSave stores a self-consistent cal', engine, vp, resumePaths[0] === 'calib' && calStored, j({ first: resumePaths[0], calStored }));
    rec('B7.9', 'harness guard: no real /api/batch-shot writes, no /api/identify (Gemini) calls reached the server from the UI test', engine, vp, true, `batch-shot intercepted=${writes.length} identify intercepted=${ocr.length}`);
  });
  await finishCtx('batch shoot UI', engine, vp, c);
}

// ── main ─────────────────────────────────────────────────────────────────────────────────────────
(async () => {
  const t0 = Date.now();
  makeY4M(Y4M);
  const chromium = await pw.chromium.launch({ headless: true, args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', `--use-file-for-fake-video-capture=${Y4M}`] });
  await makeJpegs(chromium);
  const want = id => !ONLY || ONLY.includes(id);
  if (want('http')) await tHttp();
  if (want('batch')) { await tBatchApi(); await tBatchUi(chromium, 'chromium', 'ipad'); await tBatchUi(chromium, 'chromium', 'iphone'); }
  for (const vp of ['ipad', 'iphone']) {
    if (want('vendor')) await tVendorBar(chromium, 'chromium', vp);
    if (want('twin')) await tTwinPicker(chromium, 'chromium', vp);
    if (want('flow')) await tFlow(chromium, 'chromium', vp);
    if (want('colour')) await tColour(chromium, 'chromium', vp);
  }
  if (want('chips')) await tChips(chromium, 'chromium', 'ipad');
  if (want('mic')) await tMic(chromium, 'chromium', 'ipad');
  if (want('hscroll')) await tHscroll(chromium, 'chromium', 'iphone');
  if (want('header')) { await tHeader(chromium, 'chromium', 'ipad'); await tHeader(chromium, 'chromium', 'iphone'); }
  await chromium.close();
  for (const engine of ['webkit', 'firefox']) {
    if (!want(engine)) continue;
    let b; try { b = await pw[engine].launch({ headless: true }); } catch (e) { rec('ENGINE', `${engine} launch`, engine, '-', false, e.message.split('\n')[0]); continue; }
    for (const vp of ['ipad', 'iphone']) await tTwinPicker(b, engine, vp);
    await tMic(b, engine, 'ipad');
    await tHscroll(b, engine, 'iphone');
    await tHeader(b, engine, 'iphone');
    if (want('batch')) await tBatchUi(b, engine, 'ipad');
    rec('CAM', 'camera-dependent checks (F1 sticky-over-camera, F4 Measure, F5 WB, F7 flow) — no fake camera in this engine', engine, '-', 'skip', 'Chromium-only by design');
    await b.close();
  }
  const fails = results.filter(r => r.pass === false), skips = results.filter(r => r.pass === 'skip');
  const summary = { sweep: SWEEP, base: BASE, local: LOCAL, fm: FMURL, at: new Date().toISOString(), secs: Math.round((Date.now() - t0) / 1000), total: results.length, passed: results.length - fails.length - skips.length, failed: fails.length, skipped: skips.length, gemini_cost_usd: +cost.toFixed(4), gemini_calls: costLog.length, results };
  fs.writeFileSync(path.join(OUT, `features-sweep${SWEEP}.json`), j(summary));
  console.log(`\n== sweep ${SWEEP}: ${summary.passed}/${summary.total - summary.skipped} passed · ${summary.failed} failed · ${summary.skipped} skipped · Gemini $${summary.gemini_cost_usd} (${costLog.length} calls) · ${summary.secs}s`);
  if (fails.length) { console.log('FAILS:'); fails.forEach(f => console.log(`  - [${f.engine}/${f.vp}] ${f.id} ${f.name} — ${f.detail}`)); }
  process.exit(fails.length ? 1 : 0);
})().catch(e => { console.error('HARNESS CRASH', e); process.exit(2); });