/* app.js — Playwright-runner UI behavior */ (function(){ const $ = (sel,root=document)=>root.querySelector(sel); const $$ = (sel,root=document)=>Array.from(root.querySelectorAll(sel)); const data = window.PORTFOLIO; const tests = data.suite.tests; // Test state: idle | running | passed const state = Object.fromEntries(tests.map(t=>[t.id, { status:'idle', runtime:0 }])); let isRunningAll = false; let activeTimers = []; const headed = () => $('#headed').checked; // ============ ICONS ============ const ICONS = { play: '', check: '', dot: '', spin: '', chev: '', rerun: '', }; function statusIcon(s){ if (s==='running') return ICONS.spin; if (s==='passed') return ICONS.check; return ICONS.dot; } function statusClass(s){ if (s==='running') return 'icon--run'; if (s==='passed') return 'icon--pass'; return 'icon--idle'; } // Format test title with syntax highlight function titleHTML(t){ return `test('${t.title}')`; } // ============ SIDEBAR (tree) ============ function renderTree(){ const wrap = $('#tree'); wrap.innerHTML = `
${ICONS.chev} describe('${data.suite.name}')
${tests.map(renderTreeTest).join('')}
`; wrap.addEventListener('click', e=>{ const head = e.target.closest('.suite__head'); if (head) { head.parentElement.classList.toggle('collapsed'); return; } const runBtn = e.target.closest('.test__run'); if (runBtn) { e.stopPropagation(); runTest(runBtn.dataset.id); return; } const row = e.target.closest('.test'); if (row) { selectTest(row.dataset.id, true); } }); } function renderTreeTest(t){ const s = state[t.id]; return `
${statusIcon(s.status)} ${titleHTML(t)} ${s.runtime?formatMs(s.runtime):''}
${t.tags.map(x=>`${x}`).join('')}
`; } function refreshTreeRow(id){ const t = tests.find(x=>x.id===id); const s = state[id]; const row = $(`.test[data-id="${id}"]`); if (!row) return; row.classList.toggle('is-running', s.status==='running'); row.classList.toggle('is-passed', s.status==='passed'); const iconEl = row.querySelector('.test__icon'); iconEl.className = `test__icon ${statusClass(s.status)}`; iconEl.innerHTML = statusIcon(s.status); row.querySelector('.test__dur').textContent = s.runtime?formatMs(s.runtime):''; } // ============ TAG BAR ============ let activeTags = new Set(); function renderTagBar(){ const bar = $('#tag-bar'); bar.innerHTML = data.tags.map(t=>`${t}`).join(''); bar.addEventListener('click', e=>{ const el = e.target.closest('.tag'); if (!el) return; const tag = el.dataset.tag; if (activeTags.has(tag)) activeTags.delete(tag); else activeTags.add(tag); bar.querySelectorAll('.tag').forEach(t=>{ t.classList.toggle('is-active', activeTags.has(t.dataset.tag)); }); // Reflect into grep input $('#grep').value = Array.from(activeTags).join(' '); applyFilter(); }); } function applyFilter(){ const grep = $('#grep').value.trim().toLowerCase(); const wantedTags = grep.split(/\s+/).filter(s=>s.startsWith('@')); const text = grep.replace(/@\S+/g,'').trim(); $$('.test').forEach(row=>{ const id = row.dataset.id; const t = tests.find(x=>x.id===id); const tagsOK = wantedTags.length===0 || wantedTags.every(tg => t.tags.includes(tg)); const textOK = !text || t.title.toLowerCase().includes(text) || id.includes(text); row.style.display = (tagsOK && textOK) ? '' : 'none'; }); } // ============ MAIN PANE RESULTS ============ function renderResults(){ const wrap = $('#results'); wrap.innerHTML = tests.map(t=>{ const s = state[t.id]; return `
${ICONS.chev} ${statusIcon(s.status)} ${titleHTML(t)} ${s.runtime?formatMs(s.runtime):''}
`; }).join(''); wrap.addEventListener('click', e=>{ const rerun = e.target.closest('.result__rerun'); if (rerun) { e.stopPropagation(); runTest(rerun.dataset.id); return; } const head = e.target.closest('.result__head'); if (head) { const art = head.parentElement; const id = art.dataset.id; // If passed, toggle. If idle, run it. if (state[id].status === 'idle') { runTest(id); } else { art.classList.toggle('is-open'); } } }); } function refreshResultRow(id){ const t = tests.find(x=>x.id===id); const s = state[id]; const art = $(`#result-${id}`); if (!art) return; const ico = art.querySelector('.result__status'); ico.className = `result__status ${statusClass(s.status)}`; ico.innerHTML = statusIcon(s.status); art.querySelector('.result__dur').textContent = s.runtime?formatMs(s.runtime):''; if (s.status === 'passed' && !art.dataset.populated) { const body = $(`#body-${id}`); // Steps + custom content const stepsHTML = t.steps.map(st=>`
${st.kind==='info'?ICONS.dot:ICONS.check} ${st.title} ${formatMs(st.dur)}
`).join(''); body.innerHTML = `
${stepsHTML}
${t.render()}`; art.dataset.populated = '1'; art.classList.add('is-open'); // Animate skill bars if present $$('.skill__fill', body).forEach(el => requestAnimationFrame(()=> el.style.width = el.dataset.pct + '%')); // Hook resume buttons const dl = $('#dl-resume', body); if (dl) dl.addEventListener('click', downloadResume); const pr = $('#print-resume', body); if (pr) pr.addEventListener('click', ()=>window.print()); } } // ============ RUN ENGINE ============ function selectTest(id, scroll){ $$('.test').forEach(r=>r.classList.toggle('is-selected', r.dataset.id===id)); if (scroll) { const el = $(`#result-${id}`); if (el) el.scrollIntoView({ behavior:'smooth', block:'start' }); } } async function runTest(id){ const t = tests.find(x=>x.id===id); if (!t) return; const s = state[id]; if (s.status === 'running') return; // reset s.status = 'running'; s.runtime = 0; refreshTreeRow(id); refreshResultRow(id); updateStatusbar(); selectTest(id, true); consoleLine('info', `▶ running test('${t.title}')`); const dur = headed() ? t.duration * 2.5 : t.duration; const start = performance.now(); const bar = $(`#bar-${id}`); bar.style.width = '0%'; await tween(dur, (p)=>{ bar.style.width = (p*100) + '%'; }); const elapsed = Math.round(performance.now() - start); s.runtime = elapsed; s.status = 'passed'; bar.style.width = '100%'; setTimeout(()=>{ bar.style.transition='opacity .4s'; bar.style.opacity='0'; }, 200); // Clear populated flag so it re-renders fresh on re-run const art = $(`#result-${id}`); if (art) art.dataset.populated = ''; refreshTreeRow(id); refreshResultRow(id); updateStatusbar(); consoleLine('ok', `✓ test('${t.title}') passed in ${formatMs(elapsed)}`); } async function runAll(){ if (isRunningAll) return; isRunningAll = true; $('#run-all').disabled = true; $('#stop-all').disabled = false; consoleLine('info', `▶ playwright test (workers=1)`); for (const t of tests) { const row = $(`.test[data-id="${t.id}"]`); if (row && row.style.display === 'none') continue; // respect filter // reset to idle if previously passed state[t.id].status = 'idle'; state[t.id].runtime = 0; const art = $(`#result-${t.id}`); if (art) { art.dataset.populated=''; const b=$(`#bar-${t.id}`); if(b){b.style.width='0%';b.style.opacity='1';b.style.transition='';}} refreshTreeRow(t.id); refreshResultRow(t.id); await runTest(t.id); if (!isRunningAll) break; } isRunningAll = false; $('#run-all').disabled = false; $('#stop-all').disabled = true; const total = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0); consoleLine('ok', `Test suite finished — ${countPassed()} passed in ${formatMs(total)}`); } function resetAll(){ isRunningAll = false; for (const t of tests) { state[t.id] = { status:'idle', runtime:0 }; const art = $(`#result-${t.id}`); if (art) { art.dataset.populated = ''; art.classList.remove('is-open'); const body = $(`#body-${t.id}`); if (body) body.innerHTML=''; const b = $(`#bar-${t.id}`); if (b){ b.style.width='0%'; b.style.opacity='1'; b.style.transition=''; } } refreshTreeRow(t.id); refreshResultRow(t.id); } $('#console').innerHTML = ''; updateStatusbar(); consoleLine('info', 'reset · all tests returned to idle'); } // ============ STATUS / COUNTS ============ function countPassed(){ return Object.values(state).filter(s=>s.status==='passed').length; } function updateStatusbar(){ const passed = countPassed(); const running = Object.values(state).filter(s=>s.status==='running').length; $('#cnt-pass').textContent = passed; $('#cnt-fail').textContent = 0; $('#cnt-skip').textContent = 0; const dot = $('#status-dot'); const txt = $('#status-text'); if (running) { dot.className='dot dot--running'; txt.textContent = 'running'; } else if (passed === tests.length) { dot.className='dot dot--pass'; txt.textContent='all passed'; } else if (passed > 0) { dot.className='dot dot--pass'; txt.textContent='partial'; } else { dot.className='dot dot--idle'; txt.textContent='idle'; } const total = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0); $('#sb-runtime').textContent = `runtime: ${formatMs(total)}`; } // ============ CONSOLE ============ function consoleLine(lvl, msg){ const el = $('#console'); const ts = new Date().toLocaleTimeString('en-CA', { hour12:false }); const line = document.createElement('div'); line.className = 'console__line'; line.innerHTML = `${ts}${lvl.toUpperCase()}${msg}`; el.appendChild(line); el.scrollTop = el.scrollHeight; } // ============ TABS ============ function initTabs(){ $$('.tab').forEach(tab=>{ tab.addEventListener('click', ()=>{ const id = tab.dataset.tab; $$('.tab').forEach(t=>t.classList.toggle('is-active', t===tab)); $$('.pane').forEach(p=>p.classList.toggle('is-active', p.id === `pane-${id}`)); }); }); } // ============ TRACE PANE ============ function renderTrace(){ // Build a career timeline: each role gets a bar relative to a global timeline const trace = $('#trace'); const items = data.experience.map(e => { const m = e.when.match(/(\w+\s+\d{4})\s*[–-]\s*(\w+\s+\d{4})/i); if (!m) return null; return { ...e, start: parseMon(m[1]), end: parseMon(m[2]) }; }).filter(Boolean); const min = Math.min(...items.map(i=>i.start.getTime())); const max = Math.max(...items.map(i=>i.end.getTime())); const span = max - min; trace.innerHTML = items.map(it=>{ const left = ((it.start - min) / span) * 100; const width = Math.max(2, ((it.end - it.start) / span) * 100); const months = Math.round((it.end - it.start)/(1000*60*60*24*30)); return `
${_esc(it.company)} · ${_esc(it.role)}
${months}mo
`; }).join(''); // Axis ticks: years from min to max const axis = $('#trace-axis'); const yStart = new Date(min).getFullYear(); const yEnd = new Date(max).getFullYear(); const years = []; for (let y=yStart; y<=yEnd; y+=Math.max(1, Math.ceil((yEnd-yStart)/8))) years.push(y); if (years[years.length-1] !== yEnd) years.push(yEnd); axis.innerHTML = `
${years.map(y=>`${y}`).join('')}
`; } function parseMon(s){ const [mon,yr] = s.split(/\s+/); const idx = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'] .indexOf(mon.slice(0,3).toLowerCase()); return new Date(parseInt(yr), idx<0?0:idx, 1); } function _esc(s){ return String(s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } // ============ SOURCE PANE ============ function renderSource(){ const lines = [ ['// portfolio.spec.ts — Senior SDET portfolio, expressed as a Playwright test suite'], ['import { test, expect } from \'@playwright/test\';'], ['import { person, experience, skills, projects } from \'./fixtures/ilia\';'], [''], [`test.describe('${data.suite.name}', () => {`], [''], ]; tests.forEach(t=>{ const tagStr = t.tags.length ? ` // ${t.tags.join(' ')}` : ''; lines.push([` test('${t.title}', async ({ page }) => {${tagStr}`]); t.steps.forEach(s=>{ lines.push([` await test.step('${s.title.replace(/'/g,"\\'")}', async () => { /* ${s.dur}ms */ });`]); }); lines.push([` });`]); lines.push(['']); }); lines.push(['});']); $('#source').innerHTML = lines.map((row,i)=>`
${i+1} ${row[0]}
`).join(''); } // ============ RESUME DOWNLOAD ============ function downloadResume(){ // Build a single-page HTML resume as a blob, open it in a new tab and trigger print const p = data.person; const html = `${p.first} ${p.last} — Resume

${p.first} ${p.last}

${p.email} · ${p.phone}
${p.title} · ${p.location} · ${p.linkedin}

${p.blurb}

Experience

${data.experience.map(e=>`

${e.company} — ${e.role}

${e.when} · ${e.where}
`).join('')}

Skills

Projects

${data.projects.map(p=>`

${p.name}

${p.desc}

`).join('')} `; const blob = new Blob([html], { type:'text/html' }); const url = URL.createObjectURL(blob); window.open(url, '_blank'); consoleLine('ok', '⇩ resume.pdf generated — print dialog opened in new tab'); } // ============ THEME TOGGLE ============ // Persist via cookie (localStorage is unavailable in some sandboxed iframes) function readThemeCookie(){ const m = document.cookie.match(/(?:^|;\s*)theme=(dark|light)/); return m ? m[1] : null; } function writeThemeCookie(v){ try { document.cookie = `theme=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch(_) {} } function initTheme(){ const saved = readThemeCookie() || 'dark'; document.documentElement.setAttribute('data-theme', saved); $('#theme-toggle').addEventListener('click', ()=>{ const cur = document.documentElement.getAttribute('data-theme'); const next = cur === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); writeThemeCookie(next); }); } // ============ HELPERS ============ function formatMs(ms){ if (ms < 1000) return `${Math.round(ms)}ms`; return `${(ms/1000).toFixed(1)}s`; } function tween(duration, onUpdate){ return new Promise(resolve=>{ const start = performance.now(); function frame(now){ const p = Math.min(1, (now-start)/duration); onUpdate(p); if (p < 1) requestAnimationFrame(frame); else resolve(); } requestAnimationFrame(frame); }); } // ============ INIT ============ function init(){ initTheme(); renderTagBar(); renderTree(); renderResults(); renderTrace(); renderSource(); initTabs(); updateStatusbar(); $('#grep').addEventListener('input', applyFilter); $('#run-all').addEventListener('click', runAll); $('#stop-all').addEventListener('click', ()=>{ isRunningAll = false; consoleLine('warn','■ stop requested — finishing current test'); }); $('#reset-all').addEventListener('click', resetAll); $('#expand-all').addEventListener('click', ()=>{ $$('.suite').forEach(s=>s.classList.remove('collapsed')); }); // Mobile drawer const sidebar = $('#sidebar'); const scrim = $('#sidebar-scrim'); function closeDrawer(){ sidebar.classList.remove('is-open'); scrim.classList.remove('is-open'); } $('#menu-btn').addEventListener('click', ()=>{ sidebar.classList.toggle('is-open'); scrim.classList.toggle('is-open'); }); scrim.addEventListener('click', closeDrawer); // Close drawer when a test is selected on mobile sidebar.addEventListener('click', e => { if (window.innerWidth <= 900 && (e.target.closest('.test') || e.target.closest('.test__run'))) { setTimeout(closeDrawer, 120); } }); consoleLine('info', `Running ${tests.length} tests using 1 worker`); // Friendly nudge: animate the Run All button briefly on first load setTimeout(()=> $('#run-all').classList.add('pulse'), 600); } document.addEventListener('DOMContentLoaded', init); })();