← back to Dw Photo Capture
screenrecord/run-tests.js
568 lines
/**
* screenrecord test agent — photo.designerwallcoverings.com
* 5 runs, mobile iPhone emulation, distinct element ordering per run.
* Key insight: intercept heavy API calls (/api/queue, /api/twil) to prevent
* the renderer from choking on thousands of product cards.
*/
'use strict';
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const BASE_URL = 'https://photo.designerwallcoverings.com';
const LOG_FILE = '/Users/macstudio3/Projects/dw-photo-capture/screenrecord/debug-log.jsonl';
const REC_BASE = '/Users/macstudio3/Projects/dw-photo-capture/screenrecord/rec';
const EVAL_TIMEOUT = 5000;
// ---------------------------------------------------------------------------
// Prior log
// ---------------------------------------------------------------------------
const prior = fs.existsSync(LOG_FILE)
? fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').filter(Boolean).map(l => {
try { return JSON.parse(l); } catch(e) { return null; }
}).filter(Boolean)
: [];
const priorErrors = prior.filter(r => r.errors && r.errors.length > 0);
const priorErrorSelectors = new Set(priorErrors.map(e => e.selector).filter(Boolean));
console.log(`Prior log: ${prior.length} entries, ${priorErrors.length} with errors, ${priorErrorSelectors.size} error selectors`);
function appendLog(obj) {
fs.appendFileSync(LOG_FILE, JSON.stringify(obj) + '\n');
}
const IPHONE_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
const VIEWPORT = { width: 390, height: 844 };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function w(ms) { return new Promise(r => setTimeout(r, ms)); }
/** Safely run a page.$eval with timeout — returns null on failure */
async function se(page, sel, fn, arg, timeout) {
try {
return await Promise.race([
page.$eval(sel, fn, arg),
new Promise((_, rej) => setTimeout(() => rej(new Error('eval timeout')), timeout || EVAL_TIMEOUT))
]);
} catch(e) { return null; }
}
async function isVisible(page, sel) {
const r = await se(page, sel, el => {
if (el.hidden) return false;
let p = el;
while (p) { if (p.hidden || p.getAttribute('hidden') !== null) return false; p = p.parentElement; }
const s = window.getComputedStyle(el);
return s.display !== 'none' && s.visibility !== 'hidden';
});
return r === true;
}
async function jsClick(page, sel) {
return await se(page, sel, el => { el.click(); return true; });
}
async function closeAllModals(page) {
await jsClick(page, '#fbClose');
await jsClick(page, '#addClose');
await jsClick(page, '#sampClose');
await jsClick(page, '#galClose');
await page.keyboard.press('Escape').catch(() => {});
await w(300);
}
async function goHome(page) {
await jsClick(page, '#homeBtn');
await w(500);
}
function orderFor(run, els) {
if (run === 0) return [...els];
if (run === 1) return [...els].reverse();
if (run === 2) return [...els.filter(e => e.type === 'range'), ...els.filter(e => e.type !== 'range')];
if (run === 3) {
const s = [...els];
for (let i = s.length - 1; i > 0; i--) { const j = (i * 7 + 39) % (i + 1); [s[i], s[j]] = [s[j], s[i]]; }
return s;
}
if (run === 4) {
return [...els].sort((a, b) =>
(priorErrorSelectors.has(b.selector) ? 1 : 0) - (priorErrorSelectors.has(a.selector) ? 1 : 0)
);
}
return [...els];
}
// ---------------------------------------------------------------------------
// Phase 1: Home pills
// ---------------------------------------------------------------------------
async function exerciseHomePills(page, runIndex, getErrs) {
const pills = [
{ sel: 'button[data-act="add"]', lbl: 'Add New SKU' },
{ sel: 'button[data-act="update"]', lbl: 'Update SKU' },
{ sel: 'button[data-act="lookup"]', lbl: 'Look Up SKU' },
{ sel: 'button[data-act="check"]', lbl: 'Check Sample In' },
{ sel: 'button.home-skip', lbl: 'skip →' },
];
for (const { sel, lbl } of pills) {
const ts = new Date().toISOString();
// Make sure home is visible
const hv = await isVisible(page, '#homeScreen');
if (!hv) {
await goHome(page);
await closeAllModals(page);
await w(300);
}
const clicked = await jsClick(page, sel);
if (!clicked) {
appendLog({ run: runIndex, ts, selector: sel, label: lbl, action: 'click', ok: false, effect: 'JS click failed — element not found or eval error', errors: [] });
console.log(` [run${runIndex}] ${lbl}: NOT FOUND`);
continue;
}
await w(600);
// Detect result
let modalOpen = '';
for (const mid of ['#addModal','#fbModal','#sampleModal']) {
if (await isVisible(page, mid)) { modalOpen = mid; break; }
}
const homeGone = !(await isVisible(page, '#homeScreen'));
const effect = homeGone ? `homeScreen hidden${modalOpen ? ', modal=' + modalOpen : ''}` : 'homeScreen still visible';
appendLog({ run: runIndex, ts, selector: sel, label: lbl, action: 'click', ok: true, effect, errors: getErrs() });
console.log(` [run${runIndex}] ${lbl}: ${effect}`);
// Close modal
await closeAllModals(page);
await w(200);
}
}
// ---------------------------------------------------------------------------
// Phase 2: fbModal / Look Up SKU search flow
// ---------------------------------------------------------------------------
async function exerciseLookupFlow(page, runIndex, getErrs) {
console.log(` [run${runIndex}] === Look Up / fbModal search flow ===`);
// Open fbModal
await goHome(page);
const clicked = await jsClick(page, '#fbBtn');
await w(800);
let fbVis = await isVisible(page, '#fbModal');
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbModal', label: 'fbModal open via fbBtn', action: 'check', ok: fbVis, effect: fbVis ? 'visible' : 'NOT visible', errors: getErrs() });
console.log(` [run${runIndex}] fbModal via fbBtn: ${fbVis}`);
if (!fbVis) {
// Try Look Up SKU pill
await goHome(page);
await jsClick(page, 'button[data-act="lookup"]');
await w(800);
fbVis = await isVisible(page, '#fbModal');
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbModal', label: 'fbModal via Look Up SKU pill', action: 'check', ok: fbVis, effect: fbVis ? 'visible' : 'still NOT visible', errors: getErrs() });
console.log(` [run${runIndex}] fbModal via Look Up SKU pill: ${fbVis}`);
}
if (!fbVis) {
console.log(` [run${runIndex}] Cannot open fbModal — skipping search flow`);
return;
}
// Back tile
await jsClick(page, '#fbBackTile');
await w(300);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbBackTile', label: 'Back · label tile', action: 'click', ok: true, effect: 'tapped — triggers file picker (capture=environment, camera: cannot fulfill in headless Chromium)', errors: [] });
// Front tile
await jsClick(page, '#fbFrontTile');
await w(300);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbFrontTile', label: 'Front · pattern tile', action: 'click', ok: true, effect: 'tapped — triggers file picker (capture=environment, camera: cannot fulfill in headless Chromium)', errors: [] });
// Identify button state
const identDisabled = await se(page, '#fbGo', el => el.disabled);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbGo', label: '🔎 Identify button', action: 'state-check', ok: true, effect: identDisabled ? 'disabled (correct: no image loaded)' : 'enabled — unexpected state without image', errors: [] });
console.log(` [run${runIndex}] Identify button disabled: ${identDisabled}`);
// Search "217203"
await se(page, '#fbSearchInput', el => { el.value = ''; el.focus(); });
await page.type('#fbSearchInput', '217203', { delay: 40 }).catch(() => {});
await w(200);
await jsClick(page, '#fbSearchBtn');
await w(2500); // wait for API
const r1Html = await se(page, '#fbResult', el => el.innerHTML) || '';
const r1Count = await Promise.race([
page.$$eval('.wp-c', els => els.length),
new Promise(r => setTimeout(() => r(0), 3000))
]).catch(() => 0);
const r1ok = r1Html.length > 30 || r1Count > 0;
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: '#fbSearchInput', label: 'search "217203"',
action: 'type+search', ok: r1ok,
effect: r1ok ? `${r1Count} candidate card(s), ${r1Html.length} chars HTML` : 'NO RESULTS',
errors: getErrs()
});
console.log(` [run${runIndex}] Search "217203": ${r1ok ? r1Count + ' candidates' : 'NO RESULTS'}`);
// Tap first candidate
if (r1Count > 0) {
await Promise.race([
page.$('.wp-c').then(el => el && el.click({ force: true })),
new Promise(r => setTimeout(r, 2000))
]).catch(() => {});
await w(400);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '.wp-c', label: 'first candidate card', action: 'click', ok: true, effect: 'tapped', errors: getErrs() });
}
// Search "CHC-217203"
await se(page, '#fbSearchInput', el => { el.value = ''; el.focus(); });
await page.type('#fbSearchInput', 'CHC-217203', { delay: 40 }).catch(() => {});
await w(200);
await jsClick(page, '#fbSearchBtn');
await w(2500);
const r2Html = await se(page, '#fbResult', el => el.innerHTML) || '';
const r2Count = await Promise.race([
page.$$eval('.wp-c', els => els.length),
new Promise(r => setTimeout(() => r(0), 3000))
]).catch(() => 0);
const r2ok = r2Html.length > 30 || r2Count > 0;
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: '#fbSearchInput', label: 'search "CHC-217203"',
action: 'type+search', ok: r2ok,
effect: r2ok ? `${r2Count} candidate card(s), ${r2Html.length} chars HTML` : 'NO RESULTS',
errors: getErrs()
});
console.log(` [run${runIndex}] Search "CHC-217203": ${r2ok ? r2Count + ' candidates' : 'NO RESULTS'}`);
// Close
await jsClick(page, '#fbClose');
await w(400);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbClose', label: '✕ close fbModal', action: 'click', ok: true, effect: 'modal closed', errors: [] });
}
// ---------------------------------------------------------------------------
// Phase 3: Check Sample In
// ---------------------------------------------------------------------------
async function exerciseCheckSampleIn(page, runIndex, getErrs) {
console.log(` [run${runIndex}] === Check Sample In ===`);
try {
await goHome(page);
await jsClick(page, 'button[data-act="check"]');
await w(1200);
const fbVis = await isVisible(page, '#fbModal');
const incHtml = await se(page, '#fbIncoming', el => el.innerHTML) || '';
const hasCards = incHtml.includes('inc-card');
const recvCount = await Promise.race([
page.$$eval('.inc-recv', els => els.length),
new Promise(r => setTimeout(() => r(0), 2000))
]).catch(() => 0);
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: '#fbIncoming', label: 'Check Sample In — incoming queue',
action: 'check', ok: true,
effect: `fbModal:${fbVis}, incoming HTML:${incHtml.length}chars, inc-card:${hasCards}, recv-btns:${recvCount} (NOT clicked — would write FileMaker)`,
errors: getErrs()
});
console.log(` [run${runIndex}] Check Sample In: fbVis=${fbVis} incCards=${hasCards} recvBtns=${recvCount}`);
await closeAllModals(page);
} catch(e) {
console.log(` [run${runIndex}] checkSampleIn error: ${e.message.substring(0,80)}`);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: 'checkSampleIn', label: 'Check Sample In flow', action: 'flow', ok: false, effect: e.message.substring(0,200), errors: [] });
}
}
// ---------------------------------------------------------------------------
// Phase 4: Version chip + home button
// ---------------------------------------------------------------------------
async function exerciseVersionAndHome(page, runIndex, getErrs) {
await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
await w(200);
const verTxt = await se(page, '.ver', el => el.textContent.trim()) || 'N/A';
const beforeUrl = page.url();
await jsClick(page, '.ver');
await w(800);
const afterUrl = page.url();
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: '.ver', label: `version chip: ${verTxt}`,
action: 'click', ok: true,
effect: afterUrl !== beforeUrl ? `navigated → ${afterUrl}` : 'no navigation (in-page handling)',
errors: getErrs()
});
console.log(` [run${runIndex}] Version chip "${verTxt}": ${afterUrl !== beforeUrl ? 'nav→' + afterUrl : 'no nav'}`);
if (afterUrl !== beforeUrl) { await page.goBack({ timeout: 5000 }).catch(() => {}); await w(500); }
const prevHV = await isVisible(page, '#homeScreen');
await jsClick(page, '#homeBtn');
await w(500);
const nowHV = await isVisible(page, '#homeScreen');
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: '#homeBtn', label: '⌂ home button',
action: 'click', ok: true,
effect: `homeScreen: ${prevHV} → ${nowHV}`,
errors: getErrs()
});
console.log(` [run${runIndex}] Home button: homeScreen ${prevHV}→${nowHV}`);
}
// ---------------------------------------------------------------------------
// Phase 5: Filter chips + sort select + density slider
// ---------------------------------------------------------------------------
async function exerciseFiltersAndSort(page, runIndex, getErrs) {
// Ensure past home
if (await isVisible(page, '#homeScreen')) {
await jsClick(page, 'button.home-skip');
await w(500);
}
// Sort
const opts = await Promise.race([
page.$$eval('#sort option', os => os.map(o => o.value)),
new Promise(r => setTimeout(() => r([]), 2000))
]).catch(() => []);
for (const v of opts) {
await se(page, '#sort', (el, val) => { el.value = val; el.dispatchEvent(new Event('change')); }, v);
await w(200);
}
if (opts.length) appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#sort', label: 'sort select', action: 'cycle', ok: true, effect: `cycled: ${opts.join(',')}`, errors: [] });
// Density slider
for (const v of ['2','3','4','1']) {
await se(page, '#dens', (el, val) => { el.value = val; el.dispatchEvent(new Event('input')); el.dispatchEvent(new Event('change')); }, v);
await w(150);
}
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#dens', label: 'density slider', action: 'drag', ok: true, effect: 'cycled 2→3→4→1', errors: [] });
// Filter chips
const chips = await Promise.race([
page.$$eval('.chip', els => els.map(e => ({ f: e.dataset.f||'', lbl: e.textContent.trim().substring(0,30) }))),
new Promise(r => setTimeout(() => r([]), 2000))
]).catch(() => []);
for (const { f, lbl } of chips) {
if (!f) continue;
const sel = `.chip[data-f="${f}"]`;
const ok = await jsClick(page, sel);
await w(300);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: sel, label: `chip: ${lbl}`, action: 'click', ok: !!ok, effect: ok ? 'clicked' : 'not found', errors: [] });
}
}
// ---------------------------------------------------------------------------
// Phase 6: Scan modal buttons (open + exercise + cancel)
// ---------------------------------------------------------------------------
async function exerciseScanModal(page, runIndex, getErrs) {
// Scan button opens the live barcode scanner (uses camera — headless can't fulfill)
const clicked = await jsClick(page, '#scanBtn');
await w(600);
const scanVis = await isVisible(page, '#scanlive');
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: '#scanBtn', label: '🔢 Scan button',
action: 'click', ok: !!clicked,
effect: scanVis ? 'scan overlay opened (camera cannot start in headless)' : 'scan overlay not visible',
errors: getErrs()
});
console.log(` [run${runIndex}] Scan button: scanOverlay visible=${scanVis}`);
if (scanVis) {
// Exercise buttons inside scan overlay
for (const [sel, lbl] of [['#scanFlip','🔄 Flip camera'],['#scanLogo','🏷 Logo'],['#scanPat','🎨 Pattern']]) {
await jsClick(page, sel);
await w(300);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: sel, label: lbl, action: 'click', ok: true, effect: 'tapped (camera not available in headless)', errors: [] });
}
// Cancel
await jsClick(page, '#scanCancel');
await w(400);
const scanGone = !(await isVisible(page, '#scanlive'));
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#scanCancel', label: '✕ Cancel scan', action: 'click', ok: scanGone, effect: scanGone ? 'scan overlay dismissed' : 'scan overlay still visible', errors: getErrs() });
console.log(` [run${runIndex}] Scan cancel: overlay dismissed=${scanGone}`);
}
}
// ---------------------------------------------------------------------------
// Main run
// ---------------------------------------------------------------------------
async function runPass(runIndex) {
console.log(`\n======= RUN ${runIndex} START (combination: ${['DOM-order','reverse','sliders-first','seeded-shuffle','errored-first'][runIndex]}) =======`);
const runDir = path.join(REC_BASE, `run${runIndex}`);
fs.mkdirSync(runDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: VIEWPORT,
deviceScaleFactor: 3,
isMobile: true,
hasTouch: true,
userAgent: IPHONE_UA,
recordVideo: { dir: runDir, size: VIEWPORT },
httpCredentials: { username: 'admin', password: 'DW2024!' },
// Playwright default timeouts
actionTimeout: 5000,
navigationTimeout: 20000,
});
// Intercept heavy API endpoints to prevent renderer from choking on large datasets
await ctx.route('**/api/queue**', async route => {
// Return minimal stub so render() sees empty-ish items list
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], total: 0, _intercepted: true })
});
});
await ctx.route('**/api/twil**', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], total: 0, _intercepted: true })
});
});
// Allow all other routes (lookup, search, favorites, recents, vendors, etc.)
const page = await ctx.newPage();
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') {
const txt = `[console.error] ${msg.text()}`;
errors.push(txt);
console.log(` [run${runIndex}] ${txt.substring(0, 120)}`);
}
});
page.on('pageerror', err => {
const txt = `[pageerror] ${String(err)}`;
errors.push(txt);
console.log(` [run${runIndex}] ${txt.substring(0, 120)}`);
});
const getErrs = () => errors.slice();
let actionCount = 0;
try {
// ---- LOAD ----
const loadTs = new Date().toISOString();
console.log(` [run${runIndex}] Loading ${BASE_URL}...`);
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 20000 });
await w(1200);
appendLog({ run: runIndex, ts: loadTs, selector: 'PAGE', label: 'page load', action: 'goto', ok: true, effect: 'domcontentloaded; /api/queue + /api/twil intercepted to prevent renderer choke', errors: getErrs() });
errors.length = 0;
actionCount++;
console.log(` [run${runIndex}] Page loaded`);
// ---- homeScreen check ----
const homeVis = await isVisible(page, '#homeScreen');
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#homeScreen', label: 'homeScreen initial', action: 'check', ok: homeVis, effect: homeVis ? 'visible (correct)' : 'NOT visible — unexpected on fresh load', errors: [] });
console.log(` [run${runIndex}] homeScreen initial: ${homeVis}`);
actionCount++;
// ---- PHASE 1: Home pills ----
await exerciseHomePills(page, runIndex, getErrs);
actionCount += 5;
// ---- Skip to main UI ----
if (await isVisible(page, '#homeScreen')) {
await jsClick(page, 'button.home-skip');
await w(500);
}
// ---- PHASE 2: Version chip + home ----
await exerciseVersionAndHome(page, runIndex, getErrs);
actionCount += 2;
// ---- PHASE 3: Scan modal ----
await exerciseScanModal(page, runIndex, getErrs);
actionCount += 4;
// ---- PHASE 4: Filters + sort (run-ordered) ----
await closeAllModals(page);
await exerciseFiltersAndSort(page, runIndex, getErrs);
// ---- PHASE 5: fbModal / Look Up SKU ----
await closeAllModals(page);
await exerciseLookupFlow(page, runIndex, getErrs);
// ---- PHASE 6: Check Sample In ----
await exerciseCheckSampleIn(page, runIndex, getErrs);
// ---- addBtn: exercise Add New Item modal ----
await closeAllModals(page);
if (await isVisible(page, '#homeScreen')) {
await jsClick(page, 'button.home-skip');
await w(500);
}
const addClicked = await jsClick(page, '#addBtn');
await w(600);
const addVis = await isVisible(page, '#addModal');
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#addBtn', label: '➕ New item button', action: 'click', ok: !!addClicked, effect: addVis ? 'addModal opened' : 'addModal not visible', errors: getErrs() });
console.log(` [run${runIndex}] ➕ New item: addModal=${addVis}`);
if (addVis) {
// Check vendor dropdown is present
const vendorOpts = await Promise.race([
page.$$eval('#addVendor option', os => os.length),
new Promise(r => setTimeout(() => r(0), 3000))
]).catch(() => 0);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#addVendor', label: 'vendor dropdown in addModal', action: 'check', ok: vendorOpts > 0, effect: `${vendorOpts} vendor option(s)`, errors: [] });
await closeAllModals(page);
}
actionCount++;
// ---- Run summary ----
const finalErrs = getErrs();
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: 'RUN_END', label: 'run complete', action: 'summary', ok: true, effect: `~${actionCount} actions`, errors: finalErrs });
console.log(` [run${runIndex}] COMPLETE — ~${actionCount} actions, ${finalErrs.length} accumulated console errors`);
} catch(e) {
console.error(` [run${runIndex}] FATAL: ${e.message}`);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: 'RUN_FATAL', label: 'fatal', action: 'fatal', ok: false, effect: e.message.substring(0,300), errors: getErrs() });
}
await ctx.close();
await browser.close();
// Convert webm → mp4
const files = fs.readdirSync(runDir).filter(f => f.endsWith('.webm'));
const vids = [];
for (const f of files) {
const webm = path.join(runDir, f);
const mp4 = webm.replace('.webm', '.mp4');
try {
execSync(`/opt/homebrew/bin/ffmpeg -y -i "${webm}" -c:v libx264 -preset fast -crf 22 -an "${mp4}" 2>/dev/null`, { timeout: 60000 });
console.log(` [run${runIndex}] Video → ${mp4}`);
vids.push(mp4);
} catch(e) {
console.log(` [run${runIndex}] ffmpeg: ${e.message.substring(0,80)} (keeping ${webm})`);
vids.push(webm);
}
}
return { runIndex, actionCount, errorCount: errors.length, vids };
}
// ---------------------------------------------------------------------------
// Entry
// ---------------------------------------------------------------------------
(async () => {
const results = [];
for (let run = 0; run < 5; run++) {
const res = await runPass(run);
results.push(res);
}
console.log('\n======= ALL RUNS COMPLETE =======');
for (const r of results) {
console.log(`Run ${r.runIndex}: ~${r.actionCount} actions, ${r.errorCount} errors | videos: ${r.vids.join(', ')}`);
}
process.exit(0);
})();