← back to Dw Photo Capture

scripts/e2e-front-back-display.cjs

202 lines

// Usage (TK-12228): start a test server, then run this against it:
//   mkdir -p /tmp/dwp-e2e && PORT=9987 DWP_TEST_DIR=/tmp/dwp-e2e node server.js --test &
//   TESTROOT=/tmp/dwp-e2e OUT=/tmp/dwp-e2e/shots BASE=http://127.0.0.1:9987 node scripts/e2e-front-back-display.cjs
//   (PW=<path to a playwright package> if not the default; exit 0 = all pass, 1 = a check failed, 2 = crash)
// TK-12228 E2E: every captured photo (front + back) displays — preview, result, history, batch, remote cam.
// Drives the REAL app (server started with --test: Shopify/dw_unified/FM writes + paid OCR stubbed).
const PWMOD = require(process.env.PW || '/Users/macstudio3/Projects/carnegie-internal/node_modules/playwright');
// ENGINE=chromium (default) | webkit | firefox — each with its own fake-camera mechanism:
//   chromium: --use-fake-device-for-media-stream; webkit: Playwright's built-in mock capture device;
//   firefox: media.navigator.streams.fake pref. Only chromium understands the 'camera' permission.
const ENGINE = process.env.ENGINE || 'chromium';
const LAUNCH = {
  chromium: { headless: true, args: ['--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream'] },
  webkit: { headless: true },
  firefox: { headless: true, firefoxUserPrefs: { 'media.navigator.streams.fake': true, 'media.navigator.permission.disabled': true } },
}[ENGINE];
const fs = require('fs'), path = require('path');
const BASE = process.env.BASE || 'http://127.0.0.1:9987';
const OUT = process.env.OUT || path.join(process.env.TESTROOT || '.', 'shots');
const TESTROOT = process.env.TESTROOT; if (!TESTROOT) { console.error('TESTROOT (= the server DWP_TEST_DIR) required'); process.exit(2); }
fs.mkdirSync(OUT, { recursive: true });
const AUTH = { username: 'admin', password: 'DW2024!' };
const results = []; let failed = 0;
function check(name, cond, detail) { results.push({ name, ok: !!cond, detail }); if (!cond) failed++; console.log((cond ? 'PASS ' : 'FAIL ') + name + (detail ? ' — ' + JSON.stringify(detail) : '')); }
// every <img> matched must be fully loaded with real pixels
async function imgs(page, sel) {
  // history thumbs are loading="lazy": WebKit/Firefox (small lazy margins) never fetch off-screen ones,
  // so bring each into view first — the assertion below still requires EVERY one to have real pixels.
  for (const h of await page.$$(sel)) await h.scrollIntoViewIfNeeded().catch(() => {});
  await page.waitForFunction(s => { const l = [...document.querySelectorAll(s)]; return l.length && l.every(i => i.complete); }, sel, { timeout: 15000 }).catch(() => {});
  return page.$$eval(sel, l => l.map(i => ({ side: i.dataset.side || i.alt, w: i.naturalWidth, h: i.naturalHeight, src: (i.currentSrc || i.src).slice(0, 80) })));
}
const allLoaded = l => l.length > 0 && l.every(x => x.w > 0 && x.h > 0);

(async () => {
  const browser = await PWMOD[ENGINE].launch(LAUNCH);
  const ctx = await browser.newContext(Object.assign({ httpCredentials: AUTH, viewport: { width: 430, height: 932 } }, ENGINE === 'chromium' ? { permissions: ['camera', 'microphone'] } : {}));
  console.log('engine:', ENGINE, browser.version());
  const page = await ctx.newPage();
  page.on('pageerror', e => console.log('[pageerror]', e.message));
  // HUNG_CAMERA=1: getUserMedia never settles (iOS denied-but-not-rejected prompt / camera held by FaceTime) —
  // deterministically drives the timeout → snap-camera (<input capture> → shotAdd) path in ANY engine.
  if (process.env.HUNG_CAMERA === '1') await page.addInitScript(() => { const hang = () => new Promise(() => {});   // prototype too: WebKit exposes mediaDevices lazily
    if (window.MediaDevices) MediaDevices.prototype.getUserMedia = hang; if (navigator.mediaDevices) navigator.mediaDevices.getUserMedia = hang; });

  // ── 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' && process.env.HUNG_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"]');
  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');
    }
  }
  if (process.env.HUNG_CAMERA === '1') check('HUNG_CAMERA run actually took the timeout → SNAP path (camera stub effective)', !opened);
  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);
  await page.locator('.two-shot').screenshot({ path: path.join(OUT, '02-tiles-front-back.png') });

  // vendor + mfr (OCR is stubbed → manual entry path)
  await page.waitForFunction(() => document.querySelectorAll('#addVendor option').length > 1, null, { timeout: 10000 });
  await page.selectOption('#addVendor', 'Test Vendor');
  await page.fill('#addMfr', 'TK12228A');
  await page.click('#addPreview');
  await page.waitForSelector('.ph-pair[data-set="preview"] img', { timeout: 15000 });
  const pv = await imgs(page, '.ph-pair[data-set="preview"] img');
  check('Preview view: FRONT + BACK render', pv.map(x => x.side).join(',') === 'front,back' && allLoaded(pv), pv);
  const pvCount = await page.$eval('#addResult .fb-hit small', e => e.textContent);
  check('Preview counts the front+back photos (was "0 photo(s)")', /2 photo\(s\)/.test(pvCount), pvCount);
  await page.locator('#addResult').screenshot({ path: path.join(OUT, '03-preview-front-back.png') });

  await page.click('#addCommit');
  await page.waitForSelector('.ph-pair[data-set="created"] img', { timeout: 20000 });
  const cr = await imgs(page, '.ph-pair[data-set="created"] img');
  check('Created result: FRONT + BACK render from server /photos/ URLs', cr.map(x => x.side).join(',') === 'front,back' && allLoaded(cr) && cr.every(x => x.src.startsWith(BASE + '/photos/')), cr);
  await page.locator('#addResult').screenshot({ path: path.join(OUT, '04-created-front-back.png') });
  await page.click('.ph-pair[data-set="created"] figure:nth-child(2)');
  await page.waitForSelector('#photoLb:not([hidden])');
  const lb = await imgs(page, '#photoLbImg');
  check('Created result: tap BACK → full-size lightbox', allLoaded(lb) && /back/i.test(await page.$eval('#photoLbCap', e => e.textContent)), lb);
  await page.screenshot({ path: path.join(OUT, '05-lightbox-back.png') });
  await page.click('#photoLbClose');

  // files really persisted on disk
  const disk = fs.readdirSync(path.join(TESTROOT, 'photos')).filter(f => /^TEST-TK12228A-(front|back)-/.test(f));
  check('both photos persisted under photos/ with side in filename', disk.some(f => /-front-/.test(f)) && disk.some(f => /-back-/.test(f)), disk);

  // ── 2. Update mode (/api/photos) + batch (/api/batch-shot) + legacy vendor-log via API, then history ──
  const jpgFront = await page.evaluate(() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; const g = c.getContext('2d'); g.fillStyle = '#3a7'; g.fillRect(0, 0, 320, 240); g.fillStyle = '#fff'; g.font = '40px sans-serif'; g.fillText('FRONT', 90, 130); return c.toDataURL('image/jpeg', 0.9); });
  const jpgBack = await page.evaluate(() => { const c = document.createElement('canvas'); c.width = 320; c.height = 240; const g = c.getContext('2d'); g.fillStyle = '#a53'; g.fillRect(0, 0, 320, 240); g.fillStyle = '#fff'; g.font = '40px sans-serif'; g.fillText('BACK', 100, 130); return c.toDataURL('image/jpeg', 0.9); });
  const post = (u, b) => page.evaluate(async ([u, b]) => (await fetch(u, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(b) })).json(), [u, b]);
  const up = await post('/api/photos', { dw_sku: 'UPD-777', product_id: 123, dataUrls: [jpgFront, jpgBack], sides: ['front', 'back'] });
  check('/api/photos returns both stored photos with sides', up.photos && up.photos.map(p => p.side).join(',') === 'front,back', up);
  const b1 = await post('/api/batch-shot', { sessionId: 'e2e-sess', sku: 'BAT-1', seq: 1, vendor: 'Test Vendor', web: jpgFront, meta: { side: 'psku' } });
  const b2 = await post('/api/batch-shot', { sessionId: 'e2e-sess', sku: 'BAT-1', seq: 1, vendor: 'Test Vendor', web: jpgBack, meta: { side: 'info' } });
  check('/api/batch-shot accepted psku + info', b1.ok && b2.ok, { b1: b1.paths, b2: b2.paths });
  // legacy: a pre-TK-12228 create-item (vendor-log only, -0/-1 tags) must still show front + back in history
  const vdir = path.join(TESTROOT, 'photos', 'vendors', 'Legacy Co'); fs.mkdirSync(vdir, { recursive: true });
  const t0 = Date.now() - 86400000;
  [['0', jpgFront], ['1', jpgBack]].forEach(([tag, d]) => fs.writeFileSync(path.join(vdir, `LEG-5-${t0}-${tag}.jpg`), Buffer.from(d.split(',')[1], 'base64')));
  fs.appendFileSync(path.join(TESTROOT, 'data', 'vendor-photos.jsonl'), [0, 1].map(tag => JSON.stringify({ at: new Date(t0 + tag).toISOString(), vendor: 'Legacy Co', dw_sku: 'LEG-5', source: 'create-item', path: `/photos/vendors/Legacy%20Co/LEG-5-${t0}-${tag}.jpg` })).join('\n') + '\n');

  // ── 3. /captures history grid ──
  await page.goto(BASE + '/captures', { waitUntil: 'domcontentloaded' });
  await page.waitForSelector('.card');
  const cards = await page.$$eval('.card', l => l.map(c => ({ sku: c.querySelector('.sku').textContent.split(' ')[0], sides: [...c.querySelectorAll('.pair img')].map(i => i.dataset.side), when: c.querySelector('.when').textContent, title: c.querySelector('.when').title })));
  const want = ['TEST-TK12228A', 'UPD-777', 'BAT-1', 'LEG-5'];
  for (const sku of want) { const c = cards.find(x => x.sku === sku); check(`history card ${sku} shows front+back`, c && c.sides.join(',') === 'front,back', c); }
  const hist = await imgs(page, '.card .pair img');
  check('history: every front/back thumbnail has pixels (naturalWidth>0)', hist.length >= 8 && allLoaded(hist), { n: hist.length, bad: hist.filter(x => !x.w) });
  const c0 = cards[0];
  check('history card shows created date+time chip with ISO title', /\d{4}/.test(c0.when) && /\d:\d\d/.test(c0.when) && /T.*Z$/.test(c0.title), c0);
  await page.screenshot({ path: path.join(OUT, '06-history-grid.png') });   // viewport only: an accumulated history exceeds Firefox's 32767px full-page limit
  // sort + density persist across reload
  await page.selectOption('#sort', 'sku'); await page.fill('#dens', '200'); await page.dispatchEvent('#dens', 'input');
  await page.reload({ waitUntil: 'domcontentloaded' }); await page.waitForSelector('.card');
  const persisted = await page.evaluate(() => ({ sort: document.querySelector('#sort').value, dens: document.querySelector('#dens').value, first: document.querySelector('.card .sku').textContent }));
  check('history sort + density persist in localStorage', persisted.sort === 'sku' && persisted.dens === '200', persisted);
  await page.click('.card .pair figure[data-side], .card .pair figure');
  await page.waitForSelector('#lb:not([hidden])');
  check('history: click thumbnail → enlarge', allLoaded(await imgs(page, '#lbImg')));
  await page.screenshot({ path: path.join(OUT, '07-history-lightbox.png') });

  // ── 4. batch.html thumbs (unit-level: drive the page's own showThumb with real JPEG blobs) ──
  await page.goto(BASE + '/batch', { waitUntil: 'domcontentloaded' });
  await page.evaluate(async ([f, b]) => {
    const blob = async d => (await fetch(d)).blob();
    document.getElementById('vShoot').hidden = false;
    showThumb('front', await blob(f)); showThumb('back', await blob(b));
  }, [jpgFront, jpgBack]);
  const bt = await imgs(page, '#thumb, #thumbB');
  check('batch.html: last sample FRONT + BACK thumbs both render', bt.length === 2 && allLoaded(bt), bt);
  await page.locator('.thumbs').screenshot({ path: path.join(OUT, '08-batch-thumbs.png') });

  // ── 5. cam.html remote shutter: shot shows on the phone from the server-stored copy ──
  const cam = await ctx.newPage();
  cam.on('pageerror', e => console.log('[cam pageerror]', e.message));
  await cam.goto(BASE + '/cam', { waitUntil: 'domcontentloaded' });
  await cam.click('#gBtn').catch(() => {});
  // 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');
  check('cam.html: remote shot displays on the phone (server /photos/ copy)', sh.ok && allLoaded(cl) && cl[0].src.includes('/photos/'), { sh, cl });
  await cam.screenshot({ path: path.join(OUT, '09-cam-lastshot.png') });

  fs.writeFileSync(path.join(OUT, 'results.json'), JSON.stringify({ failed, results }, null, 2));
  console.log(`\n${results.length - failed}/${results.length} passed`);
  await browser.close();
  process.exit(failed ? 1 : 0);
})().catch(e => { console.error('E2E CRASH', e); process.exit(2); });