From 4c3ee593c0f2d8f4a845a38fc811d09d2fbf44ed Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 14 Aug 2026 15:58:51 -0400 Subject: [PATCH] feat(playback): ansi n styles --- tests/install/e2e-assets/playback.html | 87 +++++++++++++++++--------- 1 file changed, 58 insertions(+), 29 deletions(-) diff --git a/tests/install/e2e-assets/playback.html b/tests/install/e2e-assets/playback.html index fb309d0d03..df1026d77c 100644 --- a/tests/install/e2e-assets/playback.html +++ b/tests/install/e2e-assets/playback.html @@ -13,8 +13,8 @@ * { box-sizing: border-box; } body { margin: 0; background: var(--bg); color: var(--text); font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - #status { position: fixed; inset: 0; display: flex; align-items: center; - justify-content: center; color: var(--dim); font-size: 15px; z-index: 5; } + #status {text-align: center; position: fixed; inset: 0; display: flex-col; align-items: center; + justify-content: center; color: var(--dim); font-size: 15px; z-index: 5; padding: 24px;} #status.error { color: #f85149; } #app { display: none; height: 100vh; flex-direction: column; } @@ -107,7 +107,7 @@ /* Hermes install-e2e leg player. * * One static file. Point it at a run's artifact zip: - * playback.html?zip= + * playback.html#zip= * It unzips in-browser (JSZip from CDN), plays recording.mp4 (preferred) or * recording.mkv, and renders every *.log with a top-left timer and * video↔log sync. Sync axis: driver log lines carry a [+MM:SS] prefix, @@ -171,6 +171,43 @@ function selectTab(name) { renderPane(true); } +function ansiToHtml(text) { + const colors = { + 30: 'black', 31: 'red', 32: 'green', 33: 'yellow', + 34: 'blue', 35: 'magenta', 36: 'cyan', 37: 'white', + 90: 'gray', 91: 'lightred', 92: 'lightgreen', 93: 'lightyellow', + 94: 'lightblue', 95: 'lightmagenta', 96: 'lightcyan', 97: 'white' + }; + + const escapeHtml = s => s.replace(/[&<>]/g, c => ({'&':'&','<':'<','>':'>'}[c])); + + let html = ''; + let openSpan = false; + const parts = text.split(/\x1b\[([0-9;]*)m/); + + // parts alternates: [text, codes, text, codes, ...] + for (let i = 0; i < parts.length; i++) { + if (i % 2 === 0) { + html += escapeHtml(parts[i]); + } else { + if (openSpan) { html += ''; openSpan = false; } + const codes = parts[i].split(';').map(Number); + const styles = []; + for (const code of codes) { + if (code === 0) continue; // reset + if (code === 1) styles.push('font-weight:bold'); + else if (colors[code]) styles.push(`color:${colors[code]}`); + } + if (styles.length) { + html += ``; + openSpan = true; + } + } + } + if (openSpan) html += ''; + return html; +} + function renderPane(scrollToEnd) { const pane = $('pane'); const entry = state.logs.get(state.activeTab); @@ -198,7 +235,9 @@ function renderPane(scrollToEnd) { file.textContent = `${ln.file}: `; div.appendChild(file); } - div.appendChild(document.createTextNode(ln.text)); + const text = document.createElement('span') + text.innerHTML = ansiToHtml(ln.text); + div.appendChild(text); frag.appendChild(div); } pane.appendChild(frag); @@ -278,26 +317,20 @@ async function loadZipFromBlob(blob, label) { } async function loadZip(url) { - statusEl.textContent = `fetching ${url} …`; - try { - const resp = await fetch(url, { credentials: 'same-origin' }); - if (!resp.ok) throw new Error(`zip fetch failed: ${resp.status} ${resp.statusText}`); - await loadZipFromBlob(await resp.blob(), url.split('/').pop()); - } catch (e) { - // GitHub artifact downloads are auth-gated: a cross-origin page (this - // file opened locally) cannot fetch them. The page then becomes the - // download-and-drop path — a normal link click DOES carry the user's - // GitHub session. - statusEl.className = 'error'; - statusEl.innerHTML = - `Could not fetch the zip directly (GitHub artifact downloads need your session — ` + - `a locally-opened page cannot send it). ` + - `Download the leg zip: ${url.split('/').pop()} ` + - `— then drop it anywhere on this page (or click here to pick the file).`; - statusEl.onclick = () => fileInput.click(); - return false; + // GitHub artifact downloads are auth-gated: a cross-origin page (this + // file opened locally) cannot fetch them. The page then becomes the + // download-and-drop path — a normal link click DOES carry the user's + // GitHub session. + statusEl.innerHTML = + `
Download the zip from here: ${url.split('/').pop()}
` + + `
then drop it anywhere on this page
(or click anywhere on the page to pick the file).
`; + statusEl.onclick = (e) => { + fileInput.click() + }; + egg.onclick = (e) => { + e.stopPropagation(); } - return true; + return false; } /** @@ -385,16 +418,12 @@ function boot() { async function main() { const zipUrl = resolveZipUrl(); if (zipUrl) { - // Attempt the direct fetch (works when the page is served on - // github.com itself — same-origin, session cookies flow). On failure - // the status line becomes a download link + drop target. - if (await loadZip(zipUrl)) boot(); + loadZip(zipUrl) } else { // No zip param: this is the manual path — the leg player artifact // page or a local copy. Drop or pick a leg zip. - statusEl.className = 'error'; statusEl.innerHTML = - 'Drop a leg artifact zip anywhere on this page (or click here to pick the file).'; + 'Drop a leg artifact zip anywhere on this page
(or click here to pick the file).'; statusEl.onclick = () => fileInput.click(); } }