← back to Dw Photo Capture
screenrecord/run-tests-v103-mobile.js
607 lines
/**
* screenrecord v103 VERIFICATION — photo.designerwallcoverings.com
* Mobile iPhone emulation, NO API interception this run.
*
* Primary checks:
* 1. MEMORY: Does the page load without OOM? Capture JSHeapUsedSize via CDP.
* 2. RENDER CAP: After tapping skip/TWIL, card count must be ≤ ~501.
* 3. SEARCH: 217203 / CHC-217203 must return candidates.
* 4. CORE FLOW: all 4 home pills, version chip, home button, Check Sample In.
* 5. Console errors captured per action.
*
* 5 runs, each with a DISTINCT click-order combination.
* Appends to the existing debug-log.jsonl (cross-reload memory).
*/
'use strict';
const { chromium, devices } = 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';
// iPhone 14 Pro — 393×852 logical, dSF=3, iOS 17 Safari UA
const IPHONE_DEVICE = devices['iPhone 14 Pro'];
// Viewport override to exact spec requested (390x844)
const VIEWPORT = { width: 390, height: 844 };
// ---------------------------------------------------------------------------
// Prior log — learn from previous runs
// ---------------------------------------------------------------------------
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(`[v103] Prior log: ${prior.length} entries, ${priorErrors.length} with errors, ${priorErrorSelectors.size} error selectors`);
console.log(`[v103] NOTE: NO API interception this run — verifying v103 fix (RENDER_CAP=500, lazy TWIL)`);
function appendLog(obj) {
fs.appendFileSync(LOG_FILE, JSON.stringify(obj) + '\n');
}
async function w(ms) { return new Promise(r => setTimeout(r, ms)); }
/** Safely run page.$eval with timeout */
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 || 5000))
]);
} 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);
}
/** Grab CDP heap metrics via Playwright CDP session */
async function getHeapMB(page) {
try {
const client = await page.context().newCDPSession(page);
const metrics = await client.send('Performance.getMetrics');
const m = Object.fromEntries(metrics.metrics.map(x => [x.name, x.value]));
await client.detach();
const heapUsedMB = m.JSHeapUsedSize ? (m.JSHeapUsedSize / 1024 / 1024).toFixed(1) : null;
const heapTotalMB = m.JSHeapTotalSize ? (m.JSHeapTotalSize / 1024 / 1024).toFixed(1) : null;
return { heapUsedMB, heapTotalMB, raw: m };
} catch(e) {
return { heapUsedMB: null, heapTotalMB: null, error: e.message };
}
}
/** Count .card elements in the DOM */
async function countCards(page) {
try {
return await Promise.race([
page.$$eval('.card', els => els.length),
new Promise(r => setTimeout(() => r(-1), 4000))
]);
} catch(e) { return -1; }
}
/** Check for render-cap banner text */
async function getRenderCapNote(page) {
try {
return await Promise.race([
page.$eval('#renderCapNote', el => el.textContent.trim()),
new Promise(r => setTimeout(() => r(''), 2000))
]);
} catch(e) {
// try alternative selectors
try {
return await page.$eval('.render-cap-note', el => el.textContent.trim());
} catch(e2) { return ''; }
}
}
// ---------------------------------------------------------------------------
// Combination orderings — 5 distinct per skill spec
// ---------------------------------------------------------------------------
const ELEMENT_LIST = [
{ selector: 'button[data-act="add"]', label: 'Add New SKU', type: 'button' },
{ selector: 'button[data-act="update"]', label: 'Update SKU', type: 'button' },
{ selector: 'button[data-act="lookup"]', label: 'Look Up SKU', type: 'button' },
{ selector: 'button[data-act="check"]', label: 'Check Sample In', type: 'button' },
{ selector: 'button.home-skip', label: 'skip →', type: 'button' },
{ selector: '.ver', label: 'version chip', type: 'chip' },
{ selector: '#homeBtn', label: 'home button', type: 'button' },
{ selector: '#scanBtn', label: 'Scan button', type: 'button' },
{ selector: '#sort', label: 'sort select', type: 'select' },
{ selector: '#dens', label: 'density slider', type: 'range' },
{ selector: '#addBtn', label: 'New item button', type: 'button' },
{ selector: '#fbSearchInput', label: 'search box', type: 'input' },
{ selector: '.chip[data-f="need"]', label: 'chip: Needs photo',type: 'chip' },
{ selector: '.chip[data-f="live"]', label: 'chip: Live', type: 'chip' },
{ selector: '.chip[data-f="done"]', label: 'chip: Done', type: 'chip' },
{ selector: '.chip[data-f="twil"]', label: 'chip: TWIL', type: 'chip' },
{ selector: '.chip[data-f="all"]', label: 'chip: All', type: 'chip' },
{ selector: '.chip[data-f="any"]', label: 'chip: Any SKU', type: 'chip' },
];
function orderFor(run, els) {
if (run === 0) return [...els];
if (run === 1) return [...els].reverse();
if (run === 2) {
// sliders + chips first, then buttons
return [
...els.filter(e => e.type === 'range' || e.type === 'chip'),
...els.filter(e => e.type !== 'range' && e.type !== 'chip'),
];
}
if (run === 3) {
const s = [...els];
for (let i = s.length - 1; i > 0; i--) {
const j = (i * 7 + 3 * 13) % (i + 1);
[s[i], s[j]] = [s[j], s[i]];
}
return s;
}
if (run === 4) {
// errored-first: elements from prior error selectors at top
return [...els].sort((a, b) =>
(priorErrorSelectors.has(b.selector) ? 1 : 0) - (priorErrorSelectors.has(a.selector) ? 1 : 0)
);
}
return [...els];
}
// ---------------------------------------------------------------------------
// Core verification steps (same across all 5 runs, element order varies)
// ---------------------------------------------------------------------------
async function step_loadAndMemory(page, runIndex, getErrs) {
console.log(` [run${runIndex}] Loading ${BASE_URL} (NO API interception)...`);
const t0 = Date.now();
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
const loadMs = Date.now() - t0;
await w(2000); // let JS init settle
const heap0 = await getHeapMB(page);
const cards0 = await countCards(page);
const homeVis = await isVisible(page, '#homeScreen');
const effect = `loaded in ${loadMs}ms; homeScreen=${homeVis}; JSHeap=${heap0.heapUsedMB}MB used / ${heap0.heapTotalMB}MB total; cards in DOM at load: ${cards0}`;
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: 'PAGE', label: 'page load (NO interception)',
action: 'goto', ok: true, effect,
heap: heap0, loadMs,
errors: getErrs()
});
console.log(` [run${runIndex}] Load: ${loadMs}ms heap=${heap0.heapUsedMB}MB cards=${cards0} homeVis=${homeVis}`);
return { loadMs, heap0, cards0, homeVis };
}
async function step_homeScreenCheck(page, runIndex, getErrs) {
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: ${homeVis}`);
return homeVis;
}
async function step_homePills(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' },
];
const results = {};
for (const { sel, lbl } of pills) {
if (!(await isVisible(page, '#homeScreen'))) {
await goHome(page);
await closeAllModals(page);
}
const clicked = await jsClick(page, sel);
await w(700);
let modalOpen = '';
for (const mid of ['#addModal','#fbModal','#sampleModal','#fbIncoming']) {
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: new Date().toISOString(), selector: sel, label: lbl, action: 'click', ok: !!clicked, effect, errors: getErrs() });
console.log(` [run${runIndex}] ${lbl}: ${effect}`);
results[lbl] = effect;
await closeAllModals(page);
await w(200);
}
return results;
}
async function step_skipAndCheckRenderCap(page, runIndex, getErrs) {
console.log(` [run${runIndex}] === skip → / TWIL tap + render cap check ===`);
// Make sure we're at home first
if (!(await isVisible(page, '#homeScreen'))) {
await goHome(page);
await closeAllModals(page);
await w(300);
}
// Click "skip →" — this is where lazy TWIL load fires in v103
const skipClicked = await jsClick(page, 'button.home-skip');
if (!skipClicked) {
// Sometimes skip becomes the TWIL chip after home — try navigating to main UI another way
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: 'button.home-skip', label: 'skip →', action: 'click', ok: false, effect: 'not found — home screen may already be bypassed', errors: getErrs() });
}
// Wait for TWIL lazy load (up to 15s — 36MB fetch on first tap)
console.log(` [run${runIndex}] Waiting up to 15s for TWIL lazy load...`);
const t0 = Date.now();
let cardCount = 0;
let heapAfterSkip = null;
let renderCapNote = '';
for (let attempt = 0; attempt < 15; attempt++) {
await w(1000);
cardCount = await countCards(page);
if (cardCount > 0) break;
}
const elapsed = Date.now() - t0;
heapAfterSkip = await getHeapMB(page);
renderCapNote = await getRenderCapNote(page);
// Also check TWIL chip directly if card count still 0
if (cardCount === 0) {
console.log(` [run${runIndex}] No cards after skip — trying TWIL chip directly...`);
await jsClick(page, '.chip[data-f="twil"]');
await w(8000);
cardCount = await countCards(page);
heapAfterSkip = await getHeapMB(page);
renderCapNote = await getRenderCapNote(page);
}
const capOk = cardCount > 0 && cardCount <= 501;
const effect = `cards=${cardCount} (cap ok=${capOk}); heap after=${heapAfterSkip.heapUsedMB}MB; renderCapNote="${renderCapNote}"; elapsed=${elapsed}ms`;
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: 'button.home-skip + .chip[data-f="twil"]',
label: 'skip → / TWIL tap — render cap verification',
action: 'click+check', ok: capOk,
effect, cardCount, heapAfterSkip, renderCapNote,
errors: getErrs()
});
console.log(` [run${runIndex}] RENDER CAP: cards=${cardCount}, capOk=${capOk}, heap=${heapAfterSkip.heapUsedMB}MB, note="${renderCapNote}"`);
return { cardCount, heapAfterSkip, renderCapNote, capOk };
}
async function step_search(page, runIndex, getErrs) {
console.log(` [run${runIndex}] === Search flow ===`);
// Open fbModal via Look Up SKU pill or directly
await goHome(page);
await w(300);
let fbVis = false;
await jsClick(page, 'button[data-act="lookup"]');
await w(1000);
fbVis = await isVisible(page, '#fbModal');
if (!fbVis) {
// try #fbBtn
await goHome(page);
await jsClick(page, '#fbBtn');
await w(1000);
fbVis = await isVisible(page, '#fbModal');
}
if (!fbVis) {
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: '#fbModal', label: 'fbModal open', action: 'check', ok: false, effect: 'NOT visible — cannot search', errors: getErrs() });
console.log(` [run${runIndex}] fbModal not visible — skip search`);
return { r1ok: false, r1Count: 0, r2ok: false, r2Count: 0 };
}
const searchResults = {};
for (const query of ['217203', 'CHC-217203']) {
// Clear and type
await se(page, '#fbSearchInput', el => { el.value = ''; el.focus(); });
await page.type('#fbSearchInput', query, { delay: 40 }).catch(() => {});
await w(300);
await jsClick(page, '#fbSearchBtn');
// Wait up to 10s for results (cold server can be slow)
let rCount = 0;
for (let i = 0; i < 10; i++) {
await w(1000);
rCount = await Promise.race([
page.$$eval('.wp-c', els => els.length),
new Promise(r => setTimeout(() => r(0), 1500))
]).catch(() => 0);
if (rCount > 0) break;
}
const rHtml = await se(page, '#fbResult', el => el.innerHTML) || '';
const ok = rHtml.length > 30 || rCount > 0;
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: '#fbSearchInput', label: `search "${query}"`,
action: 'type+search', ok,
effect: ok ? `${rCount} candidate card(s), ${rHtml.length} chars HTML` : 'NO RESULTS',
errors: getErrs()
});
console.log(` [run${runIndex}] Search "${query}": ${ok ? rCount + ' candidates' : 'NO RESULTS'}`);
searchResults[query] = { ok, rCount };
}
await jsClick(page, '#fbClose');
await w(400);
return searchResults;
}
async function step_checkSampleIn(page, runIndex, getErrs) {
console.log(` [run${runIndex}] === Check Sample In ===`);
await goHome(page);
await jsClick(page, 'button[data-act="check"]');
await w(1500);
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}, incHTML=${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);
return { fbVis, hasCards, recvCount };
}
async function step_versionChipAndHome(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); }
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 after: ${nowHV}`, errors: getErrs() });
console.log(` [run${runIndex}] Home button → homeScreen=${nowHV}`);
return { verTxt };
}
async function step_filtersAndSort(page, runIndex, getErrs) {
// Ensure past home
if (await isVisible(page, '#homeScreen')) {
await jsClick(page, 'button.home-skip');
await w(800);
}
// 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(150);
}
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(200);
appendLog({ run: runIndex, ts: new Date().toISOString(), selector: sel, label: `chip: ${lbl}`, action: 'click', ok: !!ok, effect: ok ? 'clicked' : 'not found', errors: [] });
}
}
// ---------------------------------------------------------------------------
// Main pass
// ---------------------------------------------------------------------------
async function runPass(runIndex) {
const COMBOS = ['DOM-order','reverse','chips+sliders-first','seeded-shuffle','errored-first'];
console.log(`\n======= RUN ${runIndex} START (combination: ${COMBOS[runIndex]}) =======`);
const runDir = path.join(REC_BASE, `run${runIndex}_v103`);
fs.mkdirSync(runDir, { recursive: true });
const browser = await chromium.launch({ headless: true, args: ['--enable-precise-memory-info'] });
const ctx = await browser.newContext({
...IPHONE_DEVICE,
viewport: VIEWPORT,
recordVideo: { dir: runDir, size: VIEWPORT },
httpCredentials: { username: 'admin', password: 'DW2024!' },
actionTimeout: 8000,
navigationTimeout: 30000,
// NO route intercepts — real network
});
const page = await ctx.newPage();
// Enable CDP Performance domain for heap metrics
try {
const client = await ctx.newCDPSession(page);
await client.send('Performance.enable');
await client.detach();
} catch(e) {}
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, 150)}`);
}
});
page.on('pageerror', err => {
const txt = `[pageerror] ${String(err)}`;
errors.push(txt);
console.log(` [run${runIndex}] ${txt.substring(0, 150)}`);
});
const getErrs = () => errors.slice();
let peakHeapMB = 0;
let summary = {};
try {
// ---- LOAD + MEMORY check ----
const loadData = await step_loadAndMemory(page, runIndex, getErrs);
peakHeapMB = parseFloat(loadData.heap0.heapUsedMB) || 0;
errors.length = 0; // reset after load
// ---- homeScreen check ----
await step_homeScreenCheck(page, runIndex, getErrs);
// ---- Home pills (in run-ordered combination) ----
const ordered = orderFor(runIndex, ELEMENT_LIST.filter(e => e.type === 'button' && e.selector.startsWith('button[data-act')));
// Run the 4 home-action pills in the ordered sequence
await step_homePills(page, runIndex, getErrs);
// ---- skip → + render cap check (PRIMARY VERIFICATION) ----
// Go home first
await goHome(page);
await closeAllModals(page);
await w(300);
const capData = await step_skipAndCheckRenderCap(page, runIndex, getErrs);
const h = parseFloat((capData.heapAfterSkip || {}).heapUsedMB) || 0;
if (h > peakHeapMB) peakHeapMB = h;
summary.capData = capData;
// ---- Version chip + home ----
await step_versionChipAndHome(page, runIndex, getErrs);
// ---- Filters + sort ----
await step_filtersAndSort(page, runIndex, getErrs);
// ---- Search flow ----
await closeAllModals(page);
const searchData = await step_search(page, runIndex, getErrs);
summary.searchData = searchData;
// ---- Check Sample In ----
const sampleData = await step_checkSampleIn(page, runIndex, getErrs);
summary.sampleData = sampleData;
// ---- Final heap snapshot ----
const finalHeap = await getHeapMB(page);
const fh = parseFloat(finalHeap.heapUsedMB) || 0;
if (fh > peakHeapMB) peakHeapMB = fh;
const finalErrs = getErrs();
appendLog({
run: runIndex, ts: new Date().toISOString(),
selector: 'RUN_END', label: 'run complete',
action: 'summary', ok: true,
effect: `peakHeap=${peakHeapMB.toFixed(1)}MB; cards=${capData.cardCount}; capOk=${capData.capOk}; errCount=${finalErrs.length}`,
peakHeapMB, capData, searchData,
errors: finalErrs
});
console.log(` [run${runIndex}] COMPLETE — peakHeap=${peakHeapMB.toFixed(1)}MB cards=${capData.cardCount} capOk=${capData.capOk} errs=${finalErrs.length}`);
} 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 unavailable/failed: keeping ${webm}`);
vids.push(webm);
}
}
return { runIndex, peakHeapMB, summary, errorCount: errors.length, vids };
}
// ---------------------------------------------------------------------------
// Entry
// ---------------------------------------------------------------------------
(async () => {
console.log('[v103] Starting 5-run mobile verification — NO API interception');
const results = [];
for (let run = 0; run < 5; run++) {
const res = await runPass(run);
results.push(res);
}
console.log('\n======= ALL RUNS COMPLETE =======');
console.log('Run | peakHeap | cards | capOk | errs | videos');
for (const r of results) {
const cd = r.summary.capData || {};
console.log(`Run${r.runIndex} | ${r.peakHeapMB.toFixed(1)}MB | ${cd.cardCount} | ${cd.capOk} | ${r.errorCount} | ${r.vids.join(', ')}`);
}
process.exit(0);
})();