Files
hermes-agent/tests/install/e2e-assets/playback.html
T
ethernet cd667debfa fix(install-e2e): windows transcripts were empty; player gets #zip= hash + one player per run
Windows transcripts were ZERO bytes: ts-prefix.ps1 formatted with
{0:D2}, but Floor() returns a double and the D specifier is
integer-only - it threw per line, and under the driver's relaxed EAP
every line errored into the void. {0:00} fixes it (custom numeric
format works on doubles). Reproduced the exact pipeline locally
(empty file + Format specifier invalid), verified the fix produces
prefixed merged stdout+stderr with exit code intact. That is also
why the log timeline never auto-synced: there was nothing in the
files to sync.

The GitHub artifact URL 307s to /suites/... server-side and strips
the ?zip= query param. The player now reads the zip URL from a
#zip= HASH param (client-side, survives the redirect) with ?zip=
as fallback; the hash path was verified in a real browser against a
real leg zip (auto-fetch + boot).

Per ethie's design, one player artifact for the whole run: new
leg-player job uploads playback.html (archive:false) before the
matrix legs, the report job needs it, and each ran cell gets TWO
links - 📼 to the player with #zip=<that leg's logs zip> and ⬇️ to
the raw zip. Per-leg player uploads removed from all three run
workflows.
2026-08-13 22:51:44 -04:00

374 lines
14 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hermes install-e2e leg player</title>
<script src="https://unpkg.com/jszip@3.10.1/dist/jszip.min.js"></script>
<style>
:root {
--bg: #0d1117; --panel: #161b22; --panel2: #1c2128; --border: #30363d;
--text: #e6edf3; --dim: #8b949e; --accent: #58a6ff; --gold: #d29922;
}
* { 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.error { color: #f85149; }
#app { display: none; height: 100vh; flex-direction: column; }
/* header: title + controls */
header { display: flex; align-items: center; gap: 10px; padding: 6px 10px;
background: var(--panel); border-bottom: 1px solid var(--border); }
header .title { font-weight: 600; white-space: nowrap; }
header .zipfile { color: var(--dim); overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; flex: 1; direction: rtl; text-align: left; }
/* timer overlay: pinned top-left OVER the video */
#stage { position: relative; flex: 1; display: flex; flex-direction: column;
min-height: 0; }
#videoWrap { position: relative; background: #000; }
#video { width: 100%; max-height: 52vh; display: block; background: #000; }
#timer { position: absolute; top: 8px; left: 8px; z-index: 2;
background: rgba(0,0,0,.72); border: 1px solid var(--border);
border-radius: 6px; padding: 4px 10px; font-size: 15px; font-weight: 600;
color: var(--gold); pointer-events: none; }
#timer .total { color: var(--dim); font-weight: 400; }
#controls { display: flex; align-items: center; gap: 10px; padding: 6px 10px;
background: var(--panel2); border-bottom: 1px solid var(--border); }
button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 3px 12px; font: inherit; cursor: pointer; }
button:hover { border-color: var(--accent); }
select, input[type=range] { accent-color: var(--accent); }
label { color: var(--dim); }
#offsetVal { color: var(--accent); min-width: 5em; display: inline-block; }
/* log area: tabs + panes */
#logs { flex: 1; display: flex; flex-direction: column; min-height: 0; }
#tabs { display: flex; gap: 4px; padding: 6px 10px 0; background: var(--panel);
border-top: 1px solid var(--border); overflow-x: auto; }
#tabs button { border-radius: 6px 6px 0 0; border-bottom: none; background: var(--panel2);
color: var(--dim); }
#tabs button.active { color: var(--text); border-color: var(--accent);
box-shadow: inset 0 -2px 0 var(--accent); }
#tabs button .sz { color: var(--dim); font-size: 11px; }
#paneWrap { flex: 1; position: relative; min-height: 0; }
#pane { position: absolute; inset: 0; overflow: auto; background: var(--bg);
padding: 8px 10px; }
.line { white-space: pre-wrap; word-break: break-all; cursor: pointer;
border-radius: 3px; }
.line:hover { background: rgba(88,166,255,.08); }
.line .ts { color: var(--dim); margin-right: 8px; user-select: none; }
.line.synced { color: var(--accent); }
#noSync { color: var(--dim); padding: 4px 10px; font-size: 12px; }
#syncing { position: sticky; top: 0; z-index: 3; background: rgba(13,17,23,.9);
color: var(--accent); padding: 2px 10px; font-size: 12px; display: none;
border-bottom: 1px solid var(--border); }
</style>
</head>
<body>
<div id="status">loading…</div>
<div id="app">
<header>
<span class="title">install-e2e leg player</span>
<span class="zipfile" id="zipName"></span>
</header>
<div id="stage">
<div id="videoWrap">
<video id="video" controls preload="metadata"></video>
<div id="timer">0:00<span class="total"> / 0:00</span></div>
</div>
<div id="controls">
<button id="play">▶</button>
<button id="skipBack">-10s</button>
<button id="skipFwd">+10s</button>
<select id="rate">
<option value="0.5">0.5×</option>
<option value="1" selected>1×</option>
<option value="2">2×</option>
<option value="4">4×</option>
</select>
<label>sync offset <input id="offset" type="range" min="-60" max="60" step="1" value="0"><span id="offsetVal">0s</span></label>
<label><input id="autoSync" type="checkbox" checked> follow</label>
</div>
<div id="logs">
<div id="tabs"></div>
<div id="paneWrap">
<div id="syncing">— following —</div>
<div id="pane"></div>
</div>
</div>
</div>
</div>
<script>
'use strict';
/* Hermes install-e2e leg player.
*
* One static file. Point it at a run's artifact zip:
* playback.html?zip=<artifact-zip-url>
* 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,
* relative to driver start; the video starts a couple seconds before the
* driver (ffmpeg comes up first), so an offset slider aligns them.
*/
const $ = (id) => document.getElementById(id);
function fmt(s) {
s = Math.max(0, Math.floor(s));
const m = Math.floor(s / 60), r = s % 60;
return `${m}:${String(r).padStart(2, '0')}`;
}
const statusEl = $('status');
function fail(msg) {
statusEl.textContent = msg;
statusEl.className = 'error';
}
/* ── parse [+MM:SS] / [+MM:SS.mmm] line prefixes → seconds ─────────────── */
const TS_RE = /^\[\+(\d+):(\d{2})(?:\.(\d{3}))?\]\s*/;
function parseLines(text) {
const lines = [];
let prevTs = 0;
for (const raw of text.split('\n')) {
const m = raw.match(TS_RE);
if (m) {
prevTs = Number(m[1]) * 60 + Number(m[2]) + (m[3] ? Number(m[3]) / 1000 : 0);
lines.push({ ts: prevTs, text: raw.slice(m[0].length) });
} else {
lines.push({ ts: null, text: raw });
}
}
return lines;
}
/* ── app state ─────────────────────────────────────────────────────────── */
const state = {
zipName: '', videoBlob: null, logs: new Map(), /* name → {lines, hasTs} */
activeTab: null, offset: 0, autoSync: true,
};
/* ── tabs ──────────────────────────────────────────────────────────────── */
function renderTabs() {
const tabs = $('tabs');
tabs.innerHTML = '';
for (const name of [...state.logs.keys()]) {
const b = document.createElement('button');
b.textContent = name;
b.onclick = () => selectTab(name);
if (name === state.activeTab) b.classList.add('active');
tabs.appendChild(b);
}
}
function selectTab(name) {
state.activeTab = name;
renderTabs();
renderPane(true);
}
function renderPane(scrollToEnd) {
const pane = $('pane');
const entry = state.logs.get(state.activeTab);
if (!entry) return;
pane.innerHTML = '';
const frag = document.createDocumentFragment();
for (const ln of entry.lines) {
const div = document.createElement('div');
div.className = 'line';
if (ln.ts !== null) {
const ts = document.createElement('span');
ts.className = 'ts';
ts.textContent = `[+${fmt(ln.ts)}]`;
div.appendChild(ts);
div.dataset.ts = String(ln.ts);
div.title = 'click to seek the video here';
div.onclick = () => {
$('video').currentTime = Math.max(0, ln.ts - state.offset);
$('video').play().catch(() => {});
};
}
div.appendChild(document.createTextNode(ln.text));
frag.appendChild(div);
}
pane.appendChild(frag);
if (scrollToEnd) pane.scrollTop = pane.scrollHeight;
}
/* video-time → log line: last line with ts <= videoTime + offset */
function syncPane() {
if (!state.autoSync) return;
const entry = state.logs.get(state.activeTab);
const pane = $('pane');
if (!entry || !entry.hasTs) return;
const t = $('video').currentTime + state.offset;
let target = null;
for (const div of pane.children) {
if (!div.dataset.ts) continue;
const ts = Number(div.dataset.ts);
if (ts <= t) target = div;
else break;
}
if (!target) return;
const syncing = $('syncing');
syncing.style.display = 'block';
syncing.textContent = `following @ ${fmt(t)} (+${state.offset}s offset)`;
const paneRect = pane.getBoundingClientRect();
const tRect = target.getBoundingClientRect();
const targetTop = tRect.top - paneRect.top + pane.scrollTop;
if (targetTop < pane.scrollTop || targetTop > pane.scrollTop + pane.clientHeight - 40) {
pane.scrollTop = targetTop - pane.clientHeight / 3;
}
for (const div of pane.children) div.classList.remove('synced');
target.classList.add('synced');
}
/* ── zip loading ───────────────────────────────────────────────────────── */
async function loadZipFromBlob(blob, label) {
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
const names = Object.keys(zip.files).filter((n) => !zip.files[n].dir);
const videoName = names.find((n) => n === 'recording.mp4') ||
names.find((n) => n === 'recording.mkv');
if (!videoName) throw new Error('no recording.mp4 / recording.mkv in the zip');
state.videoBlob = await zip.files[videoName].async('blob');
for (const n of names.filter((n) => n.endsWith('.log'))) {
const text = await zip.files[n].async('string');
const lines = parseLines(text);
state.logs.set(n, { lines, hasTs: lines.some((l) => l.ts !== null) });
}
if (state.logs.size === 0) throw new Error('no *.log files in the zip');
state.zipName = label || 'leg artifact';
state.activeTab = [...state.logs.keys()][0];
}
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: <a href="${url}" style="color:#58a6ff">${url.split('/').pop()}</a> ` +
`— then drop it anywhere on this page (or click here to pick the file).`;
statusEl.onclick = () => fileInput.click();
return false;
}
return true;
}
/**
* Resolve the zip URL: hash param wins (?zip= gets stripped by GitHub's
* artifact redirect, the hash survives), then the query param.
* @returns {string | null}
*/
function resolveZipUrl() {
const m = location.hash.match(/(?:^|[#&])zip=([^&]+)/);
if (m) {
try { return decodeURIComponent(m[1]); } catch { return m[1]; }
}
return new URLSearchParams(location.search).get('zip');
}
/* drag-drop + file picker: the universal transport for auth-gated zips */
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.zip,application/zip';
fileInput.style.display = 'none';
fileInput.addEventListener('change', async () => {
const f = fileInput.files && fileInput.files[0];
if (!f) return;
try {
await loadZipFromBlob(f, f.name);
boot();
} catch (e) {
fail(`could not load zip: ${e.message}`);
}
});
document.body.appendChild(fileInput);
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', async (e) => {
e.preventDefault();
const f = [...(e.dataTransfer?.files ?? [])][0];
if (!f || !/\.zip$/i.test(f.name)) { fail('drop a .zip artifact file'); return; }
try {
await loadZipFromBlob(f, f.name);
boot();
} catch (err) {
fail(`could not load zip: ${err.message}`);
}
});
/* ── boot ──────────────────────────────────────────────────────────────── */
function boot() {
statusEl.style.display = 'none';
$('app').style.display = 'flex';
$('zipName').textContent = state.zipName;
const video = $('video');
video.src = URL.createObjectURL(state.videoBlob);
video.ontimeupdate = () => {
$('timer').innerHTML =
`${fmt(video.currentTime)}<span class="total"> / ${fmt(video.duration || 0)}</span>`;
syncPane();
};
video.onloadedmetadata = () => {
$('timer').innerHTML =
`${fmt(video.currentTime)}<span class="total"> / ${fmt(video.duration || 0)}</span>`;
};
$('play').onclick = () => {
if (video.paused) video.play().catch(() => {});
else video.pause();
};
$('skipBack').onclick = () => { video.currentTime = Math.max(0, video.currentTime - 10); };
$('skipFwd').onclick = () => { video.currentTime = Math.min(video.duration || 0, video.currentTime + 10); };
$('rate').onchange = (e) => { video.playbackRate = Number(e.target.value); };
$('offset').oninput = (e) => {
state.offset = Number(e.target.value);
$('offsetVal').textContent = `${state.offset > 0 ? '+' : ''}${state.offset}s`;
};
$('autoSync').onchange = (e) => {
state.autoSync = e.target.checked;
$('syncing').style.display = state.autoSync ? 'block' : 'none';
};
video.addEventListener('play', () => { $('play').textContent = '⏸'; });
video.addEventListener('pause', () => { $('play').textContent = '▶'; });
renderTabs();
renderPane(true);
}
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();
} 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).';
statusEl.onclick = () => fileInput.click();
}
}
main();
</script>
</body>
</html>