/* 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; const specs = Array.isArray(data.specs) && data.specs.length ? data.specs : [{ id: 'portfolio', file: 'portfolio.spec.ts', describe: data.suite.name }]; const RESUME_PDF = 'assets/DobkinResume26.pdf'; // Test state: idle | running | passed | skipped const state = Object.fromEntries(tests.map(t=>[t.id, { status: t.skip ? 'skipped' : t.fail ? 'failed' : 'idle', runtime:0 }])); let isRunningAll = false; const headed = () => $('#headed').checked; const getWorkers = () => Math.max(1, parseInt($('#workers').value, 10) || 1); // Active spec controls which tests are visible in tree / results / source. // Restored from cookie on init; falls back to the first spec. function readSpecCookie(){ const m = document.cookie.match(/(?:^|;\s*)spec=([a-z0-9_-]+)/i); return m ? m[1] : null; } function writeSpecCookie(v){ try { document.cookie = `spec=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch { /* cookies disabled */ } } function getSpec(id){ return specs.find(s => s.id === id) || specs[0]; } function specCountFor(id){ return tests.filter(t => t.spec === id).length; } let activeSpec = (specs.find(s => s.id === readSpecCookie()) || specs[0]).id; // ============ ICONS ============ const ICONS = { play: '', check: '', skip: '', fail: '', dot: '', spin: '', chev: '', rerun: '', }; function statusIcon(s){ if (s==='running') return ICONS.spin; if (s==='passed') return ICONS.check; if (s==='failed') return ICONS.fail; if (s==='skipped') return ICONS.skip; return ICONS.dot; } function statusClass(s){ if (s==='running') return 'icon--run'; if (s==='passed') return 'icon--pass'; if (s==='failed') return 'icon--fail'; if (s==='skipped') return 'icon--skip'; return 'icon--idle'; } // Format test title with syntax highlight function titleHTML(t){ return `test('${t.title}')`; } // ============ SIDEBAR (tree) ============ // Renders a Test Explorer with ALL specs visible (one collapsible // describe block per spec). The editor tab strip controls which spec // is "open" in the right pane; clicking a test row in any suite // auto-switches the active tab to that test's spec. function renderTree(){ const wrap = $('#tree'); wrap.innerHTML = specs.map(s => { const specTests = tests.filter(t => t.spec === s.id); const cls = s.id === activeSpec ? 'is-active' : ''; return `
${ICONS.chev} describe('${s.describe}') ${specTests.length}
${specTests.map(renderTreeTest).join('')}
`; }).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(); const id = runBtn.dataset.id; const t = tests.find(x => x.id === id); if (t && t.spec !== activeSpec) activateSpec(t.spec); runTest(id); return; } const row = e.target.closest('.test'); if (row) { const id = row.dataset.id; const t = tests.find(x => x.id === id); if (t && t.spec !== activeSpec) activateSpec(t.spec); selectTest(id, true); } }); } function renderTreeTest(t){ const s = state[t.id]; const cls = [ 'test', s.status==='running' ? 'is-running' : '', s.status==='passed' ? 'is-passed' : '', s.status==='failed' ? 'is-failed' : '', s.status==='skipped' ? 'is-skipped' : '', ].filter(Boolean).join(' '); return `
${statusIcon(s.status)} ${titleHTML(t)} ${s.runtime?formatMs(s.runtime):''}
${t.tags.map(x=>`${x}`).join('')}
`; } function refreshTreeRow(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'); row.classList.toggle('is-failed', s.status==='failed'); row.classList.toggle('is-skipped', s.status==='skipped'); 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 ============ // Show the first VISIBLE_TAGS by default; the rest hide behind a "+N more" // chip that expands them inline on click. Keeps the sidebar visual mass // small until a visitor needs the full registry. const VISIBLE_TAGS = 6; const activeTags = new Set(); function renderTagBar(){ const bar = $('#tag-bar'); const all = Array.isArray(data.tags) ? data.tags : []; const hidden = Math.max(0, all.length - VISIBLE_TAGS); const tagHTML = all.map((t, i) => `${t}` ).join(''); const moreHTML = hidden > 0 ? `` : ''; const clearHTML = ``; bar.innerHTML = tagHTML + moreHTML + clearHTML; bar.addEventListener('click', e=>{ const clr = e.target.closest('[data-clear]'); if (clr) { clearTagFilter(); return; } const more = e.target.closest('[data-more]'); if (more) { bar.classList.add('is-expanded'); more.setAttribute('aria-expanded', 'true'); return; } 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); syncTagBarUI(); }); } function syncTagBarUI(){ const bar = $('#tag-bar'); bar.querySelectorAll('.tag').forEach(t=>{ t.classList.toggle('is-active', activeTags.has(t.dataset.tag)); }); bar.classList.toggle('has-active', activeTags.size > 0); $('#grep').value = Array.from(activeTags).join(' '); applyFilter(); } function clearTagFilter(){ activeTags.clear(); syncTagBarUI(); } function applyFilter(){ const grep = $('#grep').value.trim().toLowerCase(); const wantedTags = grep.split(/\s+/).filter(s=>s.startsWith('@')); const text = grep.replace(/@\S+/g,'').trim(); const hasFilter = wantedTags.length > 0 || text.length > 0; // Sidebar tree — filter per-suite so we can hide entire describe blocks // when none of their tests match (prevents the awkward "open caret but no // rows" look that reads as a broken collapsed state). $$('[data-suite]').forEach(suite => { let anyVisible = false; suite.querySelectorAll('.test').forEach(row => { const id = row.dataset.id; const t = tests.find(x => x.id === id); if (!t) { row.style.display = 'none'; return; } const tagsOK = wantedTags.length === 0 || wantedTags.every(tg => t.tags.includes(tg)); const textOK = !text || t.title.toLowerCase().includes(text) || id.includes(text); const visible = tagsOK && textOK; row.style.display = visible ? '' : 'none'; if (visible) anyVisible = true; }); // No filter → always show all suites. With a filter → hide suites that // have zero matching tests; auto-expand suites that do match. suite.style.display = (!hasFilter || anyVisible) ? '' : 'none'; if (hasFilter && anyVisible) suite.classList.remove('collapsed'); }); // Right-pane results — only active spec is visible. The editor tab is // the "open file"; we render its body, not the rest of the project. $$('.result').forEach(art => { const id = art.dataset.id; const t = tests.find(x => x.id === id); art.style.display = (t && t.spec === activeSpec) ? '' : 'none'; }); } // ============ MAIN PANE RESULTS ============ // Returns the id of the first idle test for the active spec — the one we // render as a "pending preview" so the page never looks empty before a run. function firstIdleId(){ const t = tests.find(x => x.spec === activeSpec && state[x.id].status === 'idle' && !x.skip && !x.fail); return t ? t.id : null; } // Pre-render the body for the pending-preview test: steps + the section's // own render(). Wrapped with an overlay so it reads as "preview, not result". function renderPendingBody(t){ const stepsHTML = t.steps.map(st => `
${st.kind==='info'?ICONS.dot:ICONS.check} ${st.title} ${formatMs(st.dur)}
`).join(''); const overlay = ``; return `${overlay}
${stepsHTML}
${t.render()}`; } function renderSkippedBody(t){ const stepsHTML = t.steps.map(st => `
${ICONS.skip} ${st.title}
`).join(''); const reason = t.skipReason || 'test.skip()'; return `
${stepsHTML}
${t.render ? t.render() : `

${reason}

`}`; } function renderFailedBody(t){ const stepsHTML = t.steps.map(st => { const isFail = st.kind === 'fail'; const icon = isFail ? ICONS.fail : ICONS.check; const cls = isFail ? 'is-fail' : 'is-pass'; return `
${icon} ${st.title} ${st.dur ? formatMs(st.dur) : '—'}
`; }).join(''); const msg = t.failMessage || 'Assertion error'; return `
${stepsHTML}
${t.render ? t.render() : `
${msg}
`}`; } function renderResults(){ const wrap = $('#results'); const pendingId = firstIdleId(); wrap.innerHTML = tests.map(t=>{ const s = state[t.id]; const isPending = t.id === pendingId; const isSkip = s.status === 'skipped'; const isFail = s.status === 'failed'; let bodyHTML = ''; let cls = 'result'; let dataAttr = ''; if (isFail) { cls = 'result is-open is-failed'; bodyHTML = renderFailedBody(t); } else if (isSkip) { cls = 'result is-open is-skipped'; bodyHTML = renderSkippedBody(t); } else if (isPending) { cls = 'result is-open is-pending'; dataAttr = ' data-pending="1"'; bodyHTML = renderPendingBody(t); } return `
${ICONS.chev} ${statusIcon(s.status)} ${titleHTML(t)} ${s.runtime?formatMs(s.runtime):''}
${bodyHTML}
`; }).join(''); wrap.addEventListener('click', e=>{ const pendingBtn = e.target.closest('[data-pending-run]'); if (pendingBtn) { e.stopPropagation(); runTest(pendingBtn.dataset.pendingRun); return; } 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.remove('is-pending'); // un-fade — real result is in. 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.open(RESUME_PDF, '_blank', 'noopener,noreferrer')); } } // ============ 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; if (t.skip) { consoleLine('warn', `⊘ test('${t.title}') skipped`); return; } if (t.fail) { consoleLine('err', `✗ test('${t.title}') failed — ${t.failMessage ? t.failMessage.split('\n')[0] : 'assertion error'}`); return; } // If this row was sitting in the pending-preview state, lift the overlay // but keep the faded preview body visible through the run — that way the // report never blanks mid-animation. The "is-pending" class stays on // (so the body stays greyed) until refreshResultRow swaps in the real // result on completion. const art = $(`#result-${id}`); if (art && art.dataset.pending) { art.removeAttribute('data-pending'); const overlay = art.querySelector('.result__pending-overlay'); if (overlay) overlay.remove(); art.dataset.populated = ''; } // 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 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; // Build the queue from the active spec, in suite order. Skip tests // explicitly marked skip, and respect grep/tag filters via row display. const queue = tests.filter(t => { if (t.spec !== activeSpec) return false; if (t.skip || t.fail) return false; const row = $(`.test[data-id="${t.id}"]`); return !row || row.style.display !== 'none'; }); // Reset every queued test up front so all rows are visibly idle before // workers fan out — otherwise the first row would briefly look special. for (const t of queue) { 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); } updateStatusbar(); const N = Math.min(getWorkers(), queue.length || 1); const sf = getSpec(activeSpec).file; consoleLine('info', `▶ playwright test ${sf} (workers=${N})`); let cursor = 0; const startedAt = performance.now(); async function worker(){ while (isRunningAll) { const next = queue[cursor++]; if (!next) return; await runTest(next.id); } } await Promise.all(Array.from({ length: N }, worker)); isRunningAll = false; $('#run-all').disabled = false; $('#stop-all').disabled = true; const wall = Math.round(performance.now() - startedAt); const cpu = Object.values(state).reduce((a,s)=>a+(s.runtime||0),0); const speedup = cpu > 0 ? (cpu / Math.max(1, wall)).toFixed(2) : '1.00'; const tail = N > 1 ? ` · ${formatMs(wall)} wall, ${formatMs(cpu)} cpu (${speedup}× speedup)` : ` in ${formatMs(wall)}`; consoleLine('ok', `Test suite finished — ${countPassed()} passed${tail}`); } function resetAll(){ isRunningAll = false; for (const t of tests) { state[t.id] = { status: t.skip ? 'skipped' : t.fail ? 'failed' : 'idle', runtime:0 }; const art = $(`#result-${t.id}`); if (art) { art.dataset.populated = ''; art.classList.remove('is-open'); art.classList.remove('is-pending'); art.removeAttribute('data-pending'); 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 = ''; // After reset, the first test in the active spec is "pending" again — // restore the preview so the report doesn't land empty. refreshPendingPreview(); 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(){ // Counts and runtime are scoped to the active spec — matches what the // user is currently viewing, not the global pool of all specs. const specTests = tests.filter(t => t.spec === activeSpec); const passed = specTests.filter(t => state[t.id].status === 'passed').length; const failed = specTests.filter(t => state[t.id].status === 'failed').length; const running = specTests.filter(t => state[t.id].status === 'running').length; const skipped = specTests.filter(t => state[t.id].status === 'skipped').length; const xfailed = specTests.filter(t => t.fail).length; const surprise = failed - xfailed; const runnableCount = specTests.length - skipped - xfailed; $('#cnt-pass').textContent = passed; $('#cnt-fail').textContent = surprise; $('#cnt-skip').textContent = skipped; const dot = $('#status-dot'); const txt = $('#status-text'); if (running) { dot.className='dot dot--running'; txt.textContent = 'running'; } else if (surprise > 0) { dot.className='dot dot--fail'; txt.textContent = `${surprise} failed`; } else if (runnableCount > 0 && passed === runnableCount) { 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 = specTests.reduce((a,t) => a + (state[t.id].runtime||0), 0); $('#sb-runtime').textContent = `runtime: ${formatMs(total)}`; updateSummaryStripe(specTests, { passed, failed, running, skipped, total }); } // Tiny run-history stripe above the results list. We aggregate the current // (last) wall time + counts for the active spec so it reads naturally as // either "Running… ✓ 1 passed · 3 pending" or "Last run · 1.4s · 4 passed". function updateSummaryStripe(specTests, agg){ const el = $('#summary-stripe'); if (!el) return; specTests = specTests || tests.filter(t => t.spec === activeSpec); const passed = agg ? agg.passed : specTests.filter(t => state[t.id].status === 'passed').length; const failed = agg ? (agg.failed || 0) : specTests.filter(t => state[t.id].status === 'failed').length; const running = agg ? agg.running : specTests.filter(t => state[t.id].status === 'running').length; const skipped = agg ? (agg.skipped || 0) : specTests.filter(t => state[t.id].status === 'skipped').length; const total = agg ? agg.total : specTests.reduce((a,t) => a + (state[t.id].runtime||0), 0); const pending = specTests.length - passed - failed - running - skipped; let lbl, dotCls; if (running) { lbl = 'Running'; dotCls = 'ss__dot--running'; } else if (failed > 0) { lbl = 'Last run'; dotCls = 'ss__dot--fail'; } else if (passed > 0) { lbl = 'Last run'; dotCls = 'ss__dot--pass'; } else { lbl = 'No runs yet'; dotCls = 'ss__dot--pending'; } const parts = [ `${lbl}`, ]; if (total > 0) parts.push(`·${formatMs(total)}`); parts.push(`·${passed} passed`); if (failed > 0) parts.push(`·${failed} failed`); if (running > 0) parts.push(`·${running} running`); if (pending > 0) parts.push(`·${pending} pending`); if (skipped > 0) parts.push(`·${skipped} skipped`); el.innerHTML = parts.join(''); } // ============ 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}`)); if (id === 'network') { requestAnimationFrame(()=>renderNetwork()); } }); }); } // ============ 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])); } // ============ NETWORK PANE (Gitea repos as API rows) ============ function networkLatencyMs(name){ let h = 2166136261; for (let i = 0; i < name.length; i++) { h ^= name.charCodeAt(i); h = Math.imul(h, 16777619); } return 38 + (Math.abs(h) % 145); } function formatNetworkBytes(n){ if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / (1024 * 1024)).toFixed(1)} MB`; } function utf8ByteLength(str){ if (typeof TextEncoder !== 'undefined') { try { return new TextEncoder().encode(str).length; } catch { /* fall through */ } } try { return encodeURIComponent(str).replace(/%../g, 'x').length; } catch { return str.length * 4; } } /** Resolve list mount (handles older HTML ids + creates a node under `.network-pane` if needed). */ function networkMount(){ let el = document.getElementById('gitea-network'); if (el) return el; el = document.querySelector('#pane-network [data-repo-list]'); if (el) return el; el = document.querySelector('#pane-network #network'); // legacy id if (el) return el; el = document.querySelector('#pane-network .network-scroll'); if (el) return el; const pn = document.getElementById('pane-network'); if (!pn) return null; const host = pn.querySelector('.network-pane') || pn; el = document.createElement('div'); el.id = 'gitea-network'; el.className = 'network-scroll'; el.setAttribute('data-network-list', '1'); const hdr = host.querySelector('.network-head'); if (hdr) hdr.insertAdjacentElement('afterend', el); else host.appendChild(el); console.warn('portfolio: created #gitea-network — update deployed index.html to include this div.'); return el; } /** Expand/collapse (delegated on #pane-network; survives innerHTML swaps). */ function onNetworkPaneClick(e){ const btn = e.target.closest('.network-row'); if (!btn) return; const art = btn.closest('.network-entry'); if (!art) return; const panel = art.querySelector('.network-detail'); if (!panel) return; const open = !art.classList.contains('is-open'); art.classList.toggle('is-open', open); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); panel.hidden = !open; } function renderNetwork(){ const wrap = networkMount(); if (!wrap) { console.warn('portfolio: Network tab missing #pane-network · cannot render repo list.'); return; } const GF = window.PORTFOLIO; const repos = GF && Array.isArray(GF.giteaRepos) ? GF.giteaRepos : []; const apiRoot = 'https://git.levkin.ca/api/v1/repos/'; if (repos.length === 0) { wrap.innerHTML = `
No giteaRepos in js/data.js. Hard refresh the page (Cmd+Shift+R). If deploying, redeploy after adding the array.
`; return; } try { wrap.innerHTML = repos.map((r, i) => { const url = apiRoot + String(r.full_name || ''); const ms = networkLatencyMs(String(r.name || r.full_name || 'x')); const bodyObj = { id: i + 1, full_name: r.full_name, name: r.name, html_url: r.html_url, language: r.language || null, description: r.description, }; const json = JSON.stringify(bodyObj, null, 2); const bytes = utf8ByteLength(json); return `
`; }).join(''); } catch (err) { wrap.innerHTML = `
Could not build network list (${_esc(err && err.message ? err.message : String(err))}). Check the Console for details.
`; console.error('renderNetwork', err); } } // ============ SOURCE PANE ============ function renderSource(){ const spec = getSpec(activeSpec); const specTests = tests.filter(t => t.spec === spec.id); const lines = [ [`// ${spec.file} — ${specTests.length} test${specTests.length===1?'':'s'}`], ['import { test, expect } from \'@playwright/test\';'], ['import { person, experience, skills, projects } from \'./fixtures/ilia\';'], [''], [`test.describe('${spec.describe}', () => {`], [''], ]; if (specTests.length === 0) { lines.push([' // no tests in this spec yet — see IDEAS.md']); lines.push(['']); } else { specTests.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(){ const a = document.createElement('a'); a.href = RESUME_PDF; a.download = 'Ilia-Dobkin-SDET-resume.pdf'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); document.body.removeChild(a); consoleLine('ok', '⇩ resume.pdf downloaded'); } // ============ THEME TOGGLE ============ // Three-step cycle: dark → light → hc (WCAG AAA high-contrast) → dark. // Persist via cookie because sandboxed iframes block other storage APIs. const THEME_CYCLE = ['dark', 'light', 'hc']; function readThemeCookie(){ const m = document.cookie.match(/(?:^|;\s*)theme=(dark|light|hc)/); return m ? m[1] : null; } function writeThemeCookie(v){ try { document.cookie = `theme=${v}; path=/; max-age=31536000; SameSite=Lax`; } catch { /* cookies disabled */ } } function initTheme(){ const saved = readThemeCookie() || 'dark'; document.documentElement.setAttribute('data-theme', saved); $('#theme-toggle').addEventListener('click', ()=>{ const cur = document.documentElement.getAttribute('data-theme'); const idx = THEME_CYCLE.indexOf(cur); const next = THEME_CYCLE[(idx + 1) % THEME_CYCLE.length] || THEME_CYCLE[0]; 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); }); } // ============ KEYBOARD SHORTCUTS ============ // Single source of truth: drives both the help overlay and the keydown handler. const SHORTCUTS = [ { html: 'R', desc: 'Run all tests' }, { html: 'X', desc: 'Reset · clear results' }, { html: 'Esc', desc: 'Stop running · close overlay' }, { html: 'T', desc: 'Toggle theme' }, { html: '/', desc: 'Focus --grep filter' }, { html: '19', desc: 'Run visible test by index' }, { html: '?', desc: 'Toggle this overlay' }, ]; function renderShortcuts(){ $('#kshelp-grid').innerHTML = SHORTCUTS.map(s => `
${s.html}${s.desc}
` ).join(''); } function helpOpen(){ return !$('#kshelp').hidden; } function openHelp(){ const el = $('#kshelp'); if (!el.hidden) return; el.hidden = false; // Defer focus so the dialog is in the a11y tree before we focus into it. requestAnimationFrame(()=> el.querySelector('.kshelp__panel').focus()); } function closeHelp(){ $('#kshelp').hidden = true; } function toggleHelp(){ helpOpen() ? closeHelp() : openHelp(); } function isTypingTarget(el){ if (!el) return false; if (el.isContentEditable) return true; const tag = el.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; } function initShortcuts(){ renderShortcuts(); // Delegated on document so a click on the SVG glyph (or any nested element) // still reaches us, and so we don't depend on element lookup timing. document.addEventListener('click', e => { if (e.target.closest('#kshelp-open')) { e.preventDefault(); toggleHelp(); return; } if (e.target.closest('#kshelp-close')) { e.preventDefault(); closeHelp(); return; } if (e.target.closest('#kshelp-scrim')) { closeHelp(); return; } }); document.addEventListener('keydown', e => { // Escape always wins: close overlay > stop run > blur grep. if (e.key === 'Escape') { if (helpOpen()) { closeHelp(); e.preventDefault(); return; } if (isRunningAll) { isRunningAll = false; consoleLine('warn','■ stop requested — finishing current test'); e.preventDefault(); return; } if (isTypingTarget(document.activeElement)) { document.activeElement.blur(); e.preventDefault(); } return; } // `?` toggles the overlay everywhere, even from the grep input. if (e.key === '?') { toggleHelp(); e.preventDefault(); return; } // Otherwise: don't hijack typing or modifier combos (Cmd+R, Ctrl+T, …). if (isTypingTarget(e.target)) return; if (e.metaKey || e.ctrlKey || e.altKey) return; if (helpOpen()) return; const k = e.key; if (k === 'r' || k === 'R') { e.preventDefault(); runAll(); return; } if (k === 'x' || k === 'X') { e.preventDefault(); resetAll(); return; } if (k === 't' || k === 'T') { e.preventDefault(); $('#theme-toggle').click(); return; } if (k === '/') { e.preventDefault(); const g = $('#grep'); g.focus(); g.select(); return; } if (/^[1-9]$/.test(k)) { // Index into the active spec's currently-visible tests (so it pairs // naturally with the open editor tab + any --grep / tag filter). const visible = tests.filter(t => { if (t.spec !== activeSpec) return false; const row = $(`.test[data-id="${t.id}"]`); return !row || row.style.display !== 'none'; }); const t = visible[parseInt(k, 10) - 1]; if (t) { e.preventDefault(); runTest(t.id); } } }); } // ============ EDITOR STRIP (open .spec.ts files) ============ function renderEditorStrip(){ const strip = $('#editor-strip'); if (!strip) return; strip.innerHTML = specs.map(s => { const cnt = specCountFor(s.id); const on = s.id === activeSpec; return ` `; }).join(''); strip.addEventListener('click', e => { const btn = e.target.closest('.espec'); if (!btn) return; activateSpec(btn.dataset.spec); }); } function activateSpec(specId, opts){ opts = opts || {}; const spec = getSpec(specId); if (!spec) return; if (specId === activeSpec && !opts.force) return; activeSpec = spec.id; writeSpecCookie(activeSpec); // Editor strip — toggle is-active. $$('.espec').forEach(b => { const on = b.dataset.spec === activeSpec; b.classList.toggle('is-active', on); b.setAttribute('aria-selected', on ? 'true' : 'false'); }); // Sidebar tree — highlight the suite block matching the open spec. $$('.suite[data-spec]').forEach(s => { s.classList.toggle('is-active', s.dataset.spec === activeSpec); }); // Breadcrumb + sidebar foot. const crumb = document.querySelector('.crumb strong'); if (crumb) crumb.textContent = spec.file; const cnt = specCountFor(activeSpec); const foot = $('#sidebar-foot-meta'); if (foot) foot.textContent = `v1.0.0 · ${cnt} test${cnt===1?'':'s'}`; // Reskin the hero (title/sub/hint) for the active spec. applyHeroForSpec(); // Re-render the source pane for the new spec. renderSource(); // Re-rank the "pending preview" — different spec means a different first // idle test. Clear the previous one, mark the new one (only if it's idle). refreshPendingPreview(); // Apply grep + tag filter; refresh status counts (per active spec). applyFilter(); updateStatusbar(); // Reset scroll for panes whose content swapped (report, source). const panes = ['#pane-report', '#pane-source']; panes.forEach(sel => { const p = document.querySelector(sel); if (p) p.scrollTop = 0; }); if (!opts.silent) consoleLine('info', `▶ opened ${spec.file} (${cnt} test${cnt===1?'':'s'})`); } // Recompute which result row should show the pending-preview body. Called // when the active spec changes (each spec has its own first idle test). function refreshPendingPreview(){ const targetId = firstIdleId(); $$('.result').forEach(art => { const id = art.dataset.id; const want = id === targetId; const has = !!art.dataset.pending; if (want === has) return; const body = $(`#body-${id}`); if (want) { const t = tests.find(x => x.id === id); if (!t) return; art.classList.add('is-pending'); art.classList.add('is-open'); art.dataset.pending = '1'; if (body) body.innerHTML = renderPendingBody(t); } else { art.classList.remove('is-pending'); art.removeAttribute('data-pending'); if (body) body.innerHTML = ''; art.dataset.populated = ''; } }); } // Per-spec hero copy. Portfolio gets the full personal landing block; // other specs get a slimmer header so their (often single) test row // doesn't get visually buried by a 52px name title. const HEROES = { portfolio: { title: 'Ilia Dobkin', sub: 'Senior SDET · Remote (ET) · Canadian citizen · test.describe("portfolio")', hint: 'Click the green next to any test to run it — or press Run all above.', }, projects: { title: 'Projects', sub: 'Self-hosted infrastructure & code · test.describe("projects")', hint: 'Run the test below to surface homelab, sdetProfile, Atlas voice agent, AtAnyRate, and LLM Council cards.', }, skills: { title: 'Skills & Capabilities', sub: 'Tag-driven competencies · test.describe("skills")', hint: 'Each test exposes a different angle — skills, daily stack, leadership, and KPIs.', }, playground: { title: 'Playground', sub: 'Interactive demos & experiments · test.describe("playground")', hint: 'Reserved for fun stuff — see the test below for what\'s on the runway.', }, }; function applyHeroForSpec(){ const hero = document.querySelector('.hero'); if (!hero) return; const conf = HEROES[activeSpec] || HEROES.portfolio; hero.classList.toggle('hero--slim', activeSpec !== 'portfolio'); const titleEl = hero.querySelector('.hero__title'); const subEl = hero.querySelector('.hero__sub'); const hintEl = hero.querySelector('.hero__hint'); if (titleEl) titleEl.textContent = conf.title; if (subEl) subEl.innerHTML = conf.sub; if (hintEl) hintEl.innerHTML = conf.hint; } // ============ INIT ============ function init(){ initTheme(); renderTagBar(); renderTree(); renderResults(); renderTrace(); renderNetwork(); $('#pane-network').addEventListener('click', onNetworkPaneClick); renderEditorStrip(); // activateSpec drives renderSource, applyFilter, updateStatusbar, etc. activateSpec(activeSpec, { force: true, silent: true }); initTabs(); initShortcuts(); $('#results').addEventListener('click', e=>{ const jump = e.target.closest('.tab-link[data-tab]'); if (!jump) return; e.preventDefault(); const tabBtn = $(`.tab[data-tab="${jump.dataset.tab}"]`); if (tabBtn) tabBtn.click(); }); $('#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); } }); const w0 = getWorkers(); const sf = getSpec(activeSpec).file; const sc = specCountFor(activeSpec); consoleLine('info', `Loaded ${sc} test${sc===1?'':'s'} from ${sf} · workers=${w0}`); $('#workers').addEventListener('change', ()=>{ const n = getWorkers(); consoleLine('info', `--workers=${n} · next run will use ${n} parallel worker${n===1?'':'s'}`); }); // Overflow menu (⋯) on the right of the editor bar. Holds --workers= // and --headed; opens on click, closes on outside-click + Escape. initOverflowMenu(); // Swipe left/right on main pane to cycle specs (mobile) (function initSwipeSpecs(){ const main = $('.main'); if (!main) return; let startX = 0, startY = 0; main.addEventListener('touchstart', e => { startX = e.touches[0].clientX; startY = e.touches[0].clientY; }, { passive: true }); main.addEventListener('touchend', e => { const dx = e.changedTouches[0].clientX - startX; const dy = e.changedTouches[0].clientY - startY; if (Math.abs(dx) < 60 || Math.abs(dy) > Math.abs(dx) * 0.6) return; const idx = specs.findIndex(s => s.id === activeSpec); if (dx < 0 && idx < specs.length - 1) activateSpec(specs[idx + 1].id); else if (dx > 0 && idx > 0) activateSpec(specs[idx - 1].id); }, { passive: true }); })(); // Friendly nudge: animate the Run All button briefly on first load setTimeout(()=> $('#run-all').classList.add('pulse'), 600); // Auto-run the first test of the active spec so the report never lands // empty — visitors see body content populated, with the runner animation // still playing once (progress bar fill). Skip if a Reduce Motion user // is here, or if the visitor has already touched a test in the 850ms // since load (e.g. clicked ▶ themselves — no surprise auto-runs). const prefersReduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!prefersReduce) { setTimeout(() => { const anyTouched = tests.some(t => state[t.id].status !== 'idle'); if (anyTouched) return; const firstId = firstIdleId(); if (firstId) runTest(firstId); }, 850); } } function initOverflowMenu(){ const root = $('#overflow'); const btn = $('#overflow-btn'); const menu = $('#overflow-menu'); if (!root || !btn || !menu) return; function open(){ menu.hidden = false; root.classList.add('is-open'); btn.setAttribute('aria-expanded', 'true'); } function close(){ menu.hidden = true; root.classList.remove('is-open'); btn.setAttribute('aria-expanded', 'false'); } btn.addEventListener('click', e => { e.stopPropagation(); menu.hidden ? open() : close(); }); // Outside-click closes; clicks inside the menu (e.g. toggling --headed // or changing --workers) shouldn't close it — visitors usually want to // tweak both in one go. document.addEventListener('click', e => { if (menu.hidden) return; if (root.contains(e.target)) return; close(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && !menu.hidden) { close(); e.stopPropagation(); } }); } document.addEventListener('DOMContentLoaded', init); })();