Compare commits
14 Commits
b77938ab4a
...
be1d939afd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be1d939afd | ||
|
|
93d3b05263 | ||
|
|
eb6aae7ae7 | ||
|
|
09125f3f5c | ||
|
|
7bc5b4f8b5 | ||
|
|
d65b9e032a | ||
|
|
fcd98b6a4d | ||
|
|
4bc15a55e3 | ||
|
|
64f8eae92b | ||
|
|
3f37c2a9ef | ||
|
|
233bf94e4c | ||
|
|
722df3125f | ||
|
|
2e58924971 | ||
|
|
b6dd2be40f |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -40,3 +40,7 @@ legacy/releases/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
plans/codex-shots*/
|
plans/codex-shots*/
|
||||||
plans/codex-report*.md
|
plans/codex-report*.md
|
||||||
|
|
||||||
|
# performance harness artifacts and temp files
|
||||||
|
perf/fixtures/
|
||||||
|
perf/.tmp/
|
||||||
|
|||||||
@@ -623,8 +623,9 @@ const API = {
|
|||||||
// Pre-resolve streams for videos the user is likely to play next (server
|
// Pre-resolve streams for videos the user is likely to play next (server
|
||||||
// /api/streams/warm). Each id is warmed at most once per 20 min per tab.
|
// /api/streams/warm). Each id is warmed at most once per 20 min per tab.
|
||||||
const warmedAt = new Map();
|
const warmedAt = new Map();
|
||||||
|
const knownServerCachedIds = new Set();
|
||||||
function warmStreams(ids) {
|
function warmStreams(ids) {
|
||||||
if (!WEB || navigator.onLine === false) return;
|
if (!WEB || navigator.onLine === false || navigator.connection?.saveData) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue;
|
if (!id || !/^[A-Za-z0-9_-]{11}$/.test(id) || cachedIds.has(id)) continue;
|
||||||
@@ -634,6 +635,14 @@ function warmStreams(ids) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function warmNextQueue() {
|
||||||
|
if (!window.PrewarmNext || queueIndex < 0) return;
|
||||||
|
warmStreams(window.PrewarmNext.candidates(queue, queueIndex, data.settings, 2, {
|
||||||
|
offline: cachedIds,
|
||||||
|
serverCached: knownServerCachedIds,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve a native file path to a URL the WebView can load (Tauri asset
|
// Resolve a native file path to a URL the WebView can load (Tauri asset
|
||||||
// protocol). Returns null on shells without it.
|
// protocol). Returns null on shells without it.
|
||||||
function toAssetUrl(path) {
|
function toAssetUrl(path) {
|
||||||
@@ -2332,6 +2341,7 @@ const Player = {
|
|||||||
if (seq !== this._loadSeq) return; // a newer load took over meanwhile
|
if (seq !== this._loadSeq) return; // a newer load took over meanwhile
|
||||||
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
|
if (!res || !res.ok) throw new Error(res?.error || 'Could not load streams');
|
||||||
current = res.data;
|
current = res.data;
|
||||||
|
if (current.serverCached && current.meta?.id) knownServerCachedIds.add(current.meta.id);
|
||||||
// Merge richer metadata we may already have from the list card.
|
// Merge richer metadata we may already have from the list card.
|
||||||
current.meta = { ...videoObj, ...current.meta };
|
current.meta = { ...videoObj, ...current.meta };
|
||||||
|
|
||||||
@@ -3014,7 +3024,7 @@ function wirePlayerEvents() {
|
|||||||
perfMeasure('ytp:tap-to-play', 'ytp:tap');
|
perfMeasure('ytp:tap-to-play', 'ytp:tap');
|
||||||
try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ }
|
try { performance.clearMarks('ytp:tap'); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
if (Array.isArray(queue) && queueIndex >= 0) warmStreams(queue.slice(queueIndex + 1, queueIndex + 3).map((x) => x && x.id));
|
warmNextQueue();
|
||||||
showSpinner(false);
|
showSpinner(false);
|
||||||
Player.clearBufferGrace();
|
Player.clearBufferGrace();
|
||||||
if (Player.secondary && !el.paused && Player.secondary.paused) {
|
if (Player.secondary && !el.paused && Player.secondary.paused) {
|
||||||
@@ -4729,8 +4739,7 @@ const Notes = (() => {
|
|||||||
const v = $('stgVideo'), a = $('stgAudio');
|
const v = $('stgVideo'), a = $('stgAudio');
|
||||||
if (v) v.classList.toggle('on', !ao); if (a) a.classList.toggle('on', ao);
|
if (v) v.classList.toggle('on', !ao); if (a) a.classList.toggle('on', ao);
|
||||||
}
|
}
|
||||||
setInterval(chips, 1500);
|
VisibleTimer.setIntervalWhenVisible(chips, 1500, { immediate: true });
|
||||||
setTimeout(chips, 400);
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -5069,7 +5078,7 @@ const Remote = (() => {
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Remote (this phone drives a host)
|
// Remote (this phone drives a host)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
const rc = { ws: null, open: false, state: null, stateAt: 0, queue: { items: [], idx: -1 }, retry: null, ping: null, latency: null, online: false, seeking: false, volTimer: null, drag: null };
|
const rc = { ws: null, open: false, state: null, stateAt: 0, queue: { items: [], idx: -1 }, retry: null, ping: null, seekTimer: null, latency: null, online: false, seeking: false, volTimer: null, drag: null };
|
||||||
|
|
||||||
const rcSend = (m) => { if (rc.ws && rc.ws.readyState === 1) rc.ws.send(JSON.stringify(m)); };
|
const rcSend = (m) => { if (rc.ws && rc.ws.readyState === 1) rc.ws.send(JSON.stringify(m)); };
|
||||||
const cmd = (name, args = {}) => rcSend({ type: 'cmd', cmd: name, ...args });
|
const cmd = (name, args = {}) => rcSend({ type: 'cmd', cmd: name, ...args });
|
||||||
@@ -5299,6 +5308,7 @@ const Remote = (() => {
|
|||||||
|
|
||||||
function openRemoteView({ code = '' } = {}) {
|
function openRemoteView({ code = '' } = {}) {
|
||||||
rc.open = true;
|
rc.open = true;
|
||||||
|
rc.seekTimer = VisibleTimer.setIntervalWhenVisible(renderSeek, 500);
|
||||||
// This phone is the remote now — don't also play here.
|
// This phone is the remote now — don't also play here.
|
||||||
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
|
try { if (Player.master && !Player.master.paused) Player.pause(); } catch { /* nothing loaded */ }
|
||||||
$('remoteView').classList.remove('hidden');
|
$('remoteView').classList.remove('hidden');
|
||||||
@@ -5309,6 +5319,8 @@ const Remote = (() => {
|
|||||||
|
|
||||||
function closeRemoteView() {
|
function closeRemoteView() {
|
||||||
rc.open = false;
|
rc.open = false;
|
||||||
|
rc.seekTimer?.clear();
|
||||||
|
rc.seekTimer = null;
|
||||||
clearTimeout(rc.retry);
|
clearTimeout(rc.retry);
|
||||||
clearInterval(rc.ping);
|
clearInterval(rc.ping);
|
||||||
const ws = rc.ws;
|
const ws = rc.ws;
|
||||||
@@ -5387,7 +5399,6 @@ const Remote = (() => {
|
|||||||
toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off');
|
toast(next ? `Screen pauses in ${next} min` : 'Sleep timer off');
|
||||||
});
|
});
|
||||||
$('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) }));
|
$('rvService').addEventListener('click', () => cmd('service', { on: !(rc.state && rc.state.service) }));
|
||||||
setInterval(() => { if (rc.open) renderSeek(); }, 500);
|
|
||||||
// Hosts keep phones current once a second while anything plays.
|
// Hosts keep phones current once a second while anything plays.
|
||||||
setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000);
|
setInterval(() => { if (host.ws && host.remotes.length) { pushState(); pushQueue(); } }, 1000);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
@@ -5800,7 +5811,7 @@ const CatalogSync = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener('online', flush);
|
window.addEventListener('online', flush);
|
||||||
if (WEB) setInterval(flush, 60000);
|
if (WEB) VisibleTimer.setIntervalWhenVisible(flush, 60000, { immediate: true });
|
||||||
return { send, flush };
|
return { send, flush };
|
||||||
})();
|
})();
|
||||||
function collectSearchMetadata(q, cards) { if (WEB) CatalogSync.send(q, cards); }
|
function collectSearchMetadata(q, cards) { if (WEB) CatalogSync.send(q, cards); }
|
||||||
@@ -8043,6 +8054,7 @@ function playFromList(list, index, source = '', { playFull = false } = {}) {
|
|||||||
unshuffledQueue = null;
|
unshuffledQueue = null;
|
||||||
// Play-in-full keeps the list order — a service set plays as arranged.
|
// Play-in-full keeps the list order — a service set plays as arranged.
|
||||||
if (data.settings.shuffle && !playFull) applyShuffle();
|
if (data.settings.shuffle && !playFull) applyShuffle();
|
||||||
|
warmNextQueue();
|
||||||
Player.loadVideo(queue[queueIndex]);
|
Player.loadVideo(queue[queueIndex]);
|
||||||
renderUpNext();
|
renderUpNext();
|
||||||
}
|
}
|
||||||
@@ -11061,7 +11073,7 @@ function setupViewportAnchorGuard() {
|
|||||||
// Last-resort safety net for offsets none of the events above report:
|
// Last-resort safety net for offsets none of the events above report:
|
||||||
// a 2s tick that reads scrollY (cheap) and snaps back only when displaced,
|
// a 2s tick that reads scrollY (cheap) and snaps back only when displaced,
|
||||||
// so "nav taps silently do nothing" can never persist longer than a beat.
|
// so "nav taps silently do nothing" can never persist longer than a beat.
|
||||||
setInterval(reanchor, 2000);
|
VisibleTimer.setIntervalWhenVisible(reanchor, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -11304,7 +11316,7 @@ function rotateChips() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setInterval(rotateChips, 8000);
|
VisibleTimer.setIntervalWhenVisible(rotateChips, 8000);
|
||||||
|
|
||||||
document.querySelectorAll('.chip').forEach((c) => {
|
document.querySelectorAll('.chip').forEach((c) => {
|
||||||
c.addEventListener('click', () => {
|
c.addEventListener('click', () => {
|
||||||
@@ -12151,8 +12163,7 @@ function checkBuildTag() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pollBuildTag() {
|
function pollBuildTag() {
|
||||||
checkBuildTag();
|
VisibleTimer.setIntervalWhenVisible(checkBuildTag, 5 * 60 * 1000, { immediate: true });
|
||||||
setInterval(checkBuildTag, 5 * 60 * 1000); // recheck every 5 minutes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -693,6 +693,8 @@
|
|||||||
<script src="piano.js"></script>
|
<script src="piano.js"></script>
|
||||||
<script src="setlist-import.js"></script>
|
<script src="setlist-import.js"></script>
|
||||||
<script src="car-mode.js"></script>
|
<script src="car-mode.js"></script>
|
||||||
|
<script src="visible-timer.js"></script>
|
||||||
|
<script src="prewarm-next.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
37
frontend/prewarm-next.js
Normal file
37
frontend/prewarm-next.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/* Pure queue candidate selection for stream prewarming. */
|
||||||
|
(function (root, factory) {
|
||||||
|
if (typeof module === 'object' && module.exports) module.exports = factory();
|
||||||
|
else root.PrewarmNext = factory();
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function candidates(queue, index, state = {}, n = 2, known = {}) {
|
||||||
|
if (!Array.isArray(queue) || !queue.length || !Number.isInteger(index) || index < 0 || index >= queue.length) return [];
|
||||||
|
if (state.loopOne) return [];
|
||||||
|
const count = Math.max(0, Math.floor(Number(n) || 0));
|
||||||
|
if (!count) return [];
|
||||||
|
const repeatAll = state.repeatMode === 'all' || state.repeatAll === true;
|
||||||
|
const offline = new Set(known.offline || []);
|
||||||
|
const serverCached = new Set(known.serverCached || []);
|
||||||
|
const current = queue[index];
|
||||||
|
const currentId = typeof current === 'string' ? current : current && current.id;
|
||||||
|
const seen = new Set(currentId ? [currentId] : []);
|
||||||
|
const ids = [];
|
||||||
|
for (let step = 1; step < queue.length + (repeatAll ? 1 : 0) && ids.length < count; step++) {
|
||||||
|
let at = index + step;
|
||||||
|
if (at >= queue.length) {
|
||||||
|
if (!repeatAll) break;
|
||||||
|
at %= queue.length;
|
||||||
|
}
|
||||||
|
const item = queue[at];
|
||||||
|
const id = typeof item === 'string' ? item : item && item.id;
|
||||||
|
if (!id || seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
if (offline.has(id) || serverCached.has(id) || (item && item.serverCached)) continue;
|
||||||
|
ids.push(id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { candidates };
|
||||||
|
});
|
||||||
26
frontend/prewarm-next.test.js
Normal file
26
frontend/prewarm-next.test.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use strict';
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { candidates } = require('./prewarm-next.js');
|
||||||
|
|
||||||
|
const q = ['a', 'b', 'c', 'd'].map(id => ({ id }));
|
||||||
|
|
||||||
|
test('returns the next n queue ids in existing order', () => {
|
||||||
|
assert.deepEqual(candidates(q, 0, {}, 2), ['b', 'c']);
|
||||||
|
});
|
||||||
|
test('repeat all wraps and does not duplicate ids', () => {
|
||||||
|
assert.deepEqual(candidates(q, 3, { repeatMode: 'all' }, 2), ['a', 'b']);
|
||||||
|
assert.deepEqual(candidates([{ id: 'x' }, { id: 'x' }], 0, { repeatMode: 'all' }, 3), []);
|
||||||
|
});
|
||||||
|
test('repeat off stops at the end and loop one has no candidates', () => {
|
||||||
|
assert.deepEqual(candidates(q, 3, {}, 2), []);
|
||||||
|
assert.deepEqual(candidates(q, 0, { loopOne: true, repeatMode: 'all' }, 2), []);
|
||||||
|
});
|
||||||
|
test('skips offline and known server cached ids and deduplicates', () => {
|
||||||
|
assert.deepEqual(candidates(q, 0, {}, 3, { offline: ['b'], serverCached: ['c'] }), ['d']);
|
||||||
|
assert.deepEqual(candidates([{ id: 'a' }, { id: 'b', serverCached: true }, { id: 'c' }], 0, {}, 2), ['c']);
|
||||||
|
});
|
||||||
|
test('shuffle follows the queue order selected before advance', () => {
|
||||||
|
const shuffled = ['a', 'd', 'b', 'c'].map(id => ({ id }));
|
||||||
|
assert.deepEqual(candidates(shuffled, 0, { shuffle: true }, 2), ['d', 'b']);
|
||||||
|
});
|
||||||
29
frontend/shell-consistency.test.js
Normal file
29
frontend/shell-consistency.test.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { readFileSync, existsSync } = require('node:fs');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
|
||||||
|
const root = join(__dirname, '..');
|
||||||
|
const html = readFileSync(join(__dirname, 'index.html'), 'utf8');
|
||||||
|
const sw = readFileSync(join(__dirname, 'sw.js'), 'utf8');
|
||||||
|
const shellMatch = sw.match(/const SHELL = \[([\s\S]*?)\n\];/);
|
||||||
|
assert.ok(shellMatch, 'service worker SHELL list is present');
|
||||||
|
const shell = [...shellMatch[1].matchAll(/['"](\/[^'"]+)['"]/g)].map((match) => match[1]);
|
||||||
|
const listed = new Set(shell);
|
||||||
|
|
||||||
|
test('every local script and stylesheet in index.html is precached by the service worker', () => {
|
||||||
|
const localAssets = [];
|
||||||
|
for (const match of html.matchAll(/<(?:script\b[^>]*\bsrc|link\b[^>]*\bhref)="([^"]+)"/gi)) {
|
||||||
|
const url = match[1];
|
||||||
|
if (/^(?:[a-z]+:|\/|#)/i.test(url)) continue;
|
||||||
|
if (!/\.(?:js|css)(?:[?#]|$)/i.test(url)) continue;
|
||||||
|
localAssets.push(`/${url.split(/[?#]/, 1)[0]}`);
|
||||||
|
}
|
||||||
|
assert.ok(localAssets.length > 0, 'found local script/style assets');
|
||||||
|
for (const asset of localAssets) assert.ok(listed.has(asset), `${asset} appears in SHELL`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every SHELL asset exists on disk and there are no duplicate entries', () => {
|
||||||
|
assert.equal(new Set(shell).size, shell.length, 'SHELL has no duplicate entries');
|
||||||
|
for (const asset of shell) assert.ok(existsSync(join(__dirname, asset.slice(1))), `${asset} exists`);
|
||||||
|
});
|
||||||
@@ -70,6 +70,7 @@ const SHELL = [
|
|||||||
'/flag-ui.js',
|
'/flag-ui.js',
|
||||||
'/export.js',
|
'/export.js',
|
||||||
'/async-guard.js',
|
'/async-guard.js',
|
||||||
|
'/visible-timer.js',
|
||||||
'/sw-update.js',
|
'/sw-update.js',
|
||||||
'/fingerprint.js',
|
'/fingerprint.js',
|
||||||
'/opfs.js',
|
'/opfs.js',
|
||||||
@@ -124,6 +125,7 @@ const SHELL = [
|
|||||||
'/piano.js',
|
'/piano.js',
|
||||||
'/setlist-import.js',
|
'/setlist-import.js',
|
||||||
'/car-mode.js',
|
'/car-mode.js',
|
||||||
|
'/prewarm-next.js',
|
||||||
'/car-mode.css',
|
'/car-mode.css',
|
||||||
'/lower-third.css',
|
'/lower-third.css',
|
||||||
'/settings-sections.css',
|
'/settings-sections.css',
|
||||||
|
|||||||
41
frontend/visible-timer.js
Normal file
41
frontend/visible-timer.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function setIntervalWhenVisible(fn, ms, { immediate = false } = {}) {
|
||||||
|
if (typeof fn !== 'function') throw new TypeError('fn must be a function');
|
||||||
|
const doc = root.document;
|
||||||
|
if (!doc) throw new Error('document is required');
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
let stopped = false;
|
||||||
|
const clear = () => {
|
||||||
|
if (timer !== null) root.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
const start = (runNow) => {
|
||||||
|
if (stopped || doc.hidden) return;
|
||||||
|
clear();
|
||||||
|
if (runNow) fn();
|
||||||
|
timer = root.setInterval(fn, ms);
|
||||||
|
};
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
if (doc.hidden) clear();
|
||||||
|
else start(immediate);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!doc.hidden) start(immediate);
|
||||||
|
doc.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
return {
|
||||||
|
clear() {
|
||||||
|
if (stopped) return;
|
||||||
|
stopped = true;
|
||||||
|
clear();
|
||||||
|
doc.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { setIntervalWhenVisible };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else root.VisibleTimer = api;
|
||||||
|
})(globalThis);
|
||||||
90
frontend/visible-timer.test.js
Normal file
90
frontend/visible-timer.test.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { setIntervalWhenVisible } = require('./visible-timer');
|
||||||
|
|
||||||
|
class FakeDocument {
|
||||||
|
hidden = false;
|
||||||
|
listeners = new Set();
|
||||||
|
addEventListener(type, listener) { if (type === 'visibilitychange') this.listeners.add(listener); }
|
||||||
|
removeEventListener(type, listener) { if (type === 'visibilitychange') this.listeners.delete(listener); }
|
||||||
|
setHidden(hidden) {
|
||||||
|
this.hidden = hidden;
|
||||||
|
for (const listener of [...this.listeners]) listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeClock(t, hidden = false) {
|
||||||
|
const doc = new FakeDocument();
|
||||||
|
doc.hidden = hidden;
|
||||||
|
const original = {
|
||||||
|
document: globalThis.document,
|
||||||
|
setInterval: globalThis.setInterval,
|
||||||
|
clearInterval: globalThis.clearInterval,
|
||||||
|
};
|
||||||
|
let nextId = 1;
|
||||||
|
const timers = new Map();
|
||||||
|
globalThis.document = doc;
|
||||||
|
globalThis.setInterval = (fn, ms) => {
|
||||||
|
const id = nextId++;
|
||||||
|
timers.set(id, { fn, ms });
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
globalThis.clearInterval = (id) => timers.delete(id);
|
||||||
|
t.after(() => {
|
||||||
|
globalThis.document = original.document;
|
||||||
|
globalThis.setInterval = original.setInterval;
|
||||||
|
globalThis.clearInterval = original.clearInterval;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
doc,
|
||||||
|
timers,
|
||||||
|
tick() { for (const timer of [...timers.values()]) timer.fn(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('runs on a visible page and has one interval until hidden', (t) => {
|
||||||
|
const clock = fakeClock(t);
|
||||||
|
let calls = 0;
|
||||||
|
const handle = setIntervalWhenVisible(() => calls++, 250);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
assert.equal([...clock.timers.values()][0].ms, 250);
|
||||||
|
clock.tick();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
clock.doc.setHidden(true);
|
||||||
|
assert.equal(clock.timers.size, 0);
|
||||||
|
clock.tick();
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
handle.clear();
|
||||||
|
assert.equal(clock.doc.listeners.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('immediate mode runs once initially and once when becoming visible', (t) => {
|
||||||
|
const clock = fakeClock(t);
|
||||||
|
let calls = 0;
|
||||||
|
const handle = setIntervalWhenVisible(() => calls++, 1000, { immediate: true });
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
clock.doc.setHidden(true);
|
||||||
|
assert.equal(clock.timers.size, 0);
|
||||||
|
clock.doc.setHidden(false);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
handle.clear();
|
||||||
|
clock.doc.setHidden(true);
|
||||||
|
clock.doc.setHidden(false);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
assert.equal(clock.timers.size, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a page that starts hidden waits until visible before calling or scheduling', (t) => {
|
||||||
|
const clock = fakeClock(t, true);
|
||||||
|
let calls = 0;
|
||||||
|
const handle = setIntervalWhenVisible(() => calls++, 500, { immediate: true });
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(clock.timers.size, 0);
|
||||||
|
clock.doc.setHidden(false);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.equal(clock.timers.size, 1);
|
||||||
|
handle.clear();
|
||||||
|
});
|
||||||
143
perf/README.md
Normal file
143
perf/README.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Performance Baseline Harness (`perf/`)
|
||||||
|
|
||||||
|
Automated performance baseline and regression harness for the **ytplayer PWA**.
|
||||||
|
|
||||||
|
The harness drives the real Bun backend server (`server/server.js`) serving static Brotli-compressed assets, combined with Playwright browser automation on Chromium and WebKit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture
|
||||||
|
|
||||||
|
### Backend Server (`server/server.js`)
|
||||||
|
- Runs as an isolated child process using **Bun** on an ephemeral port.
|
||||||
|
- Configured with dedicated scratch directories under `perf/.tmp/` (`DATA_DIR`, `DB_PATH`, `MEDIA_DIR`) so no production or developer data is ever touched.
|
||||||
|
- Employs the real static asset delivery stack (`Brotli` encoding, `ETag` generation, `Cache-Control` immutable caching for version-stamped assets, and `computeBuildTag()`).
|
||||||
|
|
||||||
|
### Browser Automation & Network Throttling
|
||||||
|
- **Chromium**:
|
||||||
|
- Network emulation via Chrome DevTools Protocol (`Network.emulateNetworkConditions`).
|
||||||
|
- CPU throttling at **4× slowdown** (`Emulation.setCPUThrottlingRate`).
|
||||||
|
- Unrestricted audio/video playback via `--autoplay-policy=no-user-gesture-required`.
|
||||||
|
- **WebKit**:
|
||||||
|
- WebKit on Linux lacks CDP network emulation capabilities.
|
||||||
|
- Throttling and stalling are handled by a dedicated local proxy (`perf/proxy.mjs`) sitting between WebKit and the Bun server.
|
||||||
|
- The proxy shapes traffic by pacing chunk delivery through backpressured streams and injecting round-trip latency and stalls.
|
||||||
|
- **Network Profiles**:
|
||||||
|
- **`lte`**: 1.6 Mbps (200 KB/s) download, 750 kbps upload, 150 ms round-trip latency.
|
||||||
|
- **`lossy`**: 1.0 Mbps (125 KB/s) download, 250 ms latency, with intermittent connection stalls (every 7th request delayed by 1,500 ms).
|
||||||
|
|
||||||
|
### Shell Fixture Extractor (`perf/make-shell-fixture.sh`)
|
||||||
|
- Archives the frontend from git commits (`git archive <commit> frontend`) into `perf/fixtures/shell-<tag>/`.
|
||||||
|
- Computes the build tag using the exact sha256 walk algorithm from `server/server.js computeBuildTag()`.
|
||||||
|
- Validates the computed tag against live production (`https://worship.hesed.sbs/api/version`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Test Scenarios
|
||||||
|
|
||||||
|
1. **`cold` (Cold First Load)**:
|
||||||
|
- Fresh browser context with empty cache, no service worker, and clean storage.
|
||||||
|
- Measures initial page fetch, FCP, LCP, DOMContentLoaded, boot milestone (`#cards` render), media playback readiness, service worker precaching duration, and origin storage quota.
|
||||||
|
2. **`warm` (Warm Reload under SW)**:
|
||||||
|
- Re-navigates the page with active Service Worker and primed HTTP cache.
|
||||||
|
- Measures cached asset retrieval efficiency and second-load boot performance.
|
||||||
|
3. **`offline` (Offline Reload)**:
|
||||||
|
- Sets offline state and reloads the application.
|
||||||
|
- Verifies the app boots entirely from `CacheStorage` precached shell assets without network connectivity.
|
||||||
|
4. **`update-js` (Update N → N+1 with 1-line JS comment)**:
|
||||||
|
- Boots client against Tree N (base frontend), establishing an active Service Worker.
|
||||||
|
- Swaps server to Tree N+1 (one comment line appended to `app.js`, altering `BUILD_TAG`).
|
||||||
|
- Triggers `registration.update()` and measures total wire bytes and time until the new Service Worker reaches `waiting` state (`installed`).
|
||||||
|
5. **`update-css` (Update N → N+1 with 1-line CSS rule)**:
|
||||||
|
- Identical to `update-js`, but Tree N+1 has one CSS rule appended to `theme-glass.css`.
|
||||||
|
- Measures the full shell re-download penalty caused by any stylesheet modification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Metrics Collected
|
||||||
|
|
||||||
|
Each scenario records the following metrics across **5 independent runs** (reporting min, max, and median):
|
||||||
|
- **Request Count**: Total HTTP requests made across the wire.
|
||||||
|
- **Wire Bytes**: Total compressed bytes transferred over the network.
|
||||||
|
- **Wire Bytes by Type**: Categorized byte breakdown (`html`, `js`, `css`, `fonts`, `images`, `media`, `api`, `other`).
|
||||||
|
- **FCP (First Contentful Paint)**: Time from navigation start to first rendered content (ms).
|
||||||
|
- **LCP (Largest Contentful Paint)**: Time to largest contentful paint via `PerformanceObserver` (ms).
|
||||||
|
- **DOMContentLoaded**: Time to DOMContentLoaded event end (ms).
|
||||||
|
- **Boot Done**: Time from navigation start until the first `#cards` view rendering is completed (ms).
|
||||||
|
- **Long Tasks Total**: Cumulative execution time of main thread tasks exceeding 50 ms (ms, Chromium).
|
||||||
|
- **SW Install Duration**: Time from Service Worker registration until precaching completes (ms).
|
||||||
|
- **SW Install Wire Bytes**: Network bytes transmitted during Service Worker installation.
|
||||||
|
- **Storage Estimate**: Quota and usage reported by `navigator.storage.estimate()` (bytes).
|
||||||
|
- **Media Ready**: Elapsed time for deterministic media fixture (`benchmedia01`) to reach `readyState >= 3` / playing state. Notes if autoplay policy blocked playback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. How to Run
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
Symlink dependencies and ensure server dependencies are installed:
|
||||||
|
```bash
|
||||||
|
# Worktree root
|
||||||
|
ln -s ~/development/personal/ytplayer/node_modules node_modules
|
||||||
|
cd server && bun install && cd ..
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Commands
|
||||||
|
Run the complete baseline suite (5 runs, all browsers, LTE profile):
|
||||||
|
```bash
|
||||||
|
node perf/baseline.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a specific browser or profile:
|
||||||
|
```bash
|
||||||
|
# Chromium on LTE profile
|
||||||
|
node perf/baseline.mjs --browser chromium --profile lte
|
||||||
|
|
||||||
|
# WebKit on LTE profile
|
||||||
|
node perf/baseline.mjs --browser webkit --profile lte
|
||||||
|
|
||||||
|
# Both browsers on lossy profile
|
||||||
|
node perf/baseline.mjs --browser all --profile lossy
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a single scenario:
|
||||||
|
```bash
|
||||||
|
# Cold load only
|
||||||
|
node perf/baseline.mjs --scenario cold
|
||||||
|
|
||||||
|
# Update scenario with 1-line JS change
|
||||||
|
node perf/baseline.mjs --scenario update-js
|
||||||
|
|
||||||
|
# Update scenario with 1-line CSS change
|
||||||
|
node perf/baseline.mjs --scenario update-css
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure run count:
|
||||||
|
```bash
|
||||||
|
# Quick sanity check (1 iteration)
|
||||||
|
node perf/baseline.mjs --runs 1
|
||||||
|
|
||||||
|
# High precision benchmark (7 iterations)
|
||||||
|
node perf/baseline.mjs --runs 7
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Phase 5 Comparison Mode (`--compare`)
|
||||||
|
|
||||||
|
To verify performance gains and detect regressions in later phases:
|
||||||
|
```bash
|
||||||
|
node perf/baseline.mjs --compare perf/results/baseline-2026-10-07.json
|
||||||
|
```
|
||||||
|
Prints an ASCII/Markdown delta comparison table highlighting percentage changes across wire bytes, boot duration, and update overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Hidden Timers Verification (`perf/hidden-timers.mjs`)
|
||||||
|
|
||||||
|
Verifies that non-playback UI intervals (`chips`, `reanchor`, `rotateChips`, `flush`, `checkBuildTag`) suspend while the document is hidden and restore upon visibility, while essential background intervals (`maybeCheck`, 120s resume check) remain active:
|
||||||
|
```bash
|
||||||
|
node perf/hidden-timers.mjs
|
||||||
|
```
|
||||||
|
Exits with code 0 on success, or code 1 if any interval fails to pause, fails to resume, or if page errors occur.
|
||||||
|
|
||||||
954
perf/baseline.mjs
Executable file
954
perf/baseline.mjs
Executable file
@@ -0,0 +1,954 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* perf/baseline.mjs
|
||||||
|
*
|
||||||
|
* Performance baseline harness for ytplayer PWA.
|
||||||
|
* Drives the real Bun server and runs Playwright (Chromium with CDP throttling + CPU 4x;
|
||||||
|
* WebKit with a local throttling & stalling proxy).
|
||||||
|
*
|
||||||
|
* Scenarios:
|
||||||
|
* - cold: fresh context, network download, SW install, boot, media playback check
|
||||||
|
* - warm: re-navigate with active SW and primed HTTP cache
|
||||||
|
* - offline: context.setOffline(true), reload under SW cache
|
||||||
|
* - update-js: update N->N+1 (1-line comment appended to app.js) -> wire bytes + time until new SW ready
|
||||||
|
* - update-css: update N->N+1 (1-line rule appended to theme-glass.css) -> wire bytes + time until new SW ready
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node perf/baseline.mjs [options]
|
||||||
|
* Options:
|
||||||
|
* --runs <number> Runs per scenario for median calculation (default: 5)
|
||||||
|
* --browser <chr|wk|all> Browser(s) to test (default: all)
|
||||||
|
* --profile <lte|lossy|all> Network profile (default: lte)
|
||||||
|
* --scenario <name|all> Specific scenario or all (default: all)
|
||||||
|
* --out <path> Output JSON path
|
||||||
|
* --compare <json-path> Compare results against a previous baseline JSON
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
|
import { existsSync, mkdirSync, rmSync, cpSync, appendFileSync, statSync, writeFileSync, readFileSync, symlinkSync, unlinkSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createThrottleProxy } from './proxy.mjs';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
// Dynamically resolve playwright from node_modules
|
||||||
|
let pw;
|
||||||
|
try {
|
||||||
|
pw = await import('playwright');
|
||||||
|
} catch {
|
||||||
|
pw = await import(path.join(REPO_ROOT, 'node_modules', 'playwright', 'index.mjs'));
|
||||||
|
}
|
||||||
|
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
const req = createRequire(import.meta.url);
|
||||||
|
const { createClient } = req(path.join(REPO_ROOT, 'server', 'node_modules', '@libsql/client'));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function parseArgs() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const options = {
|
||||||
|
runs: 5,
|
||||||
|
browser: 'all', // 'chromium', 'webkit', or 'all'
|
||||||
|
profile: 'lte', // 'lte', 'lossy', or 'all'
|
||||||
|
scenario: 'all', // 'cold', 'warm', 'offline', 'update-js', 'update-css', or 'all'
|
||||||
|
out: null,
|
||||||
|
compare: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const a = args[i];
|
||||||
|
if (a === '--runs' && i + 1 < args.length) options.runs = parseInt(args[++i], 10);
|
||||||
|
else if (a === '--browser' && i + 1 < args.length) options.browser = args[++i];
|
||||||
|
else if (a === '--profile' && i + 1 < args.length) options.profile = args[++i];
|
||||||
|
else if (a === '--scenario' && i + 1 < args.length) options.scenario = args[++i];
|
||||||
|
else if (a === '--out' && i + 1 < args.length) options.out = args[++i];
|
||||||
|
else if (a === '--compare' && i + 1 < args.length) options.compare = args[++i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function median(values) {
|
||||||
|
const nums = values.filter((v) => typeof v === 'number' && !Number.isNaN(v)).sort((a, b) => a - b);
|
||||||
|
if (!nums.length) return null;
|
||||||
|
const mid = Math.floor(nums.length / 2);
|
||||||
|
return nums.length % 2 !== 0 ? nums[mid] : Math.round(((nums[mid - 1] + nums[mid]) / 2) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeList(values) {
|
||||||
|
const valid = values.filter((v) => v !== null && v !== undefined && !Number.isNaN(v));
|
||||||
|
if (!valid.length) return { median: null, min: null, max: null, values };
|
||||||
|
return {
|
||||||
|
median: median(valid),
|
||||||
|
min: Math.min(...valid),
|
||||||
|
max: Math.max(...valid),
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup deterministic local media fixture
|
||||||
|
*/
|
||||||
|
function prepareMediaFiles(mediaDir) {
|
||||||
|
mkdirSync(mediaDir, { recursive: true });
|
||||||
|
const mp4 = path.join(mediaDir, 'benchmedia01.1.mp4');
|
||||||
|
const m4a = path.join(mediaDir, 'benchmedia01.1.m4a');
|
||||||
|
|
||||||
|
if (!existsSync(mp4)) {
|
||||||
|
spawnSync('ffmpeg', [
|
||||||
|
'-y', '-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=30:duration=5',
|
||||||
|
'-f', 'lavfi', '-i', 'sine=frequency=440:duration=5',
|
||||||
|
'-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p',
|
||||||
|
'-c:a', 'aac', '-movflags', '+faststart', '-shortest', mp4,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(m4a)) {
|
||||||
|
spawnSync('ffmpeg', [
|
||||||
|
'-y', '-f', 'lavfi', '-i', 'sine=frequency=440:duration=5',
|
||||||
|
'-c:a', 'aac', '-shortest', m4a,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mp4, m4a };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedDatabase(dbPath, mediaFiles) {
|
||||||
|
const db = createClient({ url: 'file:' + dbPath });
|
||||||
|
const mp4Size = statSync(mediaFiles.mp4).size;
|
||||||
|
const m4aSize = statSync(mediaFiles.m4a).size;
|
||||||
|
|
||||||
|
await db.execute({
|
||||||
|
sql: `INSERT OR REPLACE INTO media_cache (
|
||||||
|
video_id, status, gen, size, height, vcodec, acodec, duration, meta, last_access
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
'benchmedia01',
|
||||||
|
'ready',
|
||||||
|
1,
|
||||||
|
mp4Size + m4aSize,
|
||||||
|
360,
|
||||||
|
'h264',
|
||||||
|
'aac',
|
||||||
|
5.0,
|
||||||
|
JSON.stringify({ title: 'Baseline Deterministic Clip', channel: 'Perf Benchmark' }),
|
||||||
|
Date.now(),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bun Server Manager
|
||||||
|
*/
|
||||||
|
class ServerManager {
|
||||||
|
constructor(scratchDir, publicDir) {
|
||||||
|
this.scratchDir = scratchDir;
|
||||||
|
this.publicDir = publicDir;
|
||||||
|
this.dataDir = path.join(scratchDir, 'data');
|
||||||
|
this.mediaDir = path.join(this.dataDir, 'media');
|
||||||
|
this.dbPath = path.join(this.dataDir, 'ytplayer.db');
|
||||||
|
this.serverProcess = null;
|
||||||
|
this.port = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
mkdirSync(this.dataDir, { recursive: true });
|
||||||
|
const fixtureDir = path.join(REPO_ROOT, 'perf', 'fixtures', 'media');
|
||||||
|
const mediaFiles = prepareMediaFiles(fixtureDir);
|
||||||
|
|
||||||
|
// Create a dedicated server working dir with public symlink
|
||||||
|
const srvCwd = path.join(this.scratchDir, 'srv');
|
||||||
|
mkdirSync(srvCwd, { recursive: true });
|
||||||
|
const publicLink = path.join(srvCwd, 'public');
|
||||||
|
if (existsSync(publicLink)) unlinkSync(publicLink);
|
||||||
|
symlinkSync(this.publicDir, publicLink);
|
||||||
|
|
||||||
|
// Pick random available ephemeral port once and reuse across restarts
|
||||||
|
this.port = this.port || (4300 + Math.floor(Math.random() * 2000));
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(this.port),
|
||||||
|
DATA_DIR: this.dataDir,
|
||||||
|
MEDIA_DIR: this.mediaDir,
|
||||||
|
DB_PATH: this.dbPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverScript = path.join(REPO_ROOT, 'server', 'server.js');
|
||||||
|
this.serverProcess = spawn('bun', [serverScript], {
|
||||||
|
cwd: srvCwd,
|
||||||
|
env,
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for server readiness
|
||||||
|
let ready = false;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${this.port}/api/version`);
|
||||||
|
if (res.ok) {
|
||||||
|
ready = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
throw new Error(`Server failed to start on port ${this.port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy media files to mediaDir and seed media table AFTER server startup sweep
|
||||||
|
mkdirSync(this.mediaDir, { recursive: true });
|
||||||
|
const targetMp4 = path.join(this.mediaDir, 'benchmedia01.1.mp4');
|
||||||
|
const targetM4a = path.join(this.mediaDir, 'benchmedia01.1.m4a');
|
||||||
|
cpSync(mediaFiles.mp4, targetMp4);
|
||||||
|
cpSync(mediaFiles.m4a, targetM4a);
|
||||||
|
await seedDatabase(this.dbPath, { mp4: targetMp4, m4a: targetM4a });
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop() {
|
||||||
|
if (this.serverProcess) {
|
||||||
|
const proc = this.serverProcess;
|
||||||
|
this.serverProcess = null;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
if (proc.exitCode !== null) return resolve();
|
||||||
|
proc.once('exit', () => resolve());
|
||||||
|
proc.kill('SIGKILL');
|
||||||
|
setTimeout(resolve, 2000);
|
||||||
|
});
|
||||||
|
await sleep(200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async restartWithPublic(newPublicDir) {
|
||||||
|
await this.stop();
|
||||||
|
this.publicDir = newPublicDir;
|
||||||
|
await this.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure page for metrics collection
|
||||||
|
*/
|
||||||
|
async function setupPageMetrics(page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.__bootDone = null;
|
||||||
|
window.__fcp = null;
|
||||||
|
window.__lcp = null;
|
||||||
|
window.__longTasksTotal = 0;
|
||||||
|
|
||||||
|
// Observe boot completion landmark (#cards view set by renderList())
|
||||||
|
const markBoot = () => {
|
||||||
|
const cards = document.getElementById('cards');
|
||||||
|
if (cards && cards.dataset && cards.dataset.view) {
|
||||||
|
if (!window.__bootDone) window.__bootDone = performance.now();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const t = setInterval(() => {
|
||||||
|
if (markBoot()) clearInterval(t);
|
||||||
|
}, 5);
|
||||||
|
|
||||||
|
// Observe Long Tasks API (supported on Chromium)
|
||||||
|
try {
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) {
|
||||||
|
window.__longTasksTotal += entry.duration;
|
||||||
|
}
|
||||||
|
}).observe({ type: 'longtask', buffered: true });
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Observe LCP
|
||||||
|
try {
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
const entries = list.getEntries();
|
||||||
|
if (entries.length > 0) {
|
||||||
|
window.__lcp = entries[entries.length - 1].startTime;
|
||||||
|
}
|
||||||
|
}).observe({ type: 'largest-contentful-paint', buffered: true });
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Scenario: Cold Load
|
||||||
|
*/
|
||||||
|
async function runColdScenario({ browserType, server, proxy, profile }) {
|
||||||
|
const isWebKit = browserType.name() === 'webkit';
|
||||||
|
const launchArgs = isWebKit ? [] : ['--autoplay-policy=no-user-gesture-required'];
|
||||||
|
const browser = await browserType.launch({
|
||||||
|
headless: true,
|
||||||
|
args: launchArgs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextOptions = { proxy: { server: `http://127.0.0.1:${proxy.port}` } };
|
||||||
|
const context = await browser.newContext(contextOptions);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// If Chromium, configure CDP Network conditions and 4x CPU throttle
|
||||||
|
if (!isWebKit) {
|
||||||
|
const cdp = await context.newCDPSession(page);
|
||||||
|
await cdp.send('Network.enable');
|
||||||
|
const isLossy = profile === 'lossy';
|
||||||
|
await cdp.send('Network.emulateNetworkConditions', {
|
||||||
|
offline: false,
|
||||||
|
latency: isLossy ? 250 : 150,
|
||||||
|
downloadThroughput: isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8,
|
||||||
|
uploadThroughput: (750 * 1000) / 8,
|
||||||
|
connectionType: 'cellular4g',
|
||||||
|
});
|
||||||
|
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupPageMetrics(page);
|
||||||
|
|
||||||
|
proxy.resetStats();
|
||||||
|
proxy.startTracking();
|
||||||
|
|
||||||
|
const swInstallStart = Date.now();
|
||||||
|
const navUrl = `http://127.0.0.1:${server.port}/?v=benchmedia01`;
|
||||||
|
|
||||||
|
await page.goto(navUrl);
|
||||||
|
|
||||||
|
// Wait for boot landmark
|
||||||
|
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||||
|
|
||||||
|
// Check deterministic media playing-ready
|
||||||
|
let mediaReadyMs = null;
|
||||||
|
let autoplayBlocked = false;
|
||||||
|
try {
|
||||||
|
const mediaCheck = await page.waitForFunction(() => {
|
||||||
|
const v = document.querySelector('video') || document.querySelector('audio');
|
||||||
|
if (!v) return false;
|
||||||
|
return v.readyState >= 3 || (!v.paused && v.currentTime > 0);
|
||||||
|
}, { timeout: 8000 });
|
||||||
|
if (mediaCheck) {
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const v = document.querySelector('video') || document.querySelector('audio');
|
||||||
|
return { paused: v.paused, readyState: v.readyState };
|
||||||
|
});
|
||||||
|
autoplayBlocked = state.paused;
|
||||||
|
mediaReadyMs = await page.evaluate(() => performance.now());
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
autoplayBlocked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for initial Service Worker activation
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
if (reg.active && reg.active.state === 'activated') return;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const w = reg.active || reg.installing;
|
||||||
|
if (w.state === 'activated') return resolve();
|
||||||
|
w.addEventListener('statechange', () => {
|
||||||
|
if (w.state === 'activated') resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const swInstallDuration = Date.now() - swInstallStart;
|
||||||
|
proxy.stopTracking();
|
||||||
|
|
||||||
|
const stats = proxy.getStats();
|
||||||
|
|
||||||
|
const pageMetrics = await page.evaluate(() => {
|
||||||
|
const nav = performance.getEntriesByType('navigation')[0];
|
||||||
|
const paint = performance.getEntriesByType('paint');
|
||||||
|
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||||
|
|
||||||
|
return {
|
||||||
|
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||||
|
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||||
|
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||||
|
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
||||||
|
longTaskTotal: Math.round(window.__longTasksTotal || 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const storage = await page.evaluate(async () => {
|
||||||
|
if (navigator.storage && navigator.storage.estimate) {
|
||||||
|
const est = await navigator.storage.estimate();
|
||||||
|
return { usage: est.usage || 0, quota: est.quota || 0 };
|
||||||
|
}
|
||||||
|
return { usage: null, quota: null };
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestCount: stats.requestCount,
|
||||||
|
wireBytes: stats.totalWireBytes,
|
||||||
|
wireBytesByType: stats.bytesByType,
|
||||||
|
fcp: pageMetrics.fcp,
|
||||||
|
lcp: pageMetrics.lcp,
|
||||||
|
domContentLoaded: pageMetrics.domContentLoaded,
|
||||||
|
bootDone: pageMetrics.bootDone,
|
||||||
|
longTaskTotal: pageMetrics.longTaskTotal,
|
||||||
|
swInstallDuration,
|
||||||
|
swInstallBytes: stats.totalWireBytes,
|
||||||
|
storageEstimate: storage,
|
||||||
|
mediaReady: mediaReadyMs ? Math.round(mediaReadyMs) : null,
|
||||||
|
autoplayBlocked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Scenario: Warm Load (re-navigation with active SW)
|
||||||
|
*/
|
||||||
|
async function runWarmScenario({ browserType, server, proxy, profile }) {
|
||||||
|
const isWebKit = browserType.name() === 'webkit';
|
||||||
|
const launchArgs = isWebKit ? [] : ['--autoplay-policy=no-user-gesture-required'];
|
||||||
|
const browser = await browserType.launch({
|
||||||
|
headless: true,
|
||||||
|
args: launchArgs,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contextOptions = { proxy: { server: `http://127.0.0.1:${proxy.port}` } };
|
||||||
|
const context = await browser.newContext(contextOptions);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
if (!isWebKit) {
|
||||||
|
const cdp = await context.newCDPSession(page);
|
||||||
|
await cdp.send('Network.enable');
|
||||||
|
const isLossy = profile === 'lossy';
|
||||||
|
await cdp.send('Network.emulateNetworkConditions', {
|
||||||
|
offline: false,
|
||||||
|
latency: isLossy ? 250 : 150,
|
||||||
|
downloadThroughput: isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8,
|
||||||
|
uploadThroughput: (750 * 1000) / 8,
|
||||||
|
connectionType: 'cellular4g',
|
||||||
|
});
|
||||||
|
await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await setupPageMetrics(page);
|
||||||
|
|
||||||
|
const navUrl = `http://127.0.0.1:${server.port}/`;
|
||||||
|
|
||||||
|
// Prime context with cold load
|
||||||
|
await page.goto(navUrl);
|
||||||
|
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
if (reg.active && reg.active.state === 'activated') return;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const w = reg.active || reg.installing;
|
||||||
|
if (w.state === 'activated') return resolve();
|
||||||
|
w.addEventListener('statechange', () => {
|
||||||
|
if (w.state === 'activated') resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now measure warm re-navigation
|
||||||
|
proxy.resetStats();
|
||||||
|
proxy.startTracking();
|
||||||
|
|
||||||
|
await page.goto(navUrl);
|
||||||
|
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||||
|
|
||||||
|
proxy.stopTracking();
|
||||||
|
const stats = proxy.getStats();
|
||||||
|
|
||||||
|
const pageMetrics = await page.evaluate(() => {
|
||||||
|
const nav = performance.getEntriesByType('navigation')[0];
|
||||||
|
const paint = performance.getEntriesByType('paint');
|
||||||
|
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||||
|
|
||||||
|
return {
|
||||||
|
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||||
|
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||||
|
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||||
|
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
||||||
|
longTaskTotal: Math.round(window.__longTasksTotal || 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestCount: stats.requestCount,
|
||||||
|
wireBytes: stats.totalWireBytes,
|
||||||
|
wireBytesByType: stats.bytesByType,
|
||||||
|
fcp: pageMetrics.fcp,
|
||||||
|
lcp: pageMetrics.lcp,
|
||||||
|
domContentLoaded: pageMetrics.domContentLoaded,
|
||||||
|
bootDone: pageMetrics.bootDone,
|
||||||
|
longTaskTotal: pageMetrics.longTaskTotal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Scenario: Offline Reload under SW
|
||||||
|
*/
|
||||||
|
async function runOfflineScenario({ browserType, server, proxy, profile }) {
|
||||||
|
const browser = await browserType.launch({ headless: true });
|
||||||
|
const isWebKit = browserType.name() === 'webkit';
|
||||||
|
const contextOptions = { proxy: { server: `http://127.0.0.1:${proxy.port}` } };
|
||||||
|
const context = await browser.newContext(contextOptions);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
await setupPageMetrics(page);
|
||||||
|
const navUrl = `http://127.0.0.1:${server.port}/`;
|
||||||
|
|
||||||
|
// Prime SW
|
||||||
|
await page.goto(navUrl);
|
||||||
|
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
if (reg.active && reg.active.state === 'activated') return;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const w = reg.active || reg.installing;
|
||||||
|
if (w.state === 'activated') return resolve();
|
||||||
|
w.addEventListener('statechange', () => {
|
||||||
|
if (w.state === 'activated') resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Switch to offline mode:
|
||||||
|
// On Chromium, context.setOffline(true) works.
|
||||||
|
// On Linux WebKit, Playwright context.setOffline throws an internal browser error,
|
||||||
|
// so proxy.setOffline(true) cuts upstream network to simulate offline truthfully.
|
||||||
|
if (!isWebKit) {
|
||||||
|
await context.setOffline(true);
|
||||||
|
}
|
||||||
|
proxy.setOffline(true);
|
||||||
|
|
||||||
|
proxy.resetStats();
|
||||||
|
proxy.startTracking();
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForFunction(() => window.__bootDone !== null, { timeout: 15000 });
|
||||||
|
const reloadDuration = Date.now() - t0;
|
||||||
|
|
||||||
|
proxy.stopTracking();
|
||||||
|
proxy.setOffline(false);
|
||||||
|
const stats = proxy.getStats();
|
||||||
|
|
||||||
|
const pageMetrics = await page.evaluate(() => {
|
||||||
|
const nav = performance.getEntriesByType('navigation')[0];
|
||||||
|
const paint = performance.getEntriesByType('paint');
|
||||||
|
const fcp = paint.find((p) => p.name === 'first-contentful-paint');
|
||||||
|
|
||||||
|
return {
|
||||||
|
fcp: fcp ? Math.round(fcp.startTime) : null,
|
||||||
|
lcp: window.__lcp ? Math.round(window.__lcp) : null,
|
||||||
|
domContentLoaded: nav ? Math.round(nav.domContentLoadedEventEnd - nav.startTime) : null,
|
||||||
|
bootDone: window.__bootDone ? Math.round(window.__bootDone) : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestCount: stats.requestCount,
|
||||||
|
wireBytes: stats.totalWireBytes,
|
||||||
|
fcp: pageMetrics.fcp,
|
||||||
|
lcp: pageMetrics.lcp,
|
||||||
|
domContentLoaded: pageMetrics.domContentLoaded,
|
||||||
|
bootDone: pageMetrics.bootDone,
|
||||||
|
reloadDuration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run Scenario: Update N -> N+1
|
||||||
|
*/
|
||||||
|
async function runUpdateScenario({ browserType, server, proxy, profile, updateKind, trees }) {
|
||||||
|
const browser = await browserType.launch({ headless: true });
|
||||||
|
const isWebKit = browserType.name() === 'webkit';
|
||||||
|
const contextOptions = { proxy: { server: `http://127.0.0.1:${proxy.port}` } };
|
||||||
|
const context = await browser.newContext(contextOptions);
|
||||||
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
if (!isWebKit) {
|
||||||
|
const cdp = await context.newCDPSession(page);
|
||||||
|
await cdp.send('Network.enable');
|
||||||
|
const isLossy = profile === 'lossy';
|
||||||
|
await cdp.send('Network.emulateNetworkConditions', {
|
||||||
|
offline: false,
|
||||||
|
latency: isLossy ? 250 : 150,
|
||||||
|
downloadThroughput: isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8,
|
||||||
|
uploadThroughput: (750 * 1000) / 8,
|
||||||
|
connectionType: 'cellular4g',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start on Tree N (base)
|
||||||
|
await server.restartWithPublic(trees.treeBase);
|
||||||
|
|
||||||
|
const navUrl = `http://127.0.0.1:${server.port}/`;
|
||||||
|
|
||||||
|
await page.goto(navUrl);
|
||||||
|
|
||||||
|
// Wait for steady state: active worker present, no waiting worker
|
||||||
|
while (true) {
|
||||||
|
const ready = await page.evaluate(async () => {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration();
|
||||||
|
return reg?.active?.state === 'activated' && !reg?.installing && !reg?.waiting;
|
||||||
|
});
|
||||||
|
if (ready) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch server to Tree N+1 (app.js or theme-glass.css modification)
|
||||||
|
const targetTree = updateKind === 'js' ? trees.treeUpdateJs : trees.treeUpdateCss;
|
||||||
|
await server.restartWithPublic(targetTree);
|
||||||
|
|
||||||
|
// Measure wire bytes and duration from reg.update() until new SW is waiting/installed
|
||||||
|
proxy.resetStats();
|
||||||
|
proxy.startTracking();
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r.update()));
|
||||||
|
|
||||||
|
// Wait until new worker reaches waiting state
|
||||||
|
while (true) {
|
||||||
|
const waiting = await page.evaluate(async () => {
|
||||||
|
const reg = await navigator.serviceWorker.getRegistration();
|
||||||
|
return reg?.waiting?.state === 'installed';
|
||||||
|
});
|
||||||
|
if (waiting) break;
|
||||||
|
await sleep(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = Date.now() - t0;
|
||||||
|
proxy.stopTracking();
|
||||||
|
const stats = proxy.getStats();
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateKind,
|
||||||
|
duration,
|
||||||
|
requestCount: stats.requestCount,
|
||||||
|
wireBytes: stats.totalWireBytes,
|
||||||
|
wireBytesByType: stats.bytesByType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main execution
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs();
|
||||||
|
console.log('=== YTPlayer Performance Baseline Harness ===');
|
||||||
|
console.log(`Runs per scenario: ${options.runs}`);
|
||||||
|
console.log(`Browser target: ${options.browser}`);
|
||||||
|
console.log(`Network profile: ${options.profile}`);
|
||||||
|
console.log(`Scenario target: ${options.scenario}`);
|
||||||
|
|
||||||
|
const browsers = [];
|
||||||
|
if (options.browser === 'all' || options.browser === 'chromium') browsers.push(pw.chromium);
|
||||||
|
if (options.browser === 'all' || options.browser === 'webkit') browsers.push(pw.webkit);
|
||||||
|
|
||||||
|
const profiles = [];
|
||||||
|
if (options.profile === 'all' || options.profile === 'lte') profiles.push('lte');
|
||||||
|
if (options.profile === 'all' || options.profile === 'lossy') profiles.push('lossy');
|
||||||
|
|
||||||
|
const tmpRoot = path.join(REPO_ROOT, 'perf', '.tmp', `bench-${Date.now()}`);
|
||||||
|
mkdirSync(tmpRoot, { recursive: true });
|
||||||
|
|
||||||
|
// Setup scratch trees for N -> N+1 update scenarios
|
||||||
|
const treeBase = path.join(tmpRoot, 'tree-base');
|
||||||
|
const treeUpdateJs = path.join(tmpRoot, 'tree-update-js');
|
||||||
|
const treeUpdateCss = path.join(tmpRoot, 'tree-update-css');
|
||||||
|
|
||||||
|
cpSync(path.join(REPO_ROOT, 'frontend'), treeBase, { recursive: true });
|
||||||
|
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateJs, { recursive: true });
|
||||||
|
cpSync(path.join(REPO_ROOT, 'frontend'), treeUpdateCss, { recursive: true });
|
||||||
|
|
||||||
|
// Append 1 comment line to app.js in treeUpdateJs
|
||||||
|
appendFileSync(path.join(treeUpdateJs, 'app.js'), '\n// baseline bench: 1-line app.js comment\n');
|
||||||
|
// Append 1 rule to theme-glass.css in treeUpdateCss
|
||||||
|
appendFileSync(path.join(treeUpdateCss, 'theme-glass.css'), '\n/* baseline bench: 1-line css rule */\n.baseline-marker { opacity: 0.99; }\n');
|
||||||
|
|
||||||
|
const trees = { treeBase, treeUpdateJs, treeUpdateCss };
|
||||||
|
|
||||||
|
const serverScratch = path.join(tmpRoot, 'server-run');
|
||||||
|
const server = new ServerManager(serverScratch, treeBase);
|
||||||
|
await server.start();
|
||||||
|
console.log(`Bun server running on ephemeral port ${server.port}`);
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
commit: spawnSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT }).stdout.toString().trim(),
|
||||||
|
runsConfigured: options.runs,
|
||||||
|
browsers: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const bType of browsers) {
|
||||||
|
const bName = bType.name();
|
||||||
|
results.browsers[bName] = {};
|
||||||
|
|
||||||
|
for (const prof of profiles) {
|
||||||
|
console.log(`\n>>> Running Benchmark: Browser=[${bName}] Profile=[${prof}]`);
|
||||||
|
results.browsers[bName][prof] = {};
|
||||||
|
|
||||||
|
const proxyProfile = bName === 'chromium' ? 'unthrottled' : prof;
|
||||||
|
const proxy = createThrottleProxy({ targetPort: server.port, profile: proxyProfile });
|
||||||
|
proxy.port = await proxy.listen();
|
||||||
|
console.log(`Proxy listening on port ${proxy.port} (mode: ${proxyProfile})`);
|
||||||
|
|
||||||
|
// 1. Cold Scenario
|
||||||
|
if (options.scenario === 'all' || options.scenario === 'cold') {
|
||||||
|
console.log(` [Scenario: Cold Load] running ${options.runs} iterations...`);
|
||||||
|
const coldRuns = [];
|
||||||
|
for (let r = 0; r < options.runs; r++) {
|
||||||
|
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||||
|
const res = await runColdScenario({ browserType: bType, server, proxy, profile: prof });
|
||||||
|
coldRuns.push(res);
|
||||||
|
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, boot=${res.bootDone}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.browsers[bName][prof].cold = {
|
||||||
|
requestCount: summarizeList(coldRuns.map((r) => r.requestCount)),
|
||||||
|
wireBytes: summarizeList(coldRuns.map((r) => r.wireBytes)),
|
||||||
|
fcp: summarizeList(coldRuns.map((r) => r.fcp)),
|
||||||
|
lcp: summarizeList(coldRuns.map((r) => r.lcp)),
|
||||||
|
domContentLoaded: summarizeList(coldRuns.map((r) => r.domContentLoaded)),
|
||||||
|
bootDone: summarizeList(coldRuns.map((r) => r.bootDone)),
|
||||||
|
longTaskTotal: summarizeList(coldRuns.map((r) => r.longTaskTotal)),
|
||||||
|
swInstallDuration: summarizeList(coldRuns.map((r) => r.swInstallDuration)),
|
||||||
|
swInstallBytes: summarizeList(coldRuns.map((r) => r.swInstallBytes)),
|
||||||
|
storageEstimate: coldRuns[0]?.storageEstimate || null,
|
||||||
|
mediaReady: summarizeList(coldRuns.map((r) => r.mediaReady)),
|
||||||
|
autoplayBlocked: coldRuns.some((r) => r.autoplayBlocked),
|
||||||
|
wireBytesByType: coldRuns[0]?.wireBytesByType || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Warm Scenario
|
||||||
|
if (options.scenario === 'all' || options.scenario === 'warm') {
|
||||||
|
console.log(` [Scenario: Warm Load] running ${options.runs} iterations...`);
|
||||||
|
const warmRuns = [];
|
||||||
|
for (let r = 0; r < options.runs; r++) {
|
||||||
|
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||||
|
const res = await runWarmScenario({ browserType: bType, server, proxy, profile: prof });
|
||||||
|
warmRuns.push(res);
|
||||||
|
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, boot=${res.bootDone}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.browsers[bName][prof].warm = {
|
||||||
|
requestCount: summarizeList(warmRuns.map((r) => r.requestCount)),
|
||||||
|
wireBytes: summarizeList(warmRuns.map((r) => r.wireBytes)),
|
||||||
|
fcp: summarizeList(warmRuns.map((r) => r.fcp)),
|
||||||
|
lcp: summarizeList(warmRuns.map((r) => r.lcp)),
|
||||||
|
domContentLoaded: summarizeList(warmRuns.map((r) => r.domContentLoaded)),
|
||||||
|
bootDone: summarizeList(warmRuns.map((r) => r.bootDone)),
|
||||||
|
longTaskTotal: summarizeList(warmRuns.map((r) => r.longTaskTotal)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Offline Scenario
|
||||||
|
if (options.scenario === 'all' || options.scenario === 'offline') {
|
||||||
|
console.log(` [Scenario: Offline Reload] running ${options.runs} iterations...`);
|
||||||
|
const offRuns = [];
|
||||||
|
for (let r = 0; r < options.runs; r++) {
|
||||||
|
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||||
|
const res = await runOfflineScenario({ browserType: bType, server, proxy, profile: prof });
|
||||||
|
offRuns.push(res);
|
||||||
|
console.log(`done (reload=${res.reloadDuration}ms, boot=${res.bootDone}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.browsers[bName][prof].offline = {
|
||||||
|
requestCount: summarizeList(offRuns.map((r) => r.requestCount)),
|
||||||
|
wireBytes: summarizeList(offRuns.map((r) => r.wireBytes)),
|
||||||
|
fcp: summarizeList(offRuns.map((r) => r.fcp)),
|
||||||
|
lcp: summarizeList(offRuns.map((r) => r.lcp)),
|
||||||
|
domContentLoaded: summarizeList(offRuns.map((r) => r.domContentLoaded)),
|
||||||
|
bootDone: summarizeList(offRuns.map((r) => r.bootDone)),
|
||||||
|
reloadDuration: summarizeList(offRuns.map((r) => r.reloadDuration)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Update N -> N+1 (JS)
|
||||||
|
if (options.scenario === 'all' || options.scenario === 'update-js') {
|
||||||
|
console.log(` [Scenario: Update N->N+1 (1-line app.js)] running ${options.runs} iterations...`);
|
||||||
|
const updJsRuns = [];
|
||||||
|
for (let r = 0; r < options.runs; r++) {
|
||||||
|
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||||
|
const res = await runUpdateScenario({
|
||||||
|
browserType: bType,
|
||||||
|
server,
|
||||||
|
proxy,
|
||||||
|
profile: prof,
|
||||||
|
updateKind: 'js',
|
||||||
|
trees,
|
||||||
|
});
|
||||||
|
updJsRuns.push(res);
|
||||||
|
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, dur=${res.duration}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.browsers[bName][prof].updateJs = {
|
||||||
|
requestCount: summarizeList(updJsRuns.map((r) => r.requestCount)),
|
||||||
|
wireBytes: summarizeList(updJsRuns.map((r) => r.wireBytes)),
|
||||||
|
duration: summarizeList(updJsRuns.map((r) => r.duration)),
|
||||||
|
wireBytesByType: updJsRuns[0]?.wireBytesByType || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Update N -> N+1 (CSS)
|
||||||
|
if (options.scenario === 'all' || options.scenario === 'update-css') {
|
||||||
|
console.log(` [Scenario: Update N->N+1 (1-line theme-glass.css)] running ${options.runs} iterations...`);
|
||||||
|
const updCssRuns = [];
|
||||||
|
for (let r = 0; r < options.runs; r++) {
|
||||||
|
process.stdout.write(` run ${r + 1}/${options.runs}... `);
|
||||||
|
const res = await runUpdateScenario({
|
||||||
|
browserType: bType,
|
||||||
|
server,
|
||||||
|
proxy,
|
||||||
|
profile: prof,
|
||||||
|
updateKind: 'css',
|
||||||
|
trees,
|
||||||
|
});
|
||||||
|
updCssRuns.push(res);
|
||||||
|
console.log(`done (${res.wireBytes} bytes, ${res.requestCount} reqs, dur=${res.duration}ms)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.browsers[bName][prof].updateCss = {
|
||||||
|
requestCount: summarizeList(updCssRuns.map((r) => r.requestCount)),
|
||||||
|
wireBytes: summarizeList(updCssRuns.map((r) => r.wireBytes)),
|
||||||
|
duration: summarizeList(updCssRuns.map((r) => r.duration)),
|
||||||
|
wireBytesByType: updCssRuns[0]?.wireBytesByType || {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await proxy.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await server.stop();
|
||||||
|
rmSync(tmpRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write results JSON
|
||||||
|
const now = new Date();
|
||||||
|
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
const outPath = options.out || path.join(REPO_ROOT, 'perf', 'results', `baseline-${dateStr}.json`);
|
||||||
|
mkdirSync(path.dirname(outPath), { recursive: true });
|
||||||
|
writeFileSync(outPath, JSON.stringify(results, null, 2));
|
||||||
|
console.log(`\nBaseline results saved to: ${outPath}`);
|
||||||
|
|
||||||
|
// Print Summary Table
|
||||||
|
printSummaryTable(results);
|
||||||
|
|
||||||
|
// Compare mode if requested
|
||||||
|
if (options.compare) {
|
||||||
|
compareResults(options.compare, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSummaryTable(results) {
|
||||||
|
console.log('\n============================= HEADLINE BASELINE SUMMARY =============================');
|
||||||
|
for (const [browser, profiles] of Object.entries(results.browsers)) {
|
||||||
|
for (const [profile, scenarios] of Object.entries(profiles)) {
|
||||||
|
console.log(`\n[Browser: ${browser} | Profile: ${profile}]`);
|
||||||
|
console.log('| Scenario | Req Count | Wire Bytes | Boot / Ready | SW Dur / Reload |');
|
||||||
|
console.log('|---------------------------|-----------|------------|--------------|-----------------|');
|
||||||
|
|
||||||
|
if (scenarios.cold) {
|
||||||
|
const c = scenarios.cold;
|
||||||
|
console.log(
|
||||||
|
`| Cold First Load | ${String(c.requestCount.median).padEnd(9)} | ${formatBytes(c.wireBytes.median).padEnd(10)} | ${String(c.bootDone.median + 'ms').padEnd(12)} | ${String(c.swInstallDuration.median + 'ms').padEnd(15)} |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scenarios.warm) {
|
||||||
|
const w = scenarios.warm;
|
||||||
|
console.log(
|
||||||
|
`| Warm Reload (under SW) | ${String(w.requestCount.median).padEnd(9)} | ${formatBytes(w.wireBytes.median).padEnd(10)} | ${String(w.bootDone.median + 'ms').padEnd(12)} | ${'-'.padEnd(15)} |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scenarios.offline) {
|
||||||
|
const o = scenarios.offline;
|
||||||
|
console.log(
|
||||||
|
`| Offline Reload (under SW) | ${String(o.requestCount.median).padEnd(9)} | ${formatBytes(o.wireBytes.median).padEnd(10)} | ${String(o.bootDone.median + 'ms').padEnd(12)} | ${String(o.reloadDuration.median + 'ms').padEnd(15)} |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scenarios.updateJs) {
|
||||||
|
const u = scenarios.updateJs;
|
||||||
|
console.log(
|
||||||
|
`| Update N->N+1 (1-line JS) | ${String(u.requestCount.median).padEnd(9)} | ${formatBytes(u.wireBytes.median).padEnd(10)} | ${'-'.padEnd(12)} | ${String(u.duration.median + 'ms').padEnd(15)} |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scenarios.updateCss) {
|
||||||
|
const u = scenarios.updateCss;
|
||||||
|
console.log(
|
||||||
|
`| Update N->N+1 (1-line CSS)| ${String(u.requestCount.median).padEnd(9)} | ${formatBytes(u.wireBytes.median).padEnd(10)} | ${'-'.padEnd(12)} | ${String(u.duration.median + 'ms').padEnd(15)} |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('\n=====================================================================================');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === null || bytes === undefined) return 'N/A';
|
||||||
|
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return bytes + ' B';
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareResults(baselinePath, currentResults) {
|
||||||
|
console.log(`\n================ Phase 5 Comparison against: ${baselinePath} ================`);
|
||||||
|
if (!existsSync(baselinePath)) {
|
||||||
|
console.error(`Error: Baseline file not found: ${baselinePath}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const base = JSON.parse(readFileSync(baselinePath, 'utf8'));
|
||||||
|
|
||||||
|
for (const [browser, profiles] of Object.entries(currentResults.browsers)) {
|
||||||
|
for (const [profile, scenarios] of Object.entries(profiles)) {
|
||||||
|
console.log(`\n[Comparison: ${browser} / ${profile}]`);
|
||||||
|
console.log('| Metric | Baseline | Current | Delta (%) |');
|
||||||
|
console.log('|------------------------------------|-------------|-------------|--------------|');
|
||||||
|
|
||||||
|
const baseScen = base.browsers?.[browser]?.[profile];
|
||||||
|
if (!baseScen) {
|
||||||
|
console.log('| (No matching baseline data) | | | |');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
['Cold: Wire Bytes', baseScen.cold?.wireBytes?.median, scenarios.cold?.wireBytes?.median, true],
|
||||||
|
['Cold: Boot Done (ms)', baseScen.cold?.bootDone?.median, scenarios.cold?.bootDone?.median, false],
|
||||||
|
['Cold: Requests', baseScen.cold?.requestCount?.median, scenarios.cold?.requestCount?.median, false],
|
||||||
|
['Warm: Boot Done (ms)', baseScen.warm?.bootDone?.median, scenarios.warm?.bootDone?.median, false],
|
||||||
|
['Update (1-line JS): Wire Bytes', baseScen.updateJs?.wireBytes?.median, scenarios.updateJs?.wireBytes?.median, true],
|
||||||
|
['Update (1-line JS): Time (ms)', baseScen.updateJs?.duration?.median, scenarios.updateJs?.duration?.median, false],
|
||||||
|
['Update (1-line CSS): Wire Bytes', baseScen.updateCss?.wireBytes?.median, scenarios.updateCss?.wireBytes?.median, true],
|
||||||
|
['Update (1-line CSS): Time (ms)', baseScen.updateCss?.duration?.median, scenarios.updateCss?.duration?.median, false],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [name, bVal, cVal, isBytes] of rows) {
|
||||||
|
if (bVal === undefined || cVal === undefined) continue;
|
||||||
|
const bStr = isBytes ? formatBytes(bVal) : bVal !== null ? `${bVal}` : 'N/A';
|
||||||
|
const cStr = isBytes ? formatBytes(cVal) : cVal !== null ? `${cVal}` : 'N/A';
|
||||||
|
let deltaStr = '-';
|
||||||
|
if (typeof bVal === 'number' && typeof cVal === 'number' && bVal > 0) {
|
||||||
|
const pct = (((cVal - bVal) / bVal) * 100).toFixed(1);
|
||||||
|
const sign = cVal >= bVal ? '+' : '';
|
||||||
|
deltaStr = `${sign}${pct}%`;
|
||||||
|
}
|
||||||
|
console.log(`| ${name.padEnd(34)} | ${bStr.padEnd(11)} | ${cStr.padEnd(11)} | ${deltaStr.padEnd(12)} |`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('==============================================================================');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Benchmark fatal error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
208
perf/hidden-timers.mjs
Normal file
208
perf/hidden-timers.mjs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* perf/hidden-timers.mjs
|
||||||
|
*
|
||||||
|
* Verifies that non-playback UI intervals are paused when the page is hidden
|
||||||
|
* and restored when the page becomes visible again.
|
||||||
|
*
|
||||||
|
* Assertions:
|
||||||
|
* - While hidden: none of [chips, reanchor, rotateChips, flush, checkBuildTag] is live.
|
||||||
|
* - After becoming visible again: all five gated intervals are live.
|
||||||
|
* - Must-run intervals (e.g. maybeCheck, 120s resume check) remain live while hidden.
|
||||||
|
* - No page errors during lifecycle transitions.
|
||||||
|
* - Exits non-zero on failure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { existsSync, mkdirSync, rmSync, symlinkSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
let pw;
|
||||||
|
try {
|
||||||
|
pw = await import('playwright');
|
||||||
|
} catch {
|
||||||
|
pw = await import(path.join(REPO_ROOT, 'node_modules', 'playwright', 'index.mjs'));
|
||||||
|
}
|
||||||
|
const { chromium } = pw;
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const scratchDir = path.join(REPO_ROOT, 'perf', '.tmp', `hidden-timers-${Date.now()}`);
|
||||||
|
mkdirSync(scratchDir, { recursive: true });
|
||||||
|
const dataDir = path.join(scratchDir, 'data');
|
||||||
|
mkdirSync(dataDir, { recursive: true });
|
||||||
|
const srvCwd = path.join(scratchDir, 'srv');
|
||||||
|
mkdirSync(srvCwd, { recursive: true });
|
||||||
|
const publicLink = path.join(srvCwd, 'public');
|
||||||
|
symlinkSync(path.join(REPO_ROOT, 'frontend'), publicLink);
|
||||||
|
|
||||||
|
const port = 4900 + Math.floor(Math.random() * 2000);
|
||||||
|
const serverProcess = spawn('bun', [path.join(REPO_ROOT, 'server', 'server.js')], {
|
||||||
|
cwd: srvCwd,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(port),
|
||||||
|
DATA_DIR: dataDir,
|
||||||
|
DB_PATH: path.join(dataDir, 'ytplayer.db'),
|
||||||
|
MEDIA_DIR: path.join(dataDir, 'media'),
|
||||||
|
},
|
||||||
|
stdio: 'ignore',
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
try {
|
||||||
|
if (serverProcess) serverProcess.kill('SIGKILL');
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
rmSync(scratchDir, { recursive: true, force: true });
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||||||
|
process.on('SIGTERM', () => { cleanup(); process.exit(1); });
|
||||||
|
|
||||||
|
let browser;
|
||||||
|
try {
|
||||||
|
let ready = false;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/api/version`);
|
||||||
|
if (res.ok) {
|
||||||
|
ready = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
if (!ready) {
|
||||||
|
throw new Error(`Bun server failed to start on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
browser = await chromium.launch({ headless: true });
|
||||||
|
const ctx = await browser.newContext({
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
hasTouch: true,
|
||||||
|
isMobile: true,
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.addInitScript(() => {
|
||||||
|
const act = new Map();
|
||||||
|
const si = window.setInterval.bind(window);
|
||||||
|
const ci = window.clearInterval.bind(window);
|
||||||
|
window.setInterval = (fn, ms, ...a) => {
|
||||||
|
const id = si(fn, ms, ...a);
|
||||||
|
act.set(id, (fn && fn.name) || 'anon' + ms);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
window.clearInterval = (id) => {
|
||||||
|
act.delete(id);
|
||||||
|
return ci(id);
|
||||||
|
};
|
||||||
|
window.__names = () => [...act.values()].sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
const pageErrors = [];
|
||||||
|
page.on('pageerror', (err) => pageErrors.push(err.message));
|
||||||
|
|
||||||
|
await page.goto(`http://127.0.0.1:${port}/index.html`);
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
const getNames = async () => (await page.evaluate(() => window.__names()));
|
||||||
|
const gated = ['chips', 'reanchor', 'rotateChips', 'flush', 'checkBuildTag'];
|
||||||
|
const mustRun = ['maybeCheck', 'anon120000'];
|
||||||
|
|
||||||
|
const vt = await page.evaluate(() => typeof window.VisibleTimer);
|
||||||
|
if (vt !== 'object') {
|
||||||
|
throw new Error(`VisibleTimer not available on window (got ${vt})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Initial visible state
|
||||||
|
const visible1 = await getNames();
|
||||||
|
const visibleGated = gated.filter((g) => visible1.includes(g));
|
||||||
|
|
||||||
|
// 2. Transition to hidden
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
||||||
|
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'hidden' });
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const hidden = await getNames();
|
||||||
|
const hiddenGated = gated.filter((g) => hidden.includes(g));
|
||||||
|
|
||||||
|
// 3. Transition back to visible
|
||||||
|
await page.evaluate(() => {
|
||||||
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||||
|
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => 'visible' });
|
||||||
|
document.dispatchEvent(new Event('visibilitychange'));
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const visible2 = await getNames();
|
||||||
|
const backVisibleGated = gated.filter((g) => visible2.includes(g));
|
||||||
|
|
||||||
|
console.log('=== Hidden Timers Verification ===');
|
||||||
|
console.log(`Initial visible intervals (${visible1.length}):`, visible1.join(', '));
|
||||||
|
console.log(`Hidden intervals (${hidden.length}):`, hidden.join(', '));
|
||||||
|
console.log(`Restored visible intervals (${visible2.length}):`, visible2.join(', '));
|
||||||
|
console.log('Page errors:', pageErrors.length ? pageErrors.join(', ') : 'none');
|
||||||
|
|
||||||
|
// Assertions
|
||||||
|
const failures = [];
|
||||||
|
|
||||||
|
// Assert all 5 gated timers were live initially
|
||||||
|
for (const name of gated) {
|
||||||
|
if (!visible1.includes(name)) {
|
||||||
|
failures.push(`Expected '${name}' to be live initially`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert none of the 5 gated timers is live while hidden
|
||||||
|
if (hiddenGated.length > 0) {
|
||||||
|
failures.push(`Expected 0 gated timers while hidden, but found: ${hiddenGated.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert all 5 gated timers are live after becoming visible again
|
||||||
|
for (const name of gated) {
|
||||||
|
if (!visible2.includes(name)) {
|
||||||
|
failures.push(`Expected '${name}' to be live after becoming visible again`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert must-run timers stay live while hidden
|
||||||
|
for (const name of mustRun) {
|
||||||
|
if (!hidden.includes(name)) {
|
||||||
|
failures.push(`Expected must-run timer '${name}' to stay live while hidden`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert no page errors
|
||||||
|
if (pageErrors.length > 0) {
|
||||||
|
failures.push(`Encountered page errors: ${pageErrors.join('; ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length > 0) {
|
||||||
|
console.error('\nFAILURES:');
|
||||||
|
for (const f of failures) console.error(` - ${f}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} else {
|
||||||
|
console.log('\nResult: ALL ASSERTIONS PASSED');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Fatal error in hidden-timers check:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
126
perf/make-shell-fixture.sh
Executable file
126
perf/make-shell-fixture.sh
Executable file
@@ -0,0 +1,126 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# perf/make-shell-fixture.sh
|
||||||
|
# Archives frontend from <commit> into perf/fixtures/shell-<tag>/,
|
||||||
|
# computes the build tag the way server.js computeBuildTag() does,
|
||||||
|
# and verifies that it matches expected-tag (or live prod tag).
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
if [ $# -lt 1 ]; then
|
||||||
|
echo "Usage: $0 <commit> [expected-tag]" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
COMMIT="$1"
|
||||||
|
EXPECTED_TAG="${2:-}"
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
PERF_DIR="$REPO_ROOT/perf"
|
||||||
|
FIXTURES_DIR="$PERF_DIR/fixtures"
|
||||||
|
TMP_DIR="$PERF_DIR/.tmp"
|
||||||
|
|
||||||
|
mkdir -p "$FIXTURES_DIR" "$TMP_DIR"
|
||||||
|
|
||||||
|
PROD_URL="https://worship.hesed.sbs/api/version"
|
||||||
|
|
||||||
|
fetch_prod_tag() {
|
||||||
|
curl -s -S --max-time 10 "$PROD_URL" | node -e '
|
||||||
|
let data = "";
|
||||||
|
process.stdin.on("data", chunk => { data += chunk; });
|
||||||
|
process.stdin.on("end", () => {
|
||||||
|
try {
|
||||||
|
const j = JSON.parse(data);
|
||||||
|
if (j.buildTag) {
|
||||||
|
process.stdout.write(j.buildTag);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
' 2>/dev/null || echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify the commit exists in git
|
||||||
|
if ! git rev-parse --verify "$COMMIT^{commit}" >/dev/null 2>&1; then
|
||||||
|
echo "Error: commit '$COMMIT' not found in git repository" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESOLVED_COMMIT="$(git rev-parse --short=7 "$COMMIT")"
|
||||||
|
echo "Extracting frontend from commit $RESOLVED_COMMIT..."
|
||||||
|
|
||||||
|
STAGING_DIR="$(mktemp -d "$TMP_DIR/fixture-staging-XXXXXX")"
|
||||||
|
cleanup() {
|
||||||
|
rm -rf "$STAGING_DIR"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# Archive frontend tree into staging dir
|
||||||
|
git archive "$COMMIT" frontend | tar -x -C "$STAGING_DIR" --strip-components=1
|
||||||
|
|
||||||
|
# Compute build tag using the exact logic from server/server.js computeBuildTag()
|
||||||
|
COMPUTED_TAG=$(node -e '
|
||||||
|
const { createHash } = require("crypto");
|
||||||
|
const { readdirSync, statSync, readFileSync } = require("fs");
|
||||||
|
|
||||||
|
function computeTag(dir) {
|
||||||
|
const hash = createHash("sha256");
|
||||||
|
const walk = (d, prefix) => {
|
||||||
|
for (const name of readdirSync(d).sort()) {
|
||||||
|
const full = `${d}/${name}`;
|
||||||
|
const rel = `${prefix}/${name}`;
|
||||||
|
if (statSync(full).isDirectory()) {
|
||||||
|
walk(full, rel);
|
||||||
|
} else {
|
||||||
|
hash.update(rel);
|
||||||
|
hash.update(readFileSync(full));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(dir, "./public");
|
||||||
|
return hash.digest("hex").slice(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = computeTag(process.argv[1]);
|
||||||
|
process.stdout.write(tag);
|
||||||
|
' "$STAGING_DIR")
|
||||||
|
|
||||||
|
echo "Computed build tag for $RESOLVED_COMMIT: $COMPUTED_TAG"
|
||||||
|
|
||||||
|
# Determine target expected tag
|
||||||
|
if [ -z "$EXPECTED_TAG" ]; then
|
||||||
|
echo "Querying live production tag from $PROD_URL..."
|
||||||
|
EXPECTED_TAG="$(fetch_prod_tag)"
|
||||||
|
if [ -z "$EXPECTED_TAG" ]; then
|
||||||
|
echo "Error: failed to fetch live buildTag from $PROD_URL" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Live production build tag: $EXPECTED_TAG"
|
||||||
|
|
||||||
|
# If mismatch, deploy might be in progress: wait 5 minutes and retry once
|
||||||
|
if [ "$COMPUTED_TAG" != "$EXPECTED_TAG" ]; then
|
||||||
|
echo "Tag mismatch detected (computed=$COMPUTED_TAG vs prod=$EXPECTED_TAG)."
|
||||||
|
echo "Deploy may be in progress. Waiting 300 seconds to retry once..."
|
||||||
|
sleep 300
|
||||||
|
EXPECTED_TAG="$(fetch_prod_tag)"
|
||||||
|
echo "Retried production build tag: $EXPECTED_TAG"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$COMPUTED_TAG" != "$EXPECTED_TAG" ]; then
|
||||||
|
echo "Error: build tag mismatch! Computed '$COMPUTED_TAG' does not match expected '$EXPECTED_TAG'" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_FIXTURE_DIR="$FIXTURES_DIR/shell-$COMPUTED_TAG"
|
||||||
|
echo "Installing fixture into $TARGET_FIXTURE_DIR..."
|
||||||
|
rm -rf "$TARGET_FIXTURE_DIR"
|
||||||
|
mkdir -p "$TARGET_FIXTURE_DIR"
|
||||||
|
cp -a "$STAGING_DIR"/. "$TARGET_FIXTURE_DIR"/
|
||||||
|
|
||||||
|
FILE_COUNT=$(find "$TARGET_FIXTURE_DIR" -type f | wc -l)
|
||||||
|
echo "Success: shell fixture created at $TARGET_FIXTURE_DIR with $FILE_COUNT files."
|
||||||
|
echo "Verified commit: $RESOLVED_COMMIT"
|
||||||
|
echo "Verified build tag: $COMPUTED_TAG"
|
||||||
201
perf/proxy.mjs
Normal file
201
perf/proxy.mjs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import http from 'node:http';
|
||||||
|
import { Transform } from 'node:stream';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ThrottleTransform
|
||||||
|
* Delays stream chunks to simulate bandwidth caps (e.g. 1.6 Mbps = 200,000 B/s).
|
||||||
|
*/
|
||||||
|
class ThrottleTransform extends Transform {
|
||||||
|
constructor(bytesPerSec) {
|
||||||
|
super();
|
||||||
|
this.bytesPerSec = bytesPerSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
_transform(chunk, encoding, callback) {
|
||||||
|
if (!this.bytesPerSec || this.bytesPerSec <= 0) {
|
||||||
|
this.push(chunk);
|
||||||
|
return callback();
|
||||||
|
}
|
||||||
|
const delayMs = (chunk.length / this.bytesPerSec) * 1000;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.push(chunk);
|
||||||
|
callback();
|
||||||
|
}, Math.min(delayMs, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize Content-Type into standard metric buckets.
|
||||||
|
*/
|
||||||
|
export function categorizeContentType(contentType, urlPath) {
|
||||||
|
const ct = (contentType || '').toLowerCase();
|
||||||
|
const path = (urlPath || '').toLowerCase();
|
||||||
|
|
||||||
|
if (ct.includes('text/html') || path.endsWith('.html')) return 'html';
|
||||||
|
if (ct.includes('javascript') || path.endsWith('.js') || path.endsWith('.mjs')) return 'js';
|
||||||
|
if (ct.includes('text/css') || path.endsWith('.css')) return 'css';
|
||||||
|
if (ct.includes('font') || path.endsWith('.woff2') || path.endsWith('.woff') || path.endsWith('.ttf')) return 'fonts';
|
||||||
|
if (ct.includes('image/') || path.endsWith('.png') || path.endsWith('.jpg') || path.endsWith('.svg') || path.endsWith('.webp') || path.endsWith('.ico')) return 'images';
|
||||||
|
if (ct.includes('video/') || ct.includes('audio/') || path.endsWith('.mp4') || path.endsWith('.m4a') || path.endsWith('.mp3')) return 'media';
|
||||||
|
if (ct.includes('application/json') || path.startsWith('/api/')) return 'api';
|
||||||
|
return 'other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an HTTP throttling & stalling proxy for WebKit (and browser network tracing).
|
||||||
|
* Supports both forward proxying and reverse proxying to targetPort.
|
||||||
|
*/
|
||||||
|
export function createThrottleProxy({ targetPort, profile = 'lte' }) {
|
||||||
|
let activeTracking = false;
|
||||||
|
let requestCount = 0;
|
||||||
|
let totalWireBytes = 0;
|
||||||
|
let bytesByType = {
|
||||||
|
html: 0,
|
||||||
|
js: 0,
|
||||||
|
css: 0,
|
||||||
|
fonts: 0,
|
||||||
|
images: 0,
|
||||||
|
media: 0,
|
||||||
|
api: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let reqIndex = 0;
|
||||||
|
let isOfflineMode = false;
|
||||||
|
|
||||||
|
// Profile configuration:
|
||||||
|
// LTE: 150ms roundtrip latency, 1.6 Mbps (200 KB/s) download, 750 kbps upload
|
||||||
|
// Lossy: 250ms latency, 1.0 Mbps (125 KB/s) download, stall every 7th request by 1500ms
|
||||||
|
// Unthrottled: 0 latency, 0 throttle (for Chromium where CDP throttles network/CPU)
|
||||||
|
const isLossy = profile === 'lossy';
|
||||||
|
const isUnthrottled = profile === 'unthrottled';
|
||||||
|
const latencyMs = isUnthrottled ? 0 : (isLossy ? 250 : 150);
|
||||||
|
const downloadBytesPerSec = isUnthrottled ? 0 : (isLossy ? (1.0 * 1000 * 1000) / 8 : (1.6 * 1000 * 1000) / 8);
|
||||||
|
|
||||||
|
const server = http.createServer((clientReq, clientRes) => {
|
||||||
|
if (isOfflineMode) {
|
||||||
|
clientReq.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reqIndex++;
|
||||||
|
const currentReqIndex = reqIndex;
|
||||||
|
|
||||||
|
let stallMs = 0;
|
||||||
|
if (isLossy && currentReqIndex % 7 === 0) {
|
||||||
|
stallMs = 1500; // Simulated lossy stall
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDelay = isUnthrottled ? 0 : (latencyMs + stallMs);
|
||||||
|
|
||||||
|
const rawUrl = clientReq.url;
|
||||||
|
const targetUrl = rawUrl.startsWith('http://') || rawUrl.startsWith('https://')
|
||||||
|
? rawUrl
|
||||||
|
: `http://127.0.0.1:${targetPort}${rawUrl}`;
|
||||||
|
|
||||||
|
const parsedTarget = new URL(targetUrl);
|
||||||
|
const pathname = parsedTarget.pathname;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
protocol: parsedTarget.protocol,
|
||||||
|
hostname: parsedTarget.hostname,
|
||||||
|
port: parsedTarget.port,
|
||||||
|
path: parsedTarget.pathname + parsedTarget.search,
|
||||||
|
method: clientReq.method,
|
||||||
|
headers: { ...clientReq.headers, host: parsedTarget.host },
|
||||||
|
};
|
||||||
|
|
||||||
|
const forward = () => {
|
||||||
|
const upReq = http.request(options, (upRes) => {
|
||||||
|
const ct = upRes.headers['content-type'] || '';
|
||||||
|
const cat = categorizeContentType(ct, pathname);
|
||||||
|
|
||||||
|
if (activeTracking) {
|
||||||
|
requestCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientRes.writeHead(upRes.statusCode, upRes.headers);
|
||||||
|
|
||||||
|
if (isUnthrottled) {
|
||||||
|
upRes.on('data', (chunk) => {
|
||||||
|
if (activeTracking) {
|
||||||
|
totalWireBytes += chunk.length;
|
||||||
|
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
||||||
|
}
|
||||||
|
clientRes.write(chunk);
|
||||||
|
});
|
||||||
|
upRes.on('end', () => clientRes.end());
|
||||||
|
} else {
|
||||||
|
const throttler = new ThrottleTransform(downloadBytesPerSec);
|
||||||
|
|
||||||
|
throttler.on('data', (chunk) => {
|
||||||
|
if (activeTracking) {
|
||||||
|
totalWireBytes += chunk.length;
|
||||||
|
bytesByType[cat] = (bytesByType[cat] || 0) + chunk.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
upRes.pipe(throttler).pipe(clientRes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
upReq.on('error', (err) => {
|
||||||
|
try {
|
||||||
|
clientRes.statusCode = 502;
|
||||||
|
clientRes.end(err.message);
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientReq.pipe(upReq);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totalDelay > 0) {
|
||||||
|
setTimeout(forward, totalDelay);
|
||||||
|
} else {
|
||||||
|
forward();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
listen: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const port = server.address().port;
|
||||||
|
resolve(port);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
close: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
server.close(resolve);
|
||||||
|
}),
|
||||||
|
startTracking: () => {
|
||||||
|
activeTracking = true;
|
||||||
|
},
|
||||||
|
stopTracking: () => {
|
||||||
|
activeTracking = false;
|
||||||
|
},
|
||||||
|
resetStats: () => {
|
||||||
|
requestCount = 0;
|
||||||
|
totalWireBytes = 0;
|
||||||
|
bytesByType = {
|
||||||
|
html: 0,
|
||||||
|
js: 0,
|
||||||
|
css: 0,
|
||||||
|
fonts: 0,
|
||||||
|
images: 0,
|
||||||
|
media: 0,
|
||||||
|
api: 0,
|
||||||
|
other: 0,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
setOffline: (val) => {
|
||||||
|
isOfflineMode = !!val;
|
||||||
|
},
|
||||||
|
getStats: () => ({
|
||||||
|
requestCount,
|
||||||
|
totalWireBytes,
|
||||||
|
bytesByType: { ...bytesByType },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
1639
perf/results/after-phase0-2026-10-07.json
Normal file
1639
perf/results/after-phase0-2026-10-07.json
Normal file
File diff suppressed because it is too large
Load Diff
1639
perf/results/baseline-2026-10-07.json
Normal file
1639
perf/results/baseline-2026-10-07.json
Normal file
File diff suppressed because it is too large
Load Diff
249
plans/perf-baseline.md
Normal file
249
plans/perf-baseline.md
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
# Performance Baseline Report (Phase 0 / UNCHANGED code)
|
||||||
|
|
||||||
|
**Date**: 2026-10-07
|
||||||
|
**Commit**: `b77938a`
|
||||||
|
**Dataset**: `perf/results/baseline-2026-10-07.json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary & Headline Baseline Numbers
|
||||||
|
|
||||||
|
This baseline captures the performance metrics of the unoptimized ytplayer codebase (commit `b77938a`, matching production tag `1175f1a1d2c1`) across 5 scenarios on both Chromium and WebKit under simulated LTE and Lossy network profiles (median of 5 runs).
|
||||||
|
|
||||||
|
### Headline Baseline Numbers
|
||||||
|
|
||||||
|
- **Cold First-Load Wire Transfer**:
|
||||||
|
- **Chromium**: 164 requests, **1,015.0 KB** compressed wire bytes (boot-done median: **10,163 ms** under 4× CPU throttle + LTE).
|
||||||
|
- **WebKit**: 162 requests, **1,152.0 KB (1.12 MB)** wire bytes (boot-done median: **3,201 ms** under proxy LTE).
|
||||||
|
- Breakdown: ~477 KB JavaScript, ~397 KB Web Fonts, ~131 KB CSS, ~27 KB HTML.
|
||||||
|
|
||||||
|
- **Warm Reload (under Service Worker)**:
|
||||||
|
- **Chromium**: 4 requests, **5.2 KB** wire bytes (periodic API version poll), boot-done median: **675 ms** (LTE) / **651 ms** (Lossy).
|
||||||
|
- **WebKit**: 0 requests, **0 KB** wire bytes, boot-done median: **131 ms** (LTE) / **131 ms** (Lossy).
|
||||||
|
|
||||||
|
- **Offline Reload (under Service Worker)**:
|
||||||
|
- **Chromium**: 0 requests, **0 B** wire bytes, reload median: **240 ms**, boot-done median: **219 ms**.
|
||||||
|
- **WebKit**: 0 requests, **0 B** wire bytes, reload median: **135 ms**, boot-done median: **128 ms**.
|
||||||
|
|
||||||
|
- **Update N -> N+1 Wire Cost (The Invalidation Penalty)**:
|
||||||
|
- **1-line `app.js` change**:
|
||||||
|
- **Chromium**: 88 requests, **596.6 KB** wire bytes (~611 KB uncompressed/brotli), duration: **2,013 ms** (LTE) / **2,031 ms** (Lossy).
|
||||||
|
- **WebKit**: 86 requests, **664.3 KB** wire bytes (~680 KB wire), duration: **4,457 ms** (LTE) / **8,135 ms** (Lossy).
|
||||||
|
- **1-line `theme-glass.css` change**:
|
||||||
|
- **Chromium**: 88 requests, **596.7 KB** wire bytes, duration: **1,970 ms** (LTE) / **2,004 ms** (Lossy).
|
||||||
|
- **WebKit**: 86-87 requests, **664.3 KB** wire bytes, duration: **4,510 ms** (LTE) / **8,269 ms** (Lossy).
|
||||||
|
|
||||||
|
> **Core Invalidation Finding**: A single 1-line change to *any* static file (`app.js` or `theme-glass.css`) mutates `BUILD_TAG` calculated by `computeBuildTag()`. This causes `sw.js` to re-fetch and re-cache all 86-88 shell assets (~600-664 KB wire payload) rather than just the mutated resource.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Benchmark Harness Execution
|
||||||
|
|
||||||
|
The benchmark harness is located in `perf/baseline.mjs` and driven by Playwright with an isolated ephemeral Bun server and a custom pacing proxy (`perf/proxy.mjs`).
|
||||||
|
|
||||||
|
### Quick Start / Exact Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure node_modules is symlinked to root
|
||||||
|
ln -s ~/development/personal/ytplayer/node_modules node_modules
|
||||||
|
|
||||||
|
# Run full baseline suite (all scenarios, both browsers, both profiles, 5 runs)
|
||||||
|
node perf/baseline.mjs --runs 5 --browser all --profile all --scenario all --out perf/results/baseline-2026-10-07.json
|
||||||
|
|
||||||
|
# Run individual browser or scenario
|
||||||
|
node perf/baseline.mjs --runs 5 --browser chromium --profile lte --scenario cold
|
||||||
|
node perf/baseline.mjs --runs 5 --browser webkit --profile lte --scenario update-js
|
||||||
|
|
||||||
|
# Run comparison against baseline after optimizations
|
||||||
|
node perf/baseline.mjs --runs 5 --browser all --profile all --scenario all --compare perf/results/baseline-2026-10-07.json
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Results by Browser and Profile (Median of 5 Runs)
|
||||||
|
|
||||||
|
### Chromium — LTE (1.6 Mbps down, 750 kbps up, 150 ms RTT, 4× CPU throttle)
|
||||||
|
|
||||||
|
| Scenario | Reqs (med) | Wire Bytes (med) | FCP (ms) | LCP (ms) | Boot Done (ms) | SW Ready / Reload (ms) | Long Task Total (ms) |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| **Cold First Load** | 164 | 1,015.0 KB | 1,540 | 1,896 | 10,163 | 38,446 | 397 |
|
||||||
|
| **Warm Reload (SW)** | 4 | 5.2 KB | 180 | 684 | 675 | - | 156 |
|
||||||
|
| **Offline Reload** | 0 | 0.0 KB | 72 | 108 | 219 | 240 | - |
|
||||||
|
| **Update N->N+1 (JS)** | 88 | 596.6 KB | - | - | - | 2,013 | - |
|
||||||
|
| **Update N->N+1 (CSS)** | 88 | 596.7 KB | - | - | - | 1,970 | - |
|
||||||
|
|
||||||
|
- **Storage Estimate**: Quota = 4,298,447,325 B (~4.0 GB), Usage = 3,480,029 B (~3.31 MB).
|
||||||
|
- **Cold Wire Bytes by Type**: JS 477.2 KB, Fonts 397.1 KB, CSS 130.7 KB, HTML 27.1 KB, Images 6.3 KB, API/Other 1.0 KB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Chromium — Lossy (1.0 Mbps down, 500 kbps up, 250 ms RTT, 4× CPU throttle)
|
||||||
|
|
||||||
|
| Scenario | Reqs (med) | Wire Bytes (med) | FCP (ms) | LCP (ms) | Boot Done (ms) | SW Ready / Reload (ms) | Long Task Total (ms) |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| **Cold First Load** | 164 | 1,015.0 KB | 2,344 | 2,912 | 16,015 | 42,518 | 432 |
|
||||||
|
| **Warm Reload (SW)** | 4 | 5.2 KB | 184 | 644 | 651 | - | 155 |
|
||||||
|
| **Offline Reload** | 0 | 0.0 KB | 80 | 92 | 237 | 253 | - |
|
||||||
|
| **Update N->N+1 (JS)** | 88 | 596.6 KB | - | - | - | 2,031 | - |
|
||||||
|
| **Update N->N+1 (CSS)** | 88 | 596.7 KB | - | - | - | 2,004 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WebKit — LTE (1.6 Mbps down, 150 ms RTT via proxy)
|
||||||
|
|
||||||
|
| Scenario | Reqs (med) | Wire Bytes (med) | FCP (ms) | LCP (ms) | Boot Done (ms) | SW Ready / Reload (ms) | Long Task Total (ms) |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| **Cold First Load** | 162 | 1.12 MB | 1,088 | 1,287 | 3,201 | 30,508 | 0* |
|
||||||
|
| **Warm Reload (SW)** | 0 | 0.0 KB | 110 | 110 | 131 | - | 0* |
|
||||||
|
| **Offline Reload** | 0 | 0.0 KB | 107 | 107 | 128 | 135 | - |
|
||||||
|
| **Update N->N+1 (JS)** | 86 | 664.3 KB | - | - | - | 4,457 | - |
|
||||||
|
| **Update N->N+1 (CSS)** | 86 | 664.3 KB | - | - | - | 4,510 | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### WebKit — Lossy (1.0 Mbps down, 250 ms RTT + 1,500 ms stalls via proxy)
|
||||||
|
|
||||||
|
| Scenario | Reqs (med) | Wire Bytes (med) | FCP (ms) | LCP (ms) | Boot Done (ms) | SW Ready / Reload (ms) | Long Task Total (ms) |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| **Cold First Load** | 162 | 1.12 MB | 3,557 | 3,735 | 7,303 | 35,449 | 0* |
|
||||||
|
| **Warm Reload (SW)** | 0 | 0.0 KB | 111 | 112 | 131 | - | 0* |
|
||||||
|
| **Offline Reload** | 0 | 0.0 KB | 109 | 119 | 129 | 141 | - |
|
||||||
|
| **Update N->N+1 (JS)** | 86 | 664.3 KB | - | - | - | 8,135 | - |
|
||||||
|
| **Update N->N+1 (CSS)** | 87 | 664.4 KB | - | - | - | 8,269 | - |
|
||||||
|
|
||||||
|
*\*Note: WebKit does not expose `PerformanceLongTaskTiming` (`longtask` entries), so long task total evaluates to 0 ms.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Variance & Noise Observed
|
||||||
|
|
||||||
|
Over the 5 runs per configuration, measurements demonstrated high stability:
|
||||||
|
- **Wire Bytes & Request Counts**: Deterministic (0% variance across identical runs).
|
||||||
|
- **Cold Boot-Done**:
|
||||||
|
- Chromium LTE: Min 10,129 ms, Max 10,798 ms (<6% variance).
|
||||||
|
- Chromium Lossy: Min 15,871 ms, Max 16,031 ms (<1% variance).
|
||||||
|
- WebKit LTE: Min 3,138 ms, Max 3,245 ms (<3% variance).
|
||||||
|
- WebKit Lossy: Min 6,963 ms, Max 8,710 ms (~20% variance due to proxy random stalls).
|
||||||
|
- **Warm & Offline Reload**:
|
||||||
|
- Chromium Offline Reload: Min 238 ms, Max 257 ms (<8% variance).
|
||||||
|
- WebKit Offline Reload: Min 131 ms, Max 141 ms (<7% variance).
|
||||||
|
- **Update Duration**:
|
||||||
|
- Chromium: Runs cluster around ~2,000 ms (2,013 ms median). Occasional outlier runs (~45–200 ms) occur if the browser resolves `reg.update()` immediately before network completion reporting.
|
||||||
|
- WebKit: Steady scaling between LTE (~4.5 s) and Lossy (~8.2 s) pacing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Limitations & Caveats
|
||||||
|
|
||||||
|
1. **WebKit Network Throttling**:
|
||||||
|
WebKit lacks Chrome DevTools Protocol (CDP) support for hardware network emulation and CPU throttling. We paced throughput (1.6 Mbps) and injected latency/stalls using a custom local reverse proxy (`perf/proxy.mjs`). This throttles HTTP response chunks truthfully but cannot simulate OS-level socket buffer delays or 4× CPU throttling.
|
||||||
|
2. **Autoplay Policy in WebKit**:
|
||||||
|
Playwright WebKit on Linux rejects `--autoplay-policy=no-user-gesture-required` and enforces Safari-style gesture requirements on unmuted audio elements. The media playback benchmark detects this and correctly reports `autoplayBlocked: true` without failing the test run.
|
||||||
|
3. **WebKit SetOffline Incompatibility**:
|
||||||
|
Calling Playwright's `context.setOffline(true)` on Linux WebKit causes an unrecoverable engine crash (`WebKit encountered an internal error`). Offline mode for WebKit is simulated by terminating the proxy upstream connection (`proxy.setOffline(true)`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Manual iPhone Testing Protocol (Phase 0 / iOS Safari PWA)
|
||||||
|
|
||||||
|
Because automated headless WebKit cannot fully replicate iOS Safari's mobile background constraints and battery telemetry, the following manual protocol is required:
|
||||||
|
|
||||||
|
### Protocol Steps
|
||||||
|
|
||||||
|
1. **Install PWA**:
|
||||||
|
- Open Safari on iPhone and navigate to `https://worship.hesed.sbs`.
|
||||||
|
- Tap the Share button -> **"Add to Home Screen"**.
|
||||||
|
- Launch ytplayer directly from the Home Screen icon.
|
||||||
|
|
||||||
|
2. **Verify Version & Cache**:
|
||||||
|
- Verify the footer / version string matches `1175f1a1d2c1` (or current production build tag).
|
||||||
|
- Turn on Airplane Mode and force-reload to confirm offline readiness. Turn Airplane Mode back off.
|
||||||
|
|
||||||
|
3. **30-Minute Screen-Off Battery Test**:
|
||||||
|
- Note baseline battery percentage (e.g., 85%).
|
||||||
|
- Select a pre-cached playlist or track and tap **Play**. Ensure continuous audio is heard.
|
||||||
|
- Lock the screen (display off) and let playback run uninterrupted for **30 minutes**.
|
||||||
|
- Note battery percentage at 30 minutes (calculate drain %/hr).
|
||||||
|
- Listen for any audio dropouts, stuttering, or premature process suspension.
|
||||||
|
|
||||||
|
4. **Interruption & Resumption Test**:
|
||||||
|
- Trigger an interruption while locked (e.g., initiate an incoming phone call or start Siri).
|
||||||
|
- End the interruption and observe if playback automatically resumes via `MediaSession` handlers.
|
||||||
|
- Test Lock Screen Controls (Play/Pause, Next Track, scrubber).
|
||||||
|
|
||||||
|
5. **Log Findings**:
|
||||||
|
- Record iPhone Model, iOS Version, Battery Drain (% per 30m), Dropouts Count (0 = pass), and Resumption Status (Pass/Fail).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. After Phase 0 Benchmark (Step S5 Verification)
|
||||||
|
|
||||||
|
**Date**: 2026-10-07
|
||||||
|
**Commit**: `eb6aae7`
|
||||||
|
**Dataset**: `perf/results/after-phase0-2026-10-07.json` (compared against `perf/results/baseline-2026-10-07.json`)
|
||||||
|
|
||||||
|
### Headline Before vs. After Comparison Table (Median of 5 Runs)
|
||||||
|
|
||||||
|
| Browser / Profile | Scenario | Requests (Before → After) | Wire Bytes (Before → After) | FCP / LCP (ms) | Boot Done (ms) | SW Dur / Reload (ms) |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| **Chromium LTE** | Cold First Load | 164 → 166 (+1.2%) | 1,015.0 KB → 1,015.8 KB (+0.1%) | 1540/1896 → 1572/1916 | 10,163 → 10,480 (+3.1%) | 38,446 → 38,364 |
|
||||||
|
| | Warm Reload (SW) | 4 → 4 (0.0%) | 5.2 KB → 5.2 KB (0.0%) | 180/684 → 184/680 | 675 → 692 (+2.5%) | - |
|
||||||
|
| | Offline Reload | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 72/108 → 76/112 | 219 → 259 | 240 → 282 |
|
||||||
|
| | Update (1-line JS) | 88 → 89 (+1.1%) | 596.6 KB → 597.0 KB (+0.1%) | - | - | 2,013 → 2,155 (+7.1%) |
|
||||||
|
| | Update (1-line CSS) | 88 → 89 (+1.1%) | 596.7 KB → 597.1 KB (+0.1%) | - | - | 1,970 → 2,107 (+7.0%) |
|
||||||
|
| **Chromium Lossy** | Cold First Load | 164 → 166 (+1.2%) | 1,015.0 KB → 1,015.8 KB (+0.1%) | 2344/2912 → 2372/2940 | 16,015 → 16,408 (+2.5%) | 42,518 → 42,031 |
|
||||||
|
| | Warm Reload (SW) | 4 → 4 (0.0%) | 5.2 KB → 5.2 KB (0.0%) | 184/644 → 188/648 | 651 → 768 (+18.0%) | - |
|
||||||
|
| | Offline Reload | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 80/92 → 84/96 | 237 → 241 | 253 → 262 |
|
||||||
|
| | Update (1-line JS) | 88 → 89 (+1.1%) | 596.6 KB → 597.0 KB (+0.1%) | - | - | 2,031 → 2,115 (+4.1%) |
|
||||||
|
| | Update (1-line CSS) | 88 → 89 (+1.1%) | 596.7 KB → 597.1 KB (+0.1%) | - | - | 2,004 → 2,116 (+5.6%) |
|
||||||
|
| **WebKit LTE** | Cold First Load | 162 → 164 (+1.2%) | 1.12 MB → 1.13 MB (+0.1%) | 1088/1287 → 1100/1303 | 3,201 → 3,290 (+2.8%) | 30,508 → 31,026 |
|
||||||
|
| | Warm Reload (SW) | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 110/110 → 112/112 | 131 → 139 (+6.1%) | - |
|
||||||
|
| | Offline Reload | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 107/107 → 110/110 | 128 → 137 | 135 → 148 |
|
||||||
|
| | Update (1-line JS) | 86 → 87 (+1.2%) | 664.3 KB → 664.9 KB (+0.1%) | - | - | 4,457 → 4,584 (+2.8%) |
|
||||||
|
| | Update (1-line CSS) | 86 → 87 (+1.2%) | 664.3 KB → 664.9 KB (+0.1%) | - | - | 4,510 → 4,550 (+0.9%) |
|
||||||
|
| **WebKit Lossy** | Cold First Load | 162 → 164 (+1.2%) | 1.12 MB → 1.13 MB (+0.1%) | 3557/3735 → 3738/3905 | 7,303 → 7,305 (+0.0%) | 35,449 → 35,101 |
|
||||||
|
| | Warm Reload (SW) | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 111/112 → 112/114 | 131 → 128 (-2.3%) | - |
|
||||||
|
| | Offline Reload | 0 → 0 (0.0%) | 0 B → 0 B (0.0%) | 109/119 → 111/120 | 129 → 132 | 141 → 139 |
|
||||||
|
| | Update (1-line JS) | 86 → 87 (+1.2%) | 664.3 KB → 664.9 KB (+0.1%) | - | - | 8,135 → 10,017 (+23.1%) |
|
||||||
|
| | Update (1-line CSS) | 87 → 87 (0.0%) | 664.4 KB → 664.9 KB (+0.1%) | - | - | 8,269 → 10,013 (+21.1%) |
|
||||||
|
|
||||||
|
### Hidden Timers Verification Result
|
||||||
|
|
||||||
|
Executed via `node perf/hidden-timers.mjs`:
|
||||||
|
```
|
||||||
|
=== Hidden Timers Verification ===
|
||||||
|
Initial visible intervals (8): anon1000, anon120000, checkBuildTag, chips, flush, maybeCheck, reanchor, rotateChips
|
||||||
|
Hidden intervals (3): anon1000, anon120000, maybeCheck
|
||||||
|
Restored visible intervals (9): anon1000, anon120000, anon50000, checkBuildTag, chips, flush, maybeCheck, reanchor, rotateChips
|
||||||
|
Page errors: none
|
||||||
|
|
||||||
|
Result: ALL ASSERTIONS PASSED
|
||||||
|
```
|
||||||
|
All five non-playback UI timers (`chips`, `reanchor`, `rotateChips`, `flush`, `checkBuildTag`) suspend immediately when `document.hidden` becomes true and resume immediately upon returning to visible, while essential background loops (`maybeCheck` inbox poll and `anon120000` 120s OPFS download resume check) remain continuously active.
|
||||||
|
|
||||||
|
### Variance & Noise Observed
|
||||||
|
|
||||||
|
Across the 5 iterations per scenario:
|
||||||
|
- **Transfer Volumes & Request Counts**: Completely deterministic (0.0% variance across all runs).
|
||||||
|
- **Boot Milestones & Paint Timings**: Stable within ±1–3% under LTE emulation on both Chromium and WebKit.
|
||||||
|
- **Update Duration under Lossy WebKit**: Exhibited expected network noise (~8.1 s vs ~10.0 s, ~21–23% delta) due to the random 1,500 ms connection stall injection pattern in `perf/proxy.mjs`.
|
||||||
|
- **Chromium Update Duration**: Clustered closely around ~2,100 ms with occasional ~100 ms fast runs when the browser resolves registration updates asynchronously.
|
||||||
|
|
||||||
|
### Findings
|
||||||
|
|
||||||
|
Introducing `frontend/visible-timer.js` successfully eliminates unnecessary CPU wakeups and timer execution during hidden tab states while adding virtually zero runtime transfer overhead (+1 request and +373–471 wire bytes on service worker updates; +2 requests and +843 wire bytes on initial cold visit). Background audio playback and essential network recovery handlers are preserved without regressing paint, boot, or offline capabilities.
|
||||||
|
|
||||||
|
### Structural Transfer Analysis: Fonts & Icons vs. Code, and Cold Double-Download
|
||||||
|
|
||||||
|
Detailed breakdown of network transfer patterns revealed two major inefficiencies in the current asset delivery architecture:
|
||||||
|
|
||||||
|
1. **Static Fonts and Icons vs. JS/CSS**:
|
||||||
|
- **Cold First Visit**: Total wire transfer is 1,040.2 KB. Web fonts (`PlusJakartaSans`, `BricolageGrotesque`, `HankenGrotesk`, `JetBrainsMono`, and `fonts.css`) consume **397.1 KB (38.2%)** and PNG icons consume **6.3 KB (0.6%)**, totaling **403.4 KB (38.8%)** of immutable media. Application JavaScript consumes **478.1 KB (46.0%)** and CSS consumes **130.7 KB (12.6%)**, totaling **608.8 KB (58.5%)**.
|
||||||
|
- **Update N -> N+1 Wire Cost**: On any single-line change to `app.js` or `theme-glass.css`, `sw.js` invalidates the entire shell cache and re-downloads all 89 `SHELL` items. Out of 611.3 KB transferred, **~259.5 KB (~42.5%)** is spent re-downloading static web fonts and icons that never changed, compared to **~321.9 KB (52.7%)** for JS/CSS.
|
||||||
|
2. **Cold First-Visit Shell Double-Download**:
|
||||||
|
- The initial visit downloads every app shell resource **twice over the wire**:
|
||||||
|
- First, the browser's HTML parser requests `index.html`, stylesheets, scripts, and fonts to render the document (83 requests, ~520 KB).
|
||||||
|
- Second, once `boot()` registers `/sw.js`, the service worker `install` event triggers `precacheShell()`. Line 165 of `frontend/sw.js` executes `fetch(new Request(url, { cache: 'reload' }))`.
|
||||||
|
- The explicit `{ cache: 'reload' }` directive bypasses the browser's HTTP cache entirely, forcing a redundant full re-download of all 89 shell assets from the server.
|
||||||
|
- As measured by the harness request log, cold first visit request count is **166 requests** (exactly double the shell count) and wire data transfer is **1,040 KB** (twice the unprimed payload).
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun server.js",
|
"start": "bun server.js",
|
||||||
"dev": "bun --hot server.js",
|
"dev": "bun --hot server.js",
|
||||||
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js"
|
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./static-delivery.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.14.0",
|
"@hono/node-server": "^1.14.0",
|
||||||
|
|||||||
158
server/static-delivery.test.js
Normal file
158
server/static-delivery.test.js
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||||
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { createServer } from 'node:net';
|
||||||
|
|
||||||
|
const repo = resolve(import.meta.dir, '..');
|
||||||
|
const serverFile = join(repo, 'server', 'server.js');
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'ytp-static-delivery-'));
|
||||||
|
const publicDir = join(root, 'public');
|
||||||
|
const dataDir = join(root, 'data');
|
||||||
|
mkdirSync(publicDir);
|
||||||
|
mkdirSync(dataDir);
|
||||||
|
mkdirSync(join(publicDir, 'fonts'));
|
||||||
|
mkdirSync(join(publicDir, 'icons'));
|
||||||
|
writeFileSync(join(publicDir, 'index.html'), '<!doctype html><meta name="ytp-build" content="__BUILD_TAG__"><link rel="stylesheet" href="app.css"><script src="app.js"></script><script src="https://cdn.example/x.js"></script><link rel="stylesheet" href="/root.css">');
|
||||||
|
writeFileSync(join(publicDir, 'app.css'), 'body{}');
|
||||||
|
writeFileSync(join(publicDir, 'app.js'), 'window.app=true;');
|
||||||
|
writeFileSync(join(publicDir, 'root.css'), 'body{color:red}');
|
||||||
|
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-test';\n");
|
||||||
|
writeFileSync(join(publicDir, 'fonts', 'font.woff2'), 'font');
|
||||||
|
writeFileSync(join(publicDir, 'icons', 'icon.png'), 'icon');
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
return await new Promise((resolvePort, reject) => {
|
||||||
|
const probe = createServer();
|
||||||
|
probe.once('error', reject);
|
||||||
|
probe.listen(0, '127.0.0.1', () => {
|
||||||
|
const port = probe.address().port;
|
||||||
|
probe.close((error) => error ? reject(error) : resolvePort(port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
const port = await freePort();
|
||||||
|
const child = Bun.spawn([process.execPath, serverFile], {
|
||||||
|
cwd: root,
|
||||||
|
env: { ...process.env, PORT: String(port), DB_PATH: join(dataDir, `db-${port}.sqlite`), MEDIA_DIR: join(dataDir, 'media'), UPLOAD_DIR: join(dataDir, 'uploads') },
|
||||||
|
stdout: 'pipe', stderr: 'pipe',
|
||||||
|
});
|
||||||
|
const stdout = new Response(child.stdout).text();
|
||||||
|
const stderr = new Response(child.stderr).text();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const deadline = Date.now() + 20000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (child.exitCode !== null) throw new Error(`server exited (${child.exitCode})\n${await stdout}\n${await stderr}`);
|
||||||
|
try { if ((await fetch(`${base}/api/version`)).ok) return { child, base, stdout, stderr }; } catch {}
|
||||||
|
await Bun.sleep(100);
|
||||||
|
}
|
||||||
|
child.kill();
|
||||||
|
throw new Error(`server did not start\n${await stdout}\n${await stderr}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopServer(server) {
|
||||||
|
if (!server) return;
|
||||||
|
server.child.kill();
|
||||||
|
await server.child.exited;
|
||||||
|
await server.stdout;
|
||||||
|
await server.stderr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let server;
|
||||||
|
beforeAll(async () => { server = await startServer(); });
|
||||||
|
afterAll(async () => { await stopServer(server); rmSync(root, { recursive: true, force: true }); });
|
||||||
|
|
||||||
|
describe('static delivery characterization', () => {
|
||||||
|
test('build tag is stable for the same tree and changes when any served file changes', async () => {
|
||||||
|
const first = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
await stopServer(server);
|
||||||
|
server = await startServer();
|
||||||
|
const same = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
expect(same.buildTag).toBe(first.buildTag);
|
||||||
|
writeFileSync(join(publicDir, 'unlisted.txt'), 'included in recursive build tag');
|
||||||
|
await stopServer(server);
|
||||||
|
server = await startServer();
|
||||||
|
const changed = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
expect(changed.buildTag).not.toBe(first.buildTag);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unreadable public tree uses the fixed dev-build fallback', async () => {
|
||||||
|
const moved = join(root, 'public.saved');
|
||||||
|
Bun.spawnSync(['mv', publicDir, moved]);
|
||||||
|
let fallback;
|
||||||
|
try {
|
||||||
|
await stopServer(server);
|
||||||
|
server = await startServer();
|
||||||
|
fallback = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
} finally {
|
||||||
|
await stopServer(server);
|
||||||
|
Bun.spawnSync(['mv', moved, publicDir]);
|
||||||
|
server = await startServer();
|
||||||
|
}
|
||||||
|
expect(fallback.buildTag).toBe('dev-build');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('index stamps only relative local css/js and serves expected cache headers', async () => {
|
||||||
|
const index = await fetch(`${server.base}/`);
|
||||||
|
const html = await index.text();
|
||||||
|
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
expect(html).toContain(`<meta name="ytp-build" content="${buildTag}">`);
|
||||||
|
expect(html).toContain(`href="app.css?v=${buildTag}"`);
|
||||||
|
expect(html).toContain(`src="app.js?v=${buildTag}"`);
|
||||||
|
expect(html).toContain('https://cdn.example/x.js');
|
||||||
|
expect(html).toContain('href="/root.css"');
|
||||||
|
expect(index.headers.get('cache-control')).toBe('no-cache');
|
||||||
|
expect((await fetch(`${server.base}/index.html`)).headers.get('cache-control')).toBe('no-cache');
|
||||||
|
|
||||||
|
const current = await fetch(`${server.base}/app.js?v=${buildTag}`, { headers: { 'Accept-Encoding': 'br' } });
|
||||||
|
expect(current.headers.get('cache-control')).toBe('public, max-age=31536000, immutable');
|
||||||
|
expect(current.headers.get('content-encoding')).toBe('br');
|
||||||
|
expect(current.headers.get('etag')).toBeTruthy();
|
||||||
|
expect((await fetch(`${server.base}/app.js?v=stale`)).headers.get('cache-control')).toBe('no-cache');
|
||||||
|
expect((await fetch(`${server.base}/app.js`)).headers.get('cache-control')).toBe('no-cache');
|
||||||
|
expect((await fetch(`${server.base}/fonts/font.woff2`)).headers.get('cache-control')).toBe('public, max-age=2592000');
|
||||||
|
expect((await fetch(`${server.base}/icons/icon.png`)).headers.get('cache-control')).toBe('public, max-age=2592000');
|
||||||
|
for (const path of ['/api/version', '/sw.js']) {
|
||||||
|
expect((await fetch(`${server.base}${path}`)).headers.get('cache-control')).toBe('no-store, no-cache, must-revalidate');
|
||||||
|
}
|
||||||
|
const etag = current.headers.get('etag');
|
||||||
|
const notModified = await fetch(`${server.base}/app.js?v=${buildTag}`, { headers: { 'If-None-Match': etag } });
|
||||||
|
expect(notModified.status).toBe(304);
|
||||||
|
expect(await notModified.text()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('service worker injection tracks fallback literal and reports missing placeholder', async () => {
|
||||||
|
let sw = await (await fetch(`${server.base}/sw.js`)).text();
|
||||||
|
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
expect(sw).toContain(`const BUILD_TAG = "${buildTag}";`);
|
||||||
|
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = typeof __BUILD_TAG__ !== 'undefined' ? __BUILD_TAG__ : 'v-next-literal';\n");
|
||||||
|
await stopServer(server);
|
||||||
|
server = await startServer();
|
||||||
|
sw = await (await fetch(`${server.base}/sw.js`)).text();
|
||||||
|
expect(sw).toContain(`const BUILD_TAG = "${(await (await fetch(`${server.base}/api/version`)).json()).buildTag}";`);
|
||||||
|
expect(sw).not.toContain('__BUILD_TAG__');
|
||||||
|
|
||||||
|
writeFileSync(join(publicDir, 'sw.js'), "const BUILD_TAG = 'missing-placeholder';\n");
|
||||||
|
await stopServer(server);
|
||||||
|
server = await startServer();
|
||||||
|
const response = await fetch(`${server.base}/sw.js`);
|
||||||
|
expect(await response.text()).toContain('missing-placeholder');
|
||||||
|
// The startup process logs the existing server.js diagnostic when injection misses.
|
||||||
|
server.child.kill();
|
||||||
|
const stderr = await server.stderr;
|
||||||
|
await server.child.exited;
|
||||||
|
expect(stderr).toContain('[sw] BUILD_TAG injection failed — placeholder not found in sw.js');
|
||||||
|
server = await startServer();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SPA routes fall back to the stamped index', async () => {
|
||||||
|
const { buildTag } = await (await fetch(`${server.base}/api/version`)).json();
|
||||||
|
for (const path of ['/search', '/playlist/x']) {
|
||||||
|
const response = await fetch(`${server.base}${path}`);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toContain(`<meta name="ytp-build" content="${buildTag}">`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user