← back to CelebritySignatures
scripts/screenrecord-murals.mjs
591 lines
/**
* screenrecord-murals.mjs
* 5-pass Playwright screen recorder for http://localhost:9920/murals
* Each run uses a distinct element-ordering combination.
* Appends JSONL to screenrecord/debug-log.jsonl after every action.
*/
import { chromium } from '/Users/macstudio3/.npm-global/lib/node_modules/playwright/index.mjs';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASE = path.resolve(__dirname, '..');
const LOG = path.join(BASE, 'screenrecord/debug-log.jsonl');
const RECBASE = path.join(BASE, 'screenrecord/rec');
const URL = 'http://localhost:9920/murals';
// ── read prior runs ──
const prior = fs.existsSync(LOG)
? fs.readFileSync(LOG,'utf8').trim().split('\n').filter(Boolean).map(l=>{try{return JSON.parse(l)}catch{return null}}).filter(Boolean)
: [];
const priorErrors = prior.filter(r => r.errors && r.errors.length > 0);
const priorErrSelectors = new Set(priorErrors.map(e => e.selector));
console.log(`Prior log lines: ${prior.length}, prior errors: ${priorErrors.length}`);
if (priorErrors.length) {
console.log('Prior errored selectors:', [...priorErrSelectors].slice(0,10));
}
const append = o => fs.appendFileSync(LOG, JSON.stringify(o)+'\n');
// ── ordering strategies ──
function orderFor(run, els) {
if (run === 0) return [...els]; // DOM order
if (run === 1) return [...els].reverse(); // reverse
if (run === 2) {
// sliders / range inputs first, then number inputs, then buttons/links
const priority = el => el.type === 'range' ? 0 : el.type === 'number' ? 1 : el.type === 'search' ? 2 : 3;
return [...els].sort((a,b) => priority(a) - priority(b));
}
if (run === 3) {
// seeded shuffle
const s = [...els];
for(let i = s.length-1; i > 0; i--){
const j = (i*7 + 13*3) % (i+1);
[s[i],s[j]] = [s[j],s[i]];
}
return s;
}
if (run === 4) {
// errored-first from prior runs
return [...els].sort((a,b) => (priorErrSelectors.has(b.sel)?1:0) - (priorErrSelectors.has(a.sel)?1:0));
}
return [...els];
}
// ── define the interactive surface ──
// This is the complete list of elements to exercise.
// We discover dynamic elements inside the page after boot.
const STATIC_ELEMENTS = [
// NAV LINKS (hash links are safe; / link navigates away so we observe then come back)
{ sel: 'header nav a[href="#studio"]', label: 'Nav: Wall studio', action: 'click', type: 'link' },
{ sel: 'header nav a[href="#order"]', label: 'Nav: Order', action: 'click', type: 'link' },
{ sel: 'header nav a[href="/"]', label: 'Nav: Signature archive', action: 'click-navigate', type: 'link' },
// NAME FINDER
{ sel: '#finderInput', label: 'Finder: search input', action: 'type:lincoln', type: 'search' },
{ sel: '#finderInput', label: 'Finder: clear + type babe ruth', action: 'clear+type:babe ruth', type: 'search' },
{ sel: '#finderInput', label: 'Finder: clear + type einstein', action: 'clear+type:einstein', type: 'search' },
// STUDIO: MURAL SELECT
{ sel: '#selMural', label: 'Studio: Mural select - declaration', action: 'select-option:d', type: 'select' },
{ sel: '#selMural', label: 'Studio: Mural select - politics', action: 'select-option:p', type: 'select' },
{ sel: '#selMural', label: 'Studio: Mural select - composers', action: 'select-option:co', type: 'select' },
// STUDIO: CUSTOM SIZE INPUTS
{ sel: '#murW', label: 'Studio: Mural width - set 30', action: 'set-value:30', type: 'number' },
{ sel: '#murH', label: 'Studio: Mural height - set 10', action: 'set-value:10', type: 'number' },
{ sel: '#murW', label: 'Studio: Mural width - set 16', action: 'set-value:16', type: 'number' },
{ sel: '#murH', label: 'Studio: Mural height - set 8', action: 'set-value:8', type: 'number' },
{ sel: '#murW', label: 'Studio: Mural width - set 40', action: 'set-value:40', type: 'number' },
{ sel: '#murH', label: 'Studio: Mural height - set 16', action: 'set-value:16', type: 'number' },
// STUDIO: WALL SLIDERS
{ sel: '#wallW', label: 'Studio: Wall width slider - 40', action: 'set-slider:40', type: 'range' },
{ sel: '#wallH', label: 'Studio: Wall height slider - 16', action: 'set-slider:16', type: 'range' },
{ sel: '#wallW', label: 'Studio: Wall width slider - 60', action: 'set-slider:60', type: 'range' },
{ sel: '#wallH', label: 'Studio: Wall height slider - 20', action: 'set-slider:20', type: 'range' },
{ sel: '#wallW', label: 'Studio: Wall width slider - 24', action: 'set-slider:24', type: 'range' },
{ sel: '#wallH', label: 'Studio: Wall height slider - 12', action: 'set-slider:12', type: 'range' },
// STUDIO: CENTER BUTTON
{ sel: '#btnCenter', label: 'Studio: Center on wall', action: 'click', type: 'button' },
// STUDIO: SCENE PRESETS (will be discovered dynamically after boot)
{ sel: '#scenePresets button[data-scene="study"]', label: 'Scene preset: Study', action: 'click', type: 'button' },
{ sel: '#scenePresets button[data-scene="lounge"]', label: 'Scene preset: Lounge', action: 'click', type: 'button' },
{ sel: '#scenePresets button[data-scene="gallery"]', label: 'Scene preset: Gallery', action: 'click', type: 'button' },
{ sel: '#scenePresets button[data-scene="plain"]', label: 'Scene preset: Plain', action: 'click', type: 'button' },
// ORDER FORM INPUTS
{ sel: 'input[name="name"]', label: 'Order: Name input', action: 'type:John Test', type: 'input' },
{ sel: 'input[name="email"]', label: 'Order: Email input', action: 'type:test@example.com', type: 'input' },
{ sel: 'input[name="wall_w"]', label: 'Order: Wall width input', action: 'set-value:24', type: 'number' },
{ sel: 'input[name="wall_h"]', label: 'Order: Wall height input', action: 'set-value:12', type: 'number' },
{ sel: 'textarea[name="notes"]', label: 'Order: Notes textarea', action: 'type:Test order for master bedroom. Target install October.', type: 'input' },
{ sel: '#orderForm button[type="submit"]', label: 'Order: Submit form', action: 'click', type: 'button' },
];
// Dynamic elements to discover per-page-load (gallery cards)
const GALLERY_CARD_ELEMENTS = [
// Show names toggles (first 4 cards)
{ sel: '.m-card:nth-child(1) .name-toggle', label: 'Gallery card 1: Show names toggle', action: 'click', type: 'button' },
{ sel: '.m-card:nth-child(1) .name-toggle', label: 'Gallery card 1: Hide names toggle', action: 'click', type: 'button' },
{ sel: '.m-card:nth-child(2) .name-toggle', label: 'Gallery card 2: Show names toggle', action: 'click', type: 'button' },
{ sel: '.m-card:nth-child(3) .name-toggle', label: 'Gallery card 3: Show names toggle', action: 'click', type: 'button' },
// Roster drawers
{ sel: '.m-card:nth-child(1) .roster-trig', label: 'Gallery card 1: Roster drawer open', action: 'click', type: 'button' },
{ sel: '.m-card:nth-child(2) .roster-trig', label: 'Gallery card 2: Roster drawer open', action: 'click', type: 'button' },
{ sel: '.m-card:nth-child(3) .roster-trig', label: 'Gallery card 3: Roster drawer open', action: 'click', type: 'button' },
// Place on wall buttons
{ sel: '.m-card:nth-child(1) [data-place]', label: 'Gallery card 1: Place on wall', action: 'click', type: 'button' },
{ sel: '.m-card:nth-child(2) [data-place]', label: 'Gallery card 2: Place on wall', action: 'click', type: 'button' },
{ sel: '.m-card:nth-child(3) [data-place]', label: 'Gallery card 3: Place on wall', action: 'click', type: 'button' },
];
// Roster filter search (only after a roster is open)
const ROSTER_FILTER = [
{ sel: '.m-card:nth-child(1) .r-search', label: 'Roster filter search: type adams', action: 'type:adams', type: 'search' },
{ sel: '.m-card:nth-child(2) .r-search', label: 'Roster filter search: type bach', action: 'type:bach', type: 'search' },
];
// Mural drag action (special handling)
const DRAG_ACTION = { sel: '#muralEl', label: 'Studio: Drag mural element', action: 'drag', type: 'drag' };
const ALL_ELEMENTS = [...GALLERY_CARD_ELEMENTS, ...STATIC_ELEMENTS];
async function doAction(page, el, errs, run) {
const ts = new Date().toISOString();
const result = { run, ts, selector: el.sel, label: el.label, action: el.action, ok: false, effect: '', errors: [] };
try {
// Clear per-action error buffer
errs.splice(0);
// Locate element
const handle = page.locator(el.sel).first();
const exists = await handle.count() > 0;
if (!exists) {
result.effect = 'ELEMENT_NOT_FOUND';
result.errors = [`Element not found: ${el.sel}`];
append(result);
return result;
}
// scrollIntoView
await handle.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
await page.waitForTimeout(200);
// snapshot before
const before = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
return {
text: el.textContent?.trim().slice(0, 120),
value: el.value || null,
class: el.className?.slice(0, 80),
display: getComputedStyle(el).display,
visible: !!(el.offsetWidth || el.offsetHeight),
};
}, el.sel).catch(() => null);
// perform action
if (el.action === 'click-navigate') {
// Click a link that navigates away, then go back
await handle.click({ timeout: 8000, force: true });
await page.waitForTimeout(1500);
await page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => {});
await page.waitForTimeout(500);
result.ok = true;
result.effect = 'CLICKED_NAVIGATE_WENT_BACK';
append(result);
return result;
} else if (el.action === 'click') {
// Scroll into view with extra top offset to clear sticky header (~80px)
await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (el) {
const rect = el.getBoundingClientRect();
if (rect.top < 90) window.scrollBy(0, rect.top - 100);
}
}, el.sel).catch(() => {});
await page.waitForTimeout(150);
await handle.click({ timeout: 8000, force: true });
} else if (el.action.startsWith('type:')) {
const text = el.action.slice(5);
await handle.fill(text, { timeout: 5000 });
await handle.dispatchEvent('input');
} else if (el.action.startsWith('clear+type:')) {
const text = el.action.slice(11);
await handle.fill('', { timeout: 5000 });
await handle.fill(text, { timeout: 5000 });
await handle.dispatchEvent('input');
} else if (el.action.startsWith('set-value:')) {
const val = el.action.slice(10);
await handle.fill(val, { timeout: 5000 });
await handle.dispatchEvent('input');
await handle.dispatchEvent('change');
} else if (el.action.startsWith('set-slider:')) {
const val = el.action.slice(11);
await handle.fill(val, { timeout: 5000 });
await handle.dispatchEvent('input');
} else if (el.action.startsWith('select-option:')) {
const val = el.action.slice(14);
await handle.selectOption(val, { timeout: 5000 });
} else if (el.action === 'drag') {
// Drag mural 100px right then back
const box = await handle.boundingBox();
if (box) {
const cx = box.x + box.width/2;
const cy = box.y + box.height/2;
await page.mouse.move(cx, cy);
await page.mouse.down();
await page.waitForTimeout(100);
await page.mouse.move(cx + 80, cy + 20, { steps: 10 });
await page.waitForTimeout(200);
await page.mouse.move(cx + 150, cy, { steps: 10 });
await page.waitForTimeout(200);
await page.mouse.up();
}
}
await page.waitForTimeout(400);
// snapshot after
const after = await page.evaluate((sel) => {
const el = document.querySelector(sel);
if (!el) return null;
return {
text: el.textContent?.trim().slice(0, 120),
value: el.value || null,
class: el.className?.slice(0, 80),
display: getComputedStyle(el).display,
visible: !!(el.offsetWidth || el.offsetHeight),
};
}, el.sel).catch(() => null);
// capture readout state for studio actions
let studioReadout = null;
if (el.sel.includes('mur') || el.sel.includes('wall') || el.sel === '#btnCenter' || el.action === 'drag') {
studioReadout = await page.evaluate(() => {
const r = document.querySelector('#readout');
return r ? r.textContent?.trim().slice(0, 300) : null;
}).catch(() => null);
}
// check sig count change for mural size changes
let sigCount = null;
if (el.sel.includes('mur') || el.sel.includes('selMural')) {
sigCount = await page.evaluate(() => {
const r = document.querySelector('#readout');
if (!r) return null;
const m = r.textContent.match(/Signatures shown:\s*(\d+)/);
return m ? parseInt(m[1]) : null;
}).catch(() => null);
}
// check order summary
let orderSummary = null;
if (el.sel.includes('mur') || el.sel.includes('wall') || el.sel.includes('selMural')) {
orderSummary = await page.evaluate(() => {
const s = document.querySelector('#orderSummary');
return s ? s.textContent?.trim().slice(0, 200) : null;
}).catch(() => null);
}
result.ok = true;
result.effect = JSON.stringify({
before: before?.text?.slice(0,60) || before?.value,
after: after?.text?.slice(0,60) || after?.value,
studioReadout: studioReadout?.slice(0,200),
sigCount,
orderSummary: orderSummary?.slice(0,100),
});
} catch(err) {
result.ok = false;
result.effect = 'ACTION_FAILED';
result.errors = [String(err)];
}
// attach any console errors that fired
if (errs.length) {
result.errors = [...result.errors, ...errs.slice()];
errs.splice(0);
}
append(result);
return result;
}
async function runRosterFilter(page, errs, run, cardN, filterText) {
// Only works if roster is open
const sel = `.m-card:nth-child(${cardN}) .r-search`;
const el = { sel, label: `Roster card ${cardN}: filter "${filterText}"`, action: `type:${filterText}`, type: 'search' };
const handle = page.locator(sel).first();
const visible = await handle.count() > 0;
if (!visible) {
append({ run, ts: new Date().toISOString(), selector: sel, label: el.label, action: el.action, ok: false, effect: 'ROSTER_NOT_OPEN', errors: [] });
return;
}
await doAction(page, el, errs, run);
}
async function doRosterItemClick(page, errs, run, cardN) {
// Click first roster item to trigger provenance modal
const sel = `.m-card:nth-child(${cardN}) .r-item:first-child`;
const handle = page.locator(sel).first();
if (await handle.count() === 0) {
append({ run, ts: new Date().toISOString(), selector: sel, label: `Roster card ${cardN}: click first item`, action: 'click', ok: false, effect: 'NO_ROSTER_ITEMS', errors: [] });
return;
}
await doAction(page, { sel, label: `Roster card ${cardN}: click roster item → provenance modal`, action: 'click', type: 'button' }, errs, run);
await page.waitForTimeout(300);
// Check modal opened
const modalOpen = await page.evaluate(() => {
const m = document.querySelector('#provModal');
return m && m.classList.contains('open');
}).catch(() => false);
append({ run, ts: new Date().toISOString(), selector: '#provModal', label: 'Provenance modal: opened?', action: 'observe', ok: modalOpen, effect: modalOpen ? 'MODAL_OPEN' : 'MODAL_NOT_OPEN', errors: [] });
if (modalOpen) {
// Close modal
await doAction(page, { sel: '#provX', label: 'Provenance modal: close (X button)', action: 'click', type: 'button' }, errs, run);
}
}
async function doDrag(page, errs, run) {
const el = page.locator('#muralEl').first();
if (await el.count() === 0) {
append({ run, ts: new Date().toISOString(), selector: '#muralEl', label: 'Studio: Drag mural', action: 'drag', ok: false, effect: 'MURAL_EL_NOT_FOUND', errors: [] });
return;
}
errs.splice(0);
const ts = new Date().toISOString();
try {
await el.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => {});
await page.waitForTimeout(300);
const box = await el.boundingBox();
if (!box) throw new Error('no boundingBox');
const before = await page.evaluate(() => {
const m = document.querySelector('#muralEl');
return m ? { left: m.style.left, top: m.style.top } : null;
});
const cx = box.x + box.width/2;
const cy = box.y + box.height/2;
await page.mouse.move(cx, cy);
await page.mouse.down();
await page.waitForTimeout(150);
await page.mouse.move(cx + 60, cy - 20, { steps: 15 });
await page.waitForTimeout(150);
await page.mouse.move(cx + 120, cy, { steps: 15 });
await page.waitForTimeout(200);
await page.mouse.up();
await page.waitForTimeout(400);
const after = await page.evaluate(() => {
const m = document.querySelector('#muralEl');
return m ? { left: m.style.left, top: m.style.top } : null;
});
const readout = await page.evaluate(() => {
const r = document.querySelector('#readout');
return r ? r.textContent?.trim().slice(0, 300) : null;
});
const moved = before && after && (before.left !== after.left || before.top !== after.top);
append({ run, ts, selector: '#muralEl', label: 'Studio: Drag mural', action: 'drag', ok: moved, effect: JSON.stringify({ before, after, readout: readout?.slice(0,150), moved }), errors: errs.splice(0) });
} catch(err) {
append({ run, ts, selector: '#muralEl', label: 'Studio: Drag mural', action: 'drag', ok: false, effect: 'DRAG_FAILED', errors: [String(err), ...errs.splice(0)] });
}
}
// ── main 5-run loop ──
const RUN_LABELS = [
'run0: DOM order',
'run1: reverse order',
'run2: sliders-first',
'run3: seeded shuffle',
'run4: errored-first',
];
for (let run = 0; run < 5; run++) {
console.log(`\n=== ${RUN_LABELS[run]} ===`);
const recDir = path.join(RECBASE, `run${run}`);
fs.mkdirSync(recDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
const ctx = await browser.newContext({
viewport: { width: 1440, height: 900 },
recordVideo: { dir: recDir, size: { width: 1440, height: 900 } },
});
const page = await ctx.newPage();
const consoleErrs = [];
const pageErrs = [];
page.on('console', msg => { if (msg.type() === 'error') consoleErrs.push(`CONSOLE_ERR: ${msg.text()}`); });
page.on('pageerror', err => pageErrs.push(`PAGE_ERR: ${String(err)}`));
const combinedErrs = () => [...consoleErrs, ...pageErrs];
// Navigate
try {
await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
} catch(e) {
append({ run, ts: new Date().toISOString(), selector: 'page', label: 'PAGE LOAD', action: 'goto', ok: false, effect: 'LOAD_FAILED', errors: [String(e)] });
await ctx.close(); await browser.close();
continue;
}
// Wait for boot() to complete (gallery should be populated)
try {
await page.waitForSelector('#gallery .m-card', { timeout: 10000 });
await page.waitForSelector('#scenePresets button', { timeout: 8000 });
} catch(e) {
append({ run, ts: new Date().toISOString(), selector: '#gallery', label: 'BOOT WAIT', action: 'wait', ok: false, effect: 'GALLERY_NOT_RENDERED', errors: [String(e), ...combinedErrs()] });
await ctx.close(); await browser.close();
continue;
}
await page.waitForTimeout(1000);
// Log page load status
const pageTitle = await page.title();
const muralCount = await page.evaluate(() => document.querySelectorAll('#gallery .m-card').length);
const sigsLoaded = await page.evaluate(() => typeof SIGS !== 'undefined' && SIGS.length > 0).catch(() => false);
append({ run, ts: new Date().toISOString(), selector: 'page', label: 'PAGE LOAD', action: 'goto', ok: true,
effect: JSON.stringify({ title: pageTitle, muralCards: muralCount, sigsLoaded }), errors: combinedErrs().splice(0) });
consoleErrs.splice(0); pageErrs.splice(0);
console.log(` Page loaded: ${muralCount} mural cards, sigsLoaded=${sigsLoaded}`);
// ── Build the ordered element list for this run ──
const orderedEls = orderFor(run, ALL_ELEMENTS);
// ── Execute each element action ──
for (const el of orderedEls) {
const errs = combinedErrs();
consoleErrs.splice(0); pageErrs.splice(0);
// Special case: roster filter search requires roster to be open first
if (el.sel.includes('r-search')) {
// Check if already open
const open = await page.evaluate((sel) => {
const el = document.querySelector(sel);
return el ? el.offsetParent !== null : false;
}, el.sel).catch(() => false);
if (!open) {
append({ run, ts: new Date().toISOString(), selector: el.sel, label: el.label, action: el.action, ok: false, effect: 'SKIPPED_ROSTER_CLOSED', errors: [] });
continue;
}
}
const r = await doAction(page, el, consoleErrs, run);
const cErrs = combinedErrs();
if (cErrs.length) {
// Attach any new errors that fired after the action settled
const prev = JSON.parse(fs.readFileSync(LOG,'utf8').trim().split('\n').pop() || '{}');
if (prev.errors) prev.errors.push(...cErrs);
consoleErrs.splice(0); pageErrs.splice(0);
}
if (!r.ok) console.log(` FAIL [run${run}] ${el.label}: ${r.effect}`);
else console.log(` ok [run${run}] ${el.label}`);
}
// ── Extra targeted actions ──
// Open roster on card 1 & 2 (may already be open from gallery elements)
// Ensure card 1 roster is open, then filter
console.log(' [extra] Ensuring roster card 1 open...');
const rosterOpen1 = await page.evaluate(() => {
const body = document.querySelector('[data-rosterbody]');
return body && body.classList.contains('open');
}).catch(() => false);
if (!rosterOpen1) {
const trig = page.locator('.m-card:nth-child(1) .roster-trig').first();
if (await trig.count() > 0) { await trig.scrollIntoViewIfNeeded().catch(()=>{}); await trig.click().catch(()=>{}); await page.waitForTimeout(600); }
}
await runRosterFilter(page, consoleErrs, run, 1, 'wash');
await page.waitForTimeout(400);
await doRosterItemClick(page, consoleErrs, run, 1);
await page.waitForTimeout(500);
// Open roster card 2
const rosterOpen2 = await page.evaluate(() => {
const bodies = document.querySelectorAll('[data-rosterbody]');
return bodies[1] && bodies[1].classList.contains('open');
}).catch(() => false);
if (!rosterOpen2) {
const trig = page.locator('.m-card:nth-child(2) .roster-trig').first();
if (await trig.count() > 0) { await trig.scrollIntoViewIfNeeded().catch(()=>{}); await trig.click().catch(()=>{}); await page.waitForTimeout(600); }
}
await runRosterFilter(page, consoleErrs, run, 2, 'hancock');
await page.waitForTimeout(400);
// ── Drag mural ──
console.log(' [extra] Dragging mural...');
await page.$('#studio')?.then(el => el?.scrollIntoView?.()).catch(()=>{});
await page.evaluate(() => { const s = document.querySelector('#studio'); if(s) s.scrollIntoView({block:'center'}); });
await page.waitForTimeout(500);
await doDrag(page, consoleErrs, run);
// ── Verify collage image loads in first card ──
const img1Loaded = await page.evaluate(() => {
const img = document.querySelector('.m-card:nth-child(1) img');
return img ? { src: img.src, complete: img.complete, naturalW: img.naturalWidth } : null;
}).catch(() => null);
append({ run, ts: new Date().toISOString(), selector: '.m-card:nth-child(1) img', label: 'Gallery card 1: collage image load', action: 'observe', ok: !!(img1Loaded?.complete && img1Loaded?.naturalW > 0), effect: JSON.stringify(img1Loaded), errors: [] });
// ── Check price & signatures readout in studio ──
const readoutCheck = await page.evaluate(() => {
const r = document.querySelector('#readout');
if (!r) return null;
const text = r.textContent;
const priceMatch = text.match(/Price:\s*\$[\d,]+/);
const sigMatch = text.match(/Signatures shown:\s*(\d+)/);
const sqftMatch = text.match(/(\d+)\s*sq\s*ft/i) || text.match(/Mural:.*?(\d+)×(\d+)/);
return { text: text.slice(0,300), price: priceMatch?.[0], sigs: sigMatch?.[0], sqft: sqftMatch?.[0] };
}).catch(() => null);
append({ run, ts: new Date().toISOString(), selector: '#readout', label: 'Studio: readout check (price + sig count)', action: 'observe', ok: !!(readoutCheck?.price && readoutCheck?.sigs), effect: JSON.stringify(readoutCheck), errors: [] });
// ── Check order summary synced ──
const orderSummaryCheck = await page.evaluate(() => {
const s = document.querySelector('#orderSummary');
return s ? s.textContent?.trim().slice(0,200) : null;
}).catch(() => null);
append({ run, ts: new Date().toISOString(), selector: '#orderSummary', label: 'Order: summary synced with studio', action: 'observe', ok: !!(orderSummaryCheck && orderSummaryCheck.length > 10), effect: orderSummaryCheck, errors: [] });
// ── Escape key closes modal (if open) ──
await page.keyboard.press('Escape');
await page.waitForTimeout(200);
// ── Capture final console errors ──
const finalErrs = [...consoleErrs, ...pageErrs];
if (finalErrs.length) {
append({ run, ts: new Date().toISOString(), selector: 'page', label: 'FINAL CONSOLE ERRORS', action: 'observe', ok: false, effect: '', errors: finalErrs });
}
console.log(` Closing run${run}...`);
await ctx.close();
await browser.close();
// ── Convert webm → mp4 ──
const webms = fs.readdirSync(recDir).filter(f => f.endsWith('.webm'));
for (const w of webms) {
const inp = path.join(recDir, w);
const out = inp.replace('.webm', '.mp4');
try {
execSync(`ffmpeg -y -i "${inp}" -c:v libx264 -preset fast -crf 22 "${out}" 2>/dev/null`);
console.log(` converted: ${out}`);
} catch(e) {
console.log(` ffmpeg failed for ${w}: ${e.message?.slice(0,100)}`);
}
}
// Brief cooldown between runs
await new Promise(r => setTimeout(r, 1000));
}
console.log('\n=== All 5 runs complete ===');
console.log(`Debug log: ${LOG}`);
console.log(`Recordings: ${RECBASE}/run{0..4}/`);