Merge the integrated JamesDSP, settings layout and retry fixes into beta
@@ -60,12 +60,14 @@ async function call(zeroName, tauriName, payload = {}) {
|
||||
|
||||
// Generic fetch wrapper — returns parsed JSON or throws with a human message.
|
||||
async function webFetch(path, opts = {}) {
|
||||
if ((opts.method || 'GET').toUpperCase() === 'GET' && window.AsyncGuard?.getJson)
|
||||
return AsyncGuard.getJson(path, opts, {
|
||||
// Some upstream failures are represented by HTTP 200 + {ok:false}; do
|
||||
// not expose those transient responses as a manual Retry state either.
|
||||
retryResult: value => value?.ok === false && /\b(?:408|425|429|5\d\d)\b|temporar|timed?\s*out|network|connection|upstream|unavailable|available right now/i.test(String(value.error || value.message || '')),
|
||||
});
|
||||
const res = await fetch(path, opts);
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try { const j = await res.json(); msg = j.error || msg; } catch { /* non-JSON */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -573,7 +575,7 @@ const API = {
|
||||
? webFetch(`/api/search?q=${encodeURIComponent(query)}${refresh ? '&refresh=1' : ''}`)
|
||||
: call('yt.search', 'yt_search', { query }),
|
||||
related: (meta, { refresh = false } = {}) => WEB && /^[\w-]{11}$/.test(meta.id)
|
||||
? fetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`).then(response => response.json())
|
||||
? webFetch(`/api/related?${new URLSearchParams({ videoId: meta.id, title: meta.title || '', channel: meta.channel || meta.artist || '', ...(refresh ? { refresh: '1' } : {}) })}`)
|
||||
: API.search(meta.title || meta.channel, { refresh }),
|
||||
// Videos the server already knows — answers fast while the real search loads.
|
||||
searchLocal: (query) => WEB
|
||||
@@ -8666,9 +8668,7 @@ const RUNNING_BUILD = (() => {
|
||||
let _knownBuildTag = null;
|
||||
|
||||
async function fetchServerBuild() {
|
||||
const res = await fetch('/api/version', { cache: 'no-store' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
const data = await webFetch('/api/version', { cache: 'no-store' });
|
||||
return data.buildTag || null;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag;
|
||||
async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, optIns,
|
||||
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000,
|
||||
maxDelay = 30000, notify = () => {}, force = false } = {}) {
|
||||
maxDelay = 30000, notify = () => {}, force = false, concurrency = 1 } = {}) {
|
||||
const key = completionKey(manifest), saved = await cache.match(key);
|
||||
let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 };
|
||||
const selectedOptIns = optIns || await readOptIns(cache);
|
||||
@@ -177,7 +177,7 @@
|
||||
for (let cycle = 0; cycle < cycles; cycle++) {
|
||||
await publish(true);
|
||||
let error;
|
||||
try { await syncAssets(manifest, { cache, fetchFn, all: true, optIns: selectedOptIns }); }
|
||||
try { await syncAssets(manifest, { cache, fetchFn, all: true, optIns: selectedOptIns, concurrency }); }
|
||||
catch (failure) { error = failure; }
|
||||
snapshot = await completeness(manifest, cache, { optIns: selectedOptIns });
|
||||
if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; }
|
||||
|
||||
@@ -96,6 +96,13 @@ test('overlapping layout and completion jobs share a six-download ceiling',async
|
||||
assert.ok(peak<=6);assert.ok([...calls.values()].every(count=>count===1));
|
||||
});
|
||||
|
||||
test('offline completion keeps its own network concurrency to one',async()=>{
|
||||
const c=cache(),m=manifest();let active=0,peak=0;
|
||||
for(let i=0;i<12;i++){const p='/complete'+i+'.js';m.files[p]={h:String(i)};}
|
||||
const fetchFn=async u=>{active++;peak=Math.max(peak,active);await new Promise(r=>setTimeout(r,2));active--;return response(u.split('=')[1]);};
|
||||
await core.complete(m,{cache:c,fetchFn});assert.equal(peak,1);
|
||||
});
|
||||
|
||||
test('blocking commit never waits on an optional file readiness probe',{timeout:1000},async()=>{
|
||||
const c=cache(),m=manifest();m.files['/extra.js']={h:'extra'};m.groups.extra={files:['/extra.js'],background:true,contract:1};
|
||||
await c.put('/a.js?v=a',response('a'));await c.put('/index.html?v=b',response('b'));
|
||||
|
||||
@@ -35,7 +35,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
const AsyncGuard = { runExclusive };
|
||||
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function retry(task, { attempts = 3, delay = attempt => attempt === 1 ? 350 : 1000,
|
||||
shouldRetry = () => true, sleep = wait } = {}) {
|
||||
let last;
|
||||
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
|
||||
try { return await task(attempt); }
|
||||
catch (error) {
|
||||
last = error;
|
||||
if (attempt >= attempts || !shouldRetry(error, attempt)) throw error;
|
||||
await sleep(delay(attempt));
|
||||
}
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
// Retry only idempotent GETs. Keep the timeout active through JSON parsing so
|
||||
// a stalled response body is treated like a stalled connection.
|
||||
async function getJson(url, options = {}, config = {}) {
|
||||
const fetchFn = config.fetch || root.fetch?.bind(root);
|
||||
if (!fetchFn) throw new Error('fetch is unavailable');
|
||||
if ((options.method || 'GET').toUpperCase() !== 'GET') throw new Error('Retry helper only accepts GET');
|
||||
const attempts = config.attempts ?? 3, timeoutMs = config.timeoutMs ?? 20000;
|
||||
return retry(async () => {
|
||||
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
let timedOut = false, timer, externalAbort;
|
||||
const external = options.signal;
|
||||
if (controller && external) {
|
||||
if (external.aborted) throw external.reason || new DOMException('Aborted', 'AbortError');
|
||||
externalAbort = () => controller.abort(external.reason);
|
||||
external.addEventListener('abort', externalAbort, { once: true });
|
||||
}
|
||||
if (controller && timeoutMs > 0) timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
|
||||
try {
|
||||
const response = await fetchFn(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
|
||||
if ([408, 425, 429].includes(response.status) || response.status >= 500) {
|
||||
const error = new Error(`HTTP ${response.status}`); error.retryable = true; throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try { message = (await response.json()).error || message; } catch {}
|
||||
const error = new Error(message); error.retryable = false; throw error;
|
||||
}
|
||||
const value = await response.json();
|
||||
if (config.retryResult?.(value)) {
|
||||
const error = new Error(value.error || value.message || 'Temporary API failure');
|
||||
error.retryable = true;
|
||||
throw error;
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
if (external?.aborted) throw error;
|
||||
if (timedOut) { const timeout = new Error('Request timed out'); timeout.retryable = true; throw timeout; }
|
||||
if (error.retryable === undefined) error.retryable = true;
|
||||
throw error;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (external && externalAbort) external.removeEventListener('abort', externalAbort);
|
||||
}
|
||||
}, { attempts, shouldRetry: error => error.retryable !== false, sleep: config.sleep || wait });
|
||||
}
|
||||
|
||||
const AsyncGuard = { runExclusive, retry, getJson };
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = AsyncGuard;
|
||||
|
||||
@@ -60,6 +60,55 @@ test('frees the key after failure so a retry is possible', async () => {
|
||||
assert.strictEqual(out, 'ok');
|
||||
});
|
||||
|
||||
test('bounded GET retries recover transient network and server failures', async () => {
|
||||
const { getJson } = require('./async-guard');
|
||||
let calls = 0;
|
||||
const result = await getJson('/api/search', {}, { sleep: async () => {}, fetch: async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new TypeError('connection reset');
|
||||
if (calls === 2) return new Response('{}', { status: 503 });
|
||||
return Response.json({ ok: true, results: [] });
|
||||
} });
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('GET retry stops at the bound and does not retry permanent HTTP errors', async () => {
|
||||
const { getJson } = require('./async-guard');
|
||||
let calls = 0;
|
||||
await assert.rejects(getJson('/api/search', {}, { sleep: async () => {}, fetch: async () => {
|
||||
calls++; return new Response('{}', { status: 404 });
|
||||
} }), /HTTP 404/);
|
||||
assert.equal(calls, 1);
|
||||
await assert.rejects(getJson('/api/search', {}, { attempts: 2, sleep: async () => {}, fetch: async () => {
|
||||
calls++; throw new TypeError('offline');
|
||||
} }), /offline/);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
test('retryable API error payloads recover but permanent application errors do not retry', async () => {
|
||||
const { getJson } = require('./async-guard'); let calls = 0;
|
||||
const retryResult = value => value?.ok === false && /temporarily unavailable/i.test(value.error || '');
|
||||
const result = await getJson('/api/streams', {}, { retryResult, sleep: async () => {}, fetch: async () => {
|
||||
calls++;
|
||||
return Response.json(calls === 1 ? { ok: false, error: 'temporarily unavailable' } : { ok: true, data: [] });
|
||||
} });
|
||||
assert.equal(result.ok, true); assert.equal(calls, 2);
|
||||
calls = 0;
|
||||
const absent = await getJson('/api/streams', {}, { retryResult, sleep: async () => {}, fetch: async () => { calls++; return Response.json({ ok: false, error: 'video not found' }); } });
|
||||
assert.equal(absent.ok, false); assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('timed out GET bodies retry within the configured bound', async () => {
|
||||
const { getJson } = require('./async-guard'); let calls = 0;
|
||||
const result = await getJson('/api/related', {}, { attempts: 2, timeoutMs: 5, sleep: async () => {}, fetch: async (_url, { signal }) => {
|
||||
calls++;
|
||||
if (calls === 1) return new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true }));
|
||||
return Response.json({ ok: true });
|
||||
} });
|
||||
assert.equal(result.ok, true); assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('supports a synchronous fn', async () => {
|
||||
const set = new Set();
|
||||
const out = await runExclusive(set, 's', () => 7);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
try { manifest = JSON.parse(doc.getElementById('ytp-assets').textContent); }
|
||||
catch { return; } // The index must supply this map; never fetch one during boot.
|
||||
const assets = new Map(), groups = new Map(), hooks = new Map();
|
||||
let warmScheduled = false;
|
||||
const css = new Set([...doc.querySelectorAll('link[data-lazy-css]')].map(link => link.dataset.lazyCss));
|
||||
const selected = new Map();
|
||||
const url = path => selected.get(path) || (manifest.files[path]?.h ? path + '?v=' + manifest.files[path].h : './' + path.replace(/^\//,''));
|
||||
@@ -50,14 +51,18 @@
|
||||
const style = /\.css$/.test(path);
|
||||
if (style && css.has(path)) return Promise.resolve();
|
||||
if (!style && !/\.js$/.test(path)) return Promise.resolve(); // workers/imports/fonts are not page scripts
|
||||
const task = new Promise((resolve, reject) => {
|
||||
const loadOnce = () => new Promise((resolve, reject) => {
|
||||
const node = doc.createElement(style ? 'link' : 'script');
|
||||
const timer = setTimeout(() => { node.remove?.(); reject(new Error('Timed out loading ' + path)); }, 20000);
|
||||
if (style) { node.rel = 'stylesheet'; node.href = url(path); }
|
||||
else { node.async = false; node.src = url(path); }
|
||||
node.onload = resolve;
|
||||
node.onerror = () => reject(new Error('Unable to load ' + path));
|
||||
node.onload = () => { clearTimeout(timer); resolve(); };
|
||||
node.onerror = () => { clearTimeout(timer); node.remove?.(); reject(new Error('Unable to load ' + path)); };
|
||||
doc.head.append(node);
|
||||
});
|
||||
const task = root.AsyncGuard?.retry
|
||||
? root.AsyncGuard.retry(loadOnce, { attempts: 3 })
|
||||
: loadOnce();
|
||||
assets.set(path, task);
|
||||
task.catch(() => assets.delete(path));
|
||||
return task;
|
||||
@@ -100,10 +105,17 @@
|
||||
else root.navigator.serviceWorker?.getRegistration().then(send).catch(()=>{});
|
||||
},
|
||||
warm() {
|
||||
if (!root.navigator.serviceWorker || root.navigator.onLine === false) return;
|
||||
if (warmScheduled || !root.navigator.serviceWorker || root.navigator.onLine === false) return;
|
||||
warmScheduled = true;
|
||||
const start = () => {
|
||||
warmScheduled = false;
|
||||
if(root.navigator.onLine === false)return;
|
||||
root.navigator.serviceWorker.ready.then(reg => {
|
||||
for (const worker of new Set([reg.active, reg.waiting])) worker?.postMessage({type:'COMPLETE_ASSETS'});
|
||||
}).catch(()=>{});
|
||||
};
|
||||
if (root.requestIdleCallback) root.requestIdleCallback(start, { timeout: 8000 });
|
||||
else setTimeout(start, 5000);
|
||||
},
|
||||
onLoad(name, fn) {
|
||||
if(api.loaded(name)) { Promise.resolve().then(fn).catch(error=>{if(root.CustomEvent)root.dispatchEvent?.(new root.CustomEvent('ytp-lazy-error',{detail:{group:name,error}}));}); return; }
|
||||
|
||||
@@ -7,7 +7,7 @@ function fixture(current, held = [], appCache = false, userAgent = 'AppleWebKit/
|
||||
const manifest = { buildTag: 'own', appCache, groups: { core: { files: [] }, 'layout:classic': { files: ['/classic.css'] }, 'layout:glass-stage': { files: ['/glass.css', '/one.js', '/two.js'] }, 'feature:test': { contract:1, files: ['/one.js', '/two.js'] } }, files: { '/app.js':{h:'a'}, '/classic.css': {h:'c'}, '/glass.css':{h:'g'}, '/one.js':{h:'1'}, '/two.js':{h:'2'} } };
|
||||
const doc = { readyState:'loading', documentElement: { dataset:{} }, getElementById: () => ({ textContent:JSON.stringify(manifest) }), querySelectorAll: () => [], createElement: tag => ({ tagName:tag.toUpperCase() }), write: value => writes.push(value), addEventListener: (t,f) => listeners[t]=f };
|
||||
doc.head = { append: node => { inserted.push(node); queueMicrotask(() => node.onload?.()); } };
|
||||
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, fetch:async key=>{fetched.push(key);return new Response('app');}, setTimeout, clearTimeout, navigator:{userAgent,serviceWorker:{ready:Promise.resolve({}),controller:controlled?{}:null,addEventListener(){}}}, addEventListener(){} };
|
||||
const root = { document:doc, localStorage:{ getItem:() => '{"settings":{"layout":"glass-stage"}}' }, console, Promise, URL, crypto:{subtle:{}}, AsyncGuard:require('./async-guard'), requestIdleCallback:fn=>{root.__idle=fn;}, fetch:async key=>{fetched.push(key);return new Response('app');}, setTimeout, clearTimeout, navigator:{userAgent,serviceWorker:{ready:Promise.resolve({}),controller:controlled?{}:null,addEventListener(){}}}, addEventListener(){} };
|
||||
if (current) root.caches = { open: async () => ({ match: async key => key === '/__ytp_asset_state' ? new Response(JSON.stringify({current})) : held.includes(key) ? new Response('', {headers:{'X-Asset-Hash':key.split('=')[1]}}) : undefined }) };
|
||||
root.window=root; root.globalThis=root;
|
||||
vm.runInNewContext(readFileSync(require.resolve('./lazy.js'),'utf8'), root);
|
||||
@@ -27,11 +27,13 @@ test('lazy groups execute ordered classic scripts once, sharing concurrent calls
|
||||
await root.Lazy.load('layout:glass-stage');
|
||||
assert.equal(inserted.filter(n=>n.tagName==='SCRIPT').length,2);
|
||||
});
|
||||
test('failed injection can retry and proxy dispatches only after execution', async () => {
|
||||
test('transient script injection failure retries before proxy dispatch', async () => {
|
||||
const {root,inserted}=fixture();
|
||||
const append=root.document.head.append;
|
||||
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>n.onerror?.());};
|
||||
await assert.rejects(root.Lazy.load('feature:test'),/one.js/);
|
||||
let first=true;
|
||||
root.document.head.append=n=>{inserted.push(n);queueMicrotask(()=>{if(first){first=false;n.onerror?.();}else n.onload?.();});};
|
||||
await root.Lazy.load('feature:test');
|
||||
assert.equal(inserted.length,3,'first file is retried, then the second file loads');
|
||||
root.document.head.append=append;
|
||||
root.TestApi={open:value=>value+1};
|
||||
const proxy=root.Lazy.proxy('feature:test',['open'],'TestApi');
|
||||
@@ -92,6 +94,8 @@ test('online launches request worker completion even with Save-Data and skip off
|
||||
f.root.navigator.connection={saveData:true};f.root.navigator.onLine=true;
|
||||
f.root.navigator.serviceWorker={ready:Promise.resolve({active,waiting})};
|
||||
f.root.Lazy.warm();await Promise.resolve();
|
||||
assert.equal(sent.length,0,'warming waits until the page is idle');
|
||||
f.root.__idle();await Promise.resolve();
|
||||
assert.deepEqual(sent.map(m=>m.type),['COMPLETE_ASSETS','COMPLETE_ASSETS']);
|
||||
f.root.navigator.onLine=false;f.root.Lazy.warm();await Promise.resolve();assert.equal(sent.length,2);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
.settings-browser { display: block; width: 100%; min-width: 0; }
|
||||
.settings-browser, .settings-grid, .settings-overview, .settings-detail, .settings-section,
|
||||
.settings-browser .settings, .settings-browser .set-group, .settings-browser .dsp-settings,
|
||||
.settings-browser .dsp-subsection, .settings-browser .dsp-param-list {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
/* Settings is a workspace, not a sidebar: give the section browser the full
|
||||
content area on desktop instead of leaving it inside the fixed 452px list. */
|
||||
.body:has(#listPane .cards[data-view="settings"]) .player-pane { display: none; }
|
||||
.body:has(#listPane .cards[data-view="settings"]) #listPane {
|
||||
flex: 1 1 0;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border-left: 0;
|
||||
}
|
||||
.body:has(#listPane .cards[data-view="settings"]) #listPane .cards {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.settings-browser [hidden] { display: none !important; }
|
||||
.settings-search { margin-bottom: 12px; }
|
||||
.settings-search input { width: 100%; min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; font: inherit; }
|
||||
@@ -11,10 +34,81 @@
|
||||
.settings-section-row:hover, .settings-section-row[aria-current="true"] { background: var(--bg-3); border-radius: 8px; }
|
||||
.settings-browser :is(button,input,select,textarea):focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
.settings-back { min-height: 44px; padding: 8px 12px; margin-bottom: 12px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; font: inherit; cursor: pointer; }
|
||||
.settings-browser .set-row { min-height: 44px; }
|
||||
.settings-browser .set-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, min(40%, 260px));
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.settings-browser .set-row > :is(span, input, select, button, output) { min-width: 0; max-width: 100%; }
|
||||
.settings-browser .set-row > :is(input, select, button, output) { box-sizing: border-box; }
|
||||
.settings-browser .set-control-wrap { display: block; width: 100%; min-width: 0; max-width: 100%; overflow: hidden; }
|
||||
#listPane #cards .settings-browser .set-row select,
|
||||
#listPane #cards .settings-browser .set-control-wrap select,
|
||||
.settings-browser .set-row select,
|
||||
.settings-browser .set-control-wrap select {
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 28px;
|
||||
background-color: var(--bg-3);
|
||||
background-image: linear-gradient(45deg, transparent 50%, var(--text-dim) 50%), linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%;
|
||||
background-size: 5px 5px;
|
||||
background-repeat: no-repeat;
|
||||
color: var(--text);
|
||||
border-color: var(--line);
|
||||
}
|
||||
.settings-browser .set-row > span:first-child { overflow-wrap: anywhere; }
|
||||
.settings-browser .set-row:has(> span:only-child) { grid-template-columns: minmax(0, 1fr); }
|
||||
.settings-browser .set-row small { font-size: 12px; max-width: 100%; }
|
||||
.settings-browser .set-row > span { min-width: 0; }
|
||||
.settings-browser .set-row input[type="range"] { max-width: 42%; }
|
||||
.settings-browser .set-row input[type="range"] { width: calc(100% - 16px); max-width: calc(100% - 16px); min-width: 0; }
|
||||
.settings-browser .set-row input[type="checkbox"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
justify-self: end;
|
||||
width: 46px;
|
||||
height: 28px;
|
||||
min-width: 46px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: radial-gradient(circle at 14px 50%, var(--text) 0 9px, transparent 10px), var(--bg-3);
|
||||
cursor: pointer;
|
||||
transition: background .16s ease, border-color .16s ease;
|
||||
}
|
||||
.settings-browser .set-row input[type="checkbox"]:checked {
|
||||
border-color: var(--accent);
|
||||
background: radial-gradient(circle at 32px 50%, var(--on-accent, #fff) 0 9px, transparent 10px), var(--accent);
|
||||
}
|
||||
.settings-browser .set-row input[type="checkbox"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
.dsp-file-control {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
.dsp-file-control input[type="file"] { position: absolute; width: 1px; height: 1px; min-width: 0; max-width: 1px; padding: 0; border: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
.dsp-file-control:focus-within { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
|
||||
.dsp-file-button { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 7px 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-3); color: var(--text); font: 600 12px var(--ui); white-space: nowrap; }
|
||||
.dsp-file-name { display: block; min-width: 0; overflow: hidden; color: var(--text-2); text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
|
||||
.dsp-subsection .sp-note { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
|
||||
.dsp-subsection > .btn { min-width: 0; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.dsp-actions { min-width: 0; max-width: 100%; }
|
||||
.dsp-param-list { grid-template-columns: repeat(auto-fit, minmax(min(190px, 100%), 1fr)); }
|
||||
.dsp-param-row input[type="range"] { width: calc(100% - 16px); min-width: 0; max-width: calc(100% - 16px); }
|
||||
.settings-browser .settings-detail { display: none; min-width: 0; }
|
||||
.settings-browser[data-detail="1"] .settings-overview { display: none; }
|
||||
.settings-browser[data-detail="1"] .settings-detail { display: block; }
|
||||
@@ -22,7 +116,7 @@
|
||||
.settings-grid { display: grid; grid-template-columns: minmax(160px, .8fr) minmax(0, 1.4fr); gap: 16px; }
|
||||
.settings-browser .settings-overview, .settings-browser[data-detail="1"] .settings-overview, .settings-browser .settings-detail { display: block; }
|
||||
.settings-back { display: none; }
|
||||
.settings-browser .set-row { flex-wrap: wrap; gap: 12px; }
|
||||
.settings-browser .set-row { flex-wrap: nowrap; gap: 12px; }
|
||||
}
|
||||
.download-job { padding: 14px 0; border-bottom: 1px solid var(--line); display: grid; gap: 8px; }
|
||||
.download-job b { overflow-wrap: anywhere; }
|
||||
|
||||
@@ -265,7 +265,8 @@ self.addEventListener('fetch', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ASSET_SYNC && request.mode === 'navigate') e.waitUntil(completeAssets());
|
||||
// The page schedules completion after first paint/idle via Lazy.warm(). Do
|
||||
// not start a six-file background burst from this foreground fetch event.
|
||||
|
||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||
if (request.method !== 'GET' && request.method !== 'HEAD') return;
|
||||
@@ -597,8 +598,8 @@ async function activateAssets() {
|
||||
for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req);
|
||||
await self.clients.claim();
|
||||
if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION});
|
||||
// Completion owns a message-event lifetime, not the activation barrier.
|
||||
// In particular, a legacy client's four-second reload must not wait for it.
|
||||
// Resume completion after activation, but with one request at a time so
|
||||
// foreground navigation retains most of the available bandwidth.
|
||||
self.registration.active.postMessage({ type: 'COMPLETE_ASSETS' });
|
||||
try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {}
|
||||
}
|
||||
|
||||
@@ -741,9 +741,9 @@ async function renderSettings() {
|
||||
const dspRange = (key, label, min, max, step = 1, unit = '') => `<label class="set-row dsp-param-row"><span>${label}<small>${dspParam(key)}${unit}</small></span><input type="range" data-dsp-param="${key}" min="${min}" max="${max}" step="${step}" value="${dspParam(key)}" /></label>`;
|
||||
const dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join('');
|
||||
|
||||
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
|
||||
const sel = (id, val, opts) => `<span class="set-control-wrap"><select id="${id}" class="set-select">` +
|
||||
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
|
||||
`</select>`;
|
||||
`</select></span>`;
|
||||
const dspBuiltinIRSelect = `<select id="setDspBuiltinIR" class="set-select">${['Small room','Hall','Plate','Cathedral'].map(name=>`<option value="${name}"${dspLowEnd&&name!=='Small room'?' disabled':''}>${name}</option>`).join('')}</select>`;
|
||||
|
||||
wrap.innerHTML = `
|
||||
@@ -837,10 +837,10 @@ async function renderSettings() {
|
||||
<label class="set-row"><span>Enable convolution</span><input id="setDspConvolver" type="checkbox" ${dspParam('convolver.enabled') ? 'checked' : ''} /></label>
|
||||
<label class="set-row"><span>Dry / wet<small>${Math.round(dspParam('convolver.mix')*100)}%</small></span><input type="range" data-dsp-param="convolver.mix" min="0" max="1" step="0.01" value="${dspParam('convolver.mix')}" /></label>
|
||||
<label class="set-row"><span>Pre-delay<small>${dspParam('convolver.predelayMs')} ms</small></span><input type="range" data-dsp-param="convolver.predelayMs" min="0" max="250" step="1" value="${dspParam('convolver.predelayMs')}" /></label>
|
||||
<label class="set-row"><span>Built-in response</span>${dspBuiltinIRSelect}</label>
|
||||
<label class="set-row"><span>Built-in response</span><span class="set-control-wrap">${dspBuiltinIRSelect}</span></label>
|
||||
<button id="setDspLoadBuiltin" class="btn" type="button">Generate and load</button>
|
||||
<label class="set-row"><span>Upload impulse response<small>WAV or other browser-decodable audio; up to 65,536 frames at the internal sample rate.</small></span><input id="dspIrFile" type="file" accept="audio/*,.wav" /></label>
|
||||
<label class="set-row"><span>Stored responses</span><select id="setDspSavedIR" class="set-select"><option value="">Choose a saved response</option></select></label>
|
||||
<label class="set-row dsp-upload-row"><span>Upload impulse response<small>WAV or other browser-decodable audio; up to 65,536 frames at the internal sample rate.</small></span><span class="dsp-file-control"><input id="dspIrFile" type="file" accept="audio/*,.wav" aria-label="Upload impulse response" /><button id="dspIrChooseFile" class="dsp-file-button" type="button">Choose file</button><span class="dsp-file-name" id="dspIrFileName" aria-live="polite">No file chosen</span></span></label>
|
||||
<label class="set-row"><span>Stored responses</span><span class="set-control-wrap"><select id="setDspSavedIR" class="set-select"><option value="">Choose a saved response</option></select></span></label>
|
||||
<button id="setDspLoadSavedIR" class="btn" type="button">Load saved response</button>
|
||||
<p id="dspIrStatus" class="sp-note">${jamesDsp.irRef?.name ? `Loaded: ${jamesDsp.irRef.name}` : 'No impulse response loaded.'}</p>
|
||||
</section>
|
||||
@@ -1196,8 +1196,10 @@ async function renderSettings() {
|
||||
try{const ref=await EQ.loadDspSavedIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded saved response: ${name}`;$('setDspConvolver').checked=true;await refreshDspIrList();}
|
||||
catch(error){toast(`Saved response failed: ${error.message}`);}
|
||||
});
|
||||
if ($('dspIrChooseFile')) $('dspIrChooseFile').addEventListener('click', e => { e.preventDefault(); $('dspIrFile')?.click(); });
|
||||
if ($('dspIrFile')) $('dspIrFile').addEventListener('change',async e=>{
|
||||
const file=e.target.files?.[0];if(!file)return;
|
||||
if($('dspIrFileName'))$('dspIrFileName').textContent=file.name;
|
||||
try{const ref=await EQ.loadDspIR(file);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${ref?.name||file.name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
|
||||
catch(error){toast(`Impulse response failed: ${error.message}`);}
|
||||
finally{e.target.value='';}
|
||||
|
||||
@@ -489,3 +489,24 @@ remote toggles against a mocked relay. Transport previews and remote screenshots
|
||||
are saved in ignored `plans/transport-shots/`. Run `node perf/ui-geometry.mjs`
|
||||
for the 320–430px search-bar and Saved-row geometry matrix. OS lock-screen buttons
|
||||
remain platform-rendered through the existing MediaSession handlers.
|
||||
|
||||
Run `node perf/settings-layout.mjs` for the Settings workspace and JamesDSP
|
||||
control matrix: Chromium and WebKit, all four layouts, dark/light/contrast, and
|
||||
390/768/1280/1440/1920px widths. It checks horizontal overflow, responsive row
|
||||
geometry, themed selects and switches, the clipped native file picker, and
|
||||
button wrapping; desktop screenshots and measurements are written to
|
||||
`plans/settings-shots/`.
|
||||
|
||||
## Transient retry verification
|
||||
|
||||
`retry.mjs` starts the real Bun server behind the local traffic-shaping proxy, then
|
||||
injects one HTTP 503 into search, one dropped related-video request, and one lazy
|
||||
chunk load failure. Each path must recover automatically without exposing the
|
||||
manual related/player Retry affordance. It runs Chromium and WebKit by default;
|
||||
choose `--browser chromium|webkit` and `--profile lte|lossy|unthrottled` to narrow
|
||||
it. Retry logic is limited to idempotent GET requests and chunk loads; explicit
|
||||
save/retry POST actions remain user initiated.
|
||||
|
||||
```bash
|
||||
node perf/retry.mjs --browser all --profile lossy
|
||||
```
|
||||
|
||||
108
perf/retry.mjs
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
// Foreground transient-failure regression check over the real Bun server and
|
||||
// shaped local proxy. Injects one 503/connection drop, then requires recovery
|
||||
// without exposing the application's manual Retry/error affordances.
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { cpSync, mkdirSync, rmSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createServer } from 'node:net';
|
||||
import { chromium, webkit } from 'playwright';
|
||||
import { createThrottleProxy } from './proxy.mjs';
|
||||
|
||||
const repo = resolve(new URL('..', import.meta.url).pathname);
|
||||
const scratch = join(repo, 'perf/.tmp/retry');
|
||||
const profile = process.argv.includes('--profile') ? process.argv[process.argv.indexOf('--profile') + 1] : 'lossy';
|
||||
const engines = process.argv.includes('--browser') ? process.argv[process.argv.indexOf('--browser') + 1] : 'all';
|
||||
assert.ok(['all', 'chromium', 'webkit'].includes(engines));
|
||||
rmSync(scratch, { recursive: true, force: true }); mkdirSync(scratch, { recursive: true });
|
||||
const results = [];
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
async function freePort() { const s = createServer(); await new Promise(r => s.listen(0, '127.0.0.1', r)); const p = s.address().port; await new Promise(r => s.close(r)); return p; }
|
||||
async function poll(fn, timeout = 30000) { const end = Date.now() + timeout; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; }
|
||||
|
||||
for (const engine of engines === 'all' ? ['chromium', 'webkit'] : [engines]) {
|
||||
const cwd = join(scratch, engine); mkdirSync(cwd); cpSync(join(repo, 'frontend'), join(cwd, 'public'), { recursive: true });
|
||||
const port = await freePort(); let log = '';
|
||||
const child = spawn('bun', [join(repo, 'server/server.js')], { cwd, env: { ...process.env, PORT: String(port), ASSET_HASHING: '1', ASSET_SYNC: '1', BUILD_TAG: '', DB_PATH: join(cwd, 'db.sqlite'), DATA_DIR: join(cwd, 'data'), MEDIA_DIR: join(cwd, 'media'), UPLOAD_DIR: join(cwd, 'uploads') }, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', chunk => log = (log + chunk).slice(-8000));
|
||||
let browser, context, proxy;
|
||||
const result = { engine, profile, injected: [], recovered: [], visibleErrors: [] }; results.push(result);
|
||||
try {
|
||||
if (!await poll(async () => { try { return (await fetch(`http://127.0.0.1:${port}/api/version`)).ok; } catch { return false; } })) throw Error(`server failed: ${log}`);
|
||||
proxy = createThrottleProxy({ targetPort: port, profile }); proxy.startTracking();
|
||||
const origin = `http://127.0.0.1:${await proxy.listen()}`;
|
||||
browser = await ({ chromium, webkit })[engine].launch({ headless: true });
|
||||
context = await browser.newContext({ serviceWorkers: 'block' });
|
||||
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
|
||||
const page = await context.newPage();
|
||||
page.on('console', msg => { if (msg.type() === 'error') result.visibleErrors.push(msg.text()); });
|
||||
page.on('pageerror', error => result.visibleErrors.push(error.message));
|
||||
|
||||
const searchUrl = '**/api/search?q=retry-transport-test*'; let searchCalls = 0;
|
||||
await context.route(searchUrl, async route => {
|
||||
searchCalls++; result.injected.push({ path: '/api/search', attempt: searchCalls, fault: searchCalls === 1 ? '503' : null });
|
||||
if (searchCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary proxy fault' }) });
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: [{ id: 'abcdefghijk', title: 'Retry fixture result', channel: 'Harness', duration: 60, thumbnail: '' }] }) });
|
||||
});
|
||||
await page.goto(origin);
|
||||
await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#searchInput'));
|
||||
let versionCalls = 0;
|
||||
await context.route('**/api/version', async route => {
|
||||
versionCalls++; result.injected.push({ path: '/api/version', attempt: versionCalls, fault: versionCalls === 1 ? '503' : null });
|
||||
if (versionCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary version check failure' }) });
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ buildTag: 'retry-harness' }) });
|
||||
});
|
||||
assert.equal(await page.evaluate(() => fetchServerBuild()), 'retry-harness');
|
||||
assert.equal(versionCalls, 2, `${engine}: update version check did not retry`);
|
||||
result.recovered.push({ path: '/api/version', attempts: versionCalls });
|
||||
|
||||
await page.locator('#searchInput').fill('retry-transport-test'); await page.locator('#searchForm').evaluate(form => form.requestSubmit());
|
||||
await page.locator('.card').filter({ hasText: 'Retry fixture result' }).waitFor({ timeout: 25000 });
|
||||
result.recovered.push({ path: '/api/search', attempts: searchCalls });
|
||||
assert.equal(searchCalls, 2, `${engine}: search did not retry exactly once`);
|
||||
assert.equal(await page.locator('.retry-btn,.related-retry').count(), 0, `${engine}: transient search exposed a manual retry`);
|
||||
|
||||
let streamCalls = 0;
|
||||
await context.route('**/api/streams?v=abcdefghijk*', async route => {
|
||||
streamCalls++; result.injected.push({ path: '/api/streams', attempt: streamCalls, fault: streamCalls === 1 ? '503' : null });
|
||||
if (streamCalls === 1) return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary upstream failure' }) });
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, data: { meta: { id: 'abcdefghijk', title: 'Stream fixture' }, qualities: [] } }) });
|
||||
});
|
||||
const streams = await page.evaluate(() => API.getStreams('abcdefghijk'));
|
||||
assert.equal(streamCalls, 2, `${engine}: stream lookup did not retry`);
|
||||
assert.equal(streams.ok, true);
|
||||
result.recovered.push({ path: '/api/streams', attempts: streamCalls });
|
||||
|
||||
let relatedCalls = 0;
|
||||
await context.route('**/api/related?*', async route => {
|
||||
relatedCalls++; result.injected.push({ path: '/api/related', attempt: relatedCalls, fault: relatedCalls === 1 ? 'connection-reset' : null });
|
||||
if (relatedCalls === 1) return route.abort('failed');
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ ok: true, results: [{ id: 'lmnopqrstuv', title: 'Related recovered', channel: 'Harness' }] }) });
|
||||
});
|
||||
const relatedState = await page.evaluate(async () => {
|
||||
current = { meta: { id: 'abcdefghijk', title: 'Retry fixture result', channel: 'Harness' } };
|
||||
await loadRelated(); return relatedState.status;
|
||||
});
|
||||
assert.equal(relatedCalls, 2, `${engine}: related discovery did not retry`);
|
||||
assert.equal(relatedState, 'ready');
|
||||
assert.equal(await page.locator('.related-retry').count(), 0, 'transient failure must not expose manual Retry');
|
||||
result.recovered.push({ path: '/api/related', attempts: relatedCalls });
|
||||
|
||||
let chunkCalls = 0;
|
||||
await context.route('**/video-edit.js?v=*', async route => {
|
||||
chunkCalls++; result.injected.push({ path: '/video-edit.js', attempt: chunkCalls, fault: chunkCalls === 1 ? 'connection-reset' : null });
|
||||
if (chunkCalls === 1) return route.abort('failed');
|
||||
return route.continue();
|
||||
});
|
||||
await page.evaluate(() => Lazy.load('feature:video-edit'));
|
||||
assert.equal(chunkCalls, 2, `${engine}: lazy chunk did not retry`);
|
||||
assert.equal(await page.locator('.retry-btn,.related-retry').count(), 0);
|
||||
result.recovered.push({ path: '/video-edit.js', attempts: chunkCalls });
|
||||
const unexpected = result.visibleErrors.filter(message => !/Failed to load resource|Load failed/.test(message));
|
||||
assert.deepEqual(unexpected, [], `${engine}: page errors after transient recovery`);
|
||||
console.log(JSON.stringify(result));
|
||||
} catch (error) { console.error(engine, log); throw error; }
|
||||
finally { await context?.close(); await browser?.close(); await proxy?.close?.(); child.kill('SIGTERM'); }
|
||||
}
|
||||
console.log(`Retry recovery passed (${results.length} engine(s), ${profile}).`);
|
||||
84
perf/settings-layout.mjs
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
// Settings layout regression checks across responsive widths, layouts and themes.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile, mkdir } from 'node:fs/promises';
|
||||
import { resolve, extname } from 'node:path';
|
||||
import { chromium, webkit } from 'playwright';
|
||||
|
||||
const root = resolve('frontend'), shots = resolve('plans/settings-shots');
|
||||
await mkdir(shots, { recursive: true });
|
||||
const mime = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.svg':'image/svg+xml', '.png':'image/png' };
|
||||
const server = createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost');
|
||||
const path = resolve(root, '.' + (url.pathname === '/' ? '/index.html' : url.pathname));
|
||||
if (!path.startsWith(root + '/')) { res.writeHead(403).end(); return; }
|
||||
try { const body = await readFile(path); res.writeHead(200, { 'Content-Type':mime[extname(path)] || 'application/octet-stream' }).end(body); }
|
||||
catch { res.writeHead(404).end(); }
|
||||
});
|
||||
await new Promise(r => server.listen(0, '127.0.0.1', r));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const layouts = ['classic','f7-swipe','glass-stage','bento-hub'], themes = ['dark','light','contrast'], widths = [390,768,1280,1440,1920];
|
||||
const failures = [], results = [];
|
||||
try {
|
||||
for (const [engineName, engine] of Object.entries({ chromium, webkit })) {
|
||||
const browser = await engine.launch();
|
||||
try {
|
||||
const context = await browser.newContext({ viewport:{width:1280,height:1000}, serviceWorkers:'block' });
|
||||
await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort());
|
||||
await context.addInitScript(() => localStorage.setItem('_ytpdata', JSON.stringify({
|
||||
playlists:[], history:[], queue:[], settings:{layout:'glass-stage',theme:'dark',quality:'auto',jamesDsp:{enabled:true,downloadForOffline:false,preset:'warm',params:{'convolver.enabled':1,'spatial.enabled':1},irRef:{name:'Small room'}}}
|
||||
})));
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', error => failures.push(`${engineName}: pageerror ${error.message}`));
|
||||
await page.goto(origin);
|
||||
await page.waitForFunction(() => typeof data !== 'undefined' && typeof Lazy !== 'undefined');
|
||||
await page.waitForFunction(() => performance.getEntriesByName('ytp:boot').length > 0);
|
||||
for (const css of ['theme-classic.css','theme-f7.css','theme-glass.css','theme-glass-panel.css','theme-glass-controls.css','theme-bento-hub.css']) await page.addStyleTag({ path:resolve(root,css) });
|
||||
for (const layout of layouts) {
|
||||
for (const theme of themes) for (const width of widths) {
|
||||
await page.setViewportSize({ width, height:1000 });
|
||||
await page.evaluate(({theme,layout}) => { data.settings.theme=theme; data.settings.layout=layout; data.settings.jamesDsp.enabled=true; document.documentElement.dataset.theme=theme; document.documentElement.dataset.layout=layout; if(typeof EQ!=='undefined')EQ.listDspIRs=async()=>[]; view={type:'settings'}; render(); }, {theme,layout});
|
||||
await page.waitForSelector('.settings-browser .settings-section-row[data-section="playback"]',{state:'attached'});
|
||||
await page.evaluate(()=>{const host=document.querySelector('.settings-browser');host.dataset.detail='1';document.querySelectorAll('.settings-section').forEach(panel=>panel.hidden=panel.id!=='settings-section-playback');document.getElementById('jamesDspControls').hidden=false;});
|
||||
await page.waitForSelector('#setDspSpatialMode',{state:'attached'});
|
||||
const measured = await page.evaluate(() => {
|
||||
const rect = e => { const r=e.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right}; };
|
||||
const selectors=['.settings-browser','.settings-grid','.settings-detail','#listPane .cards','#jamesDspSettings','.dsp-subsection'];
|
||||
const boxes=Object.fromEntries(selectors.map(s=>{const e=document.querySelector(s);return [s,e?{clientWidth:e.clientWidth,scrollWidth:e.scrollWidth,rect:rect(e)}:null];}));
|
||||
const rows=[...document.querySelectorAll('#jamesDspSettings .set-row')].map(row=>{
|
||||
const label=row.querySelector(':scope > span:first-child'),control=row.querySelector(':scope > input,:scope > select,:scope > .set-control-wrap,:scope > .dsp-file-control');
|
||||
return {text:row.textContent.trim().slice(0,90),label:label?rect(label):null,control:control?{...rect(control),scrollWidth:control.scrollWidth,clientWidth:control.clientWidth,overflow:getComputedStyle(control).overflow,select:control.querySelector('select')?{...rect(control.querySelector('select')),scrollWidth:control.querySelector('select').scrollWidth,clientWidth:control.querySelector('select').clientWidth}:null}:null,row:rect(row)};
|
||||
});
|
||||
const file=document.querySelector('#dspIrFile'),checkbox=document.querySelector('#setDspConvolver'),buttons=[...document.querySelectorAll('.dsp-subsection > button')];
|
||||
const selects=[...document.querySelectorAll('.settings-browser select')].map(s=>({id:s.id,appearance:getComputedStyle(s).appearance,backgroundImage:getComputedStyle(s).backgroundImage,backgroundColor:getComputedStyle(s).backgroundColor}));
|
||||
const overflows=[...document.querySelectorAll('#jamesDspSettings *')].filter(e=>e.scrollWidth>e.clientWidth+1).map(e=>({tag:e.tagName,id:e.id,cls:e.className,text:e.textContent.trim().slice(0,100),scroll:e.scrollWidth,client:e.clientWidth,rect:rect(e)}));
|
||||
return {boxes,rows,file:{clipPath:getComputedStyle(file).clipPath,rect:rect(file),parent:rect(file.parentElement)},checkbox:{appearance:getComputedStyle(checkbox).appearance,width:rect(checkbox).width},selects,buttons:buttons.map(b=>({whiteSpace:getComputedStyle(b).whiteSpace,scrollWidth:b.scrollWidth,clientWidth:b.clientWidth,rect:rect(b)})),overflows,page:{scrollWidth:document.documentElement.scrollWidth,clientWidth:document.documentElement.clientWidth}};
|
||||
});
|
||||
const key=`${engineName}/${layout}/${theme}/${width}`;
|
||||
for (const [selector,box] of Object.entries(measured.boxes)) if(box) {
|
||||
if(box.scrollWidth>box.clientWidth+1) failures.push(`${key}: ${selector} horizontal overflow ${box.scrollWidth}/${box.clientWidth}`);
|
||||
}
|
||||
if(measured.page.scrollWidth>measured.page.clientWidth+1) failures.push(`${key}: document horizontal overflow ${measured.page.scrollWidth}/${measured.page.clientWidth}`);
|
||||
for(const row of measured.rows) if(row.label&&row.control&&row.control.x<row.label.x+Math.min(row.label.width,80)) failures.push(`${key}: control overlaps/collapses into label`);
|
||||
if(measured.checkbox.appearance!=='none'||measured.checkbox.width<40) failures.push(`${key}: DSP checkbox is not a themed switch`);
|
||||
for(const select of measured.selects) if(select.appearance!=='none'||select.backgroundImage==='none'||select.backgroundColor==='rgba(0, 0, 0, 0)') failures.push(`${key}: select #${select.id} is not themed`);
|
||||
if(measured.file.rect.width>1||measured.file.clipPath==='none') failures.push(`${key}: native file picker is visible`);
|
||||
for(const b of measured.buttons) if(b.whiteSpace!=='nowrap'||b.rect.height>50) failures.push(`${key}: DSP action button wraps vertically`);
|
||||
results.push({engine:engineName,layout,theme,width,...measured});
|
||||
if(width===1280) await page.screenshot({path:resolve(shots,`${engineName}-${layout}-${theme}.png`),fullPage:true});
|
||||
if(width===1280&&layout==='glass-stage'&&theme==='dark') {
|
||||
await page.locator('#dspConvolverTitle').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({path:resolve(shots,`${engineName}-glass-stage-dark-dsp.png`),fullPage:true});
|
||||
await page.locator('#dspSpatialTitle').scrollIntoViewIfNeeded();
|
||||
await page.screenshot({path:resolve(shots,`${engineName}-glass-stage-dark-spatial.png`),fullPage:true});
|
||||
}
|
||||
}
|
||||
}
|
||||
await context.close();
|
||||
} finally { await browser.close(); }
|
||||
}
|
||||
} finally { await new Promise(r=>server.close(r)); }
|
||||
await (await import('node:fs/promises')).writeFile(resolve(shots,'measurements.json'),JSON.stringify({cases:results.length,failures,screenshots:results.filter(r=>r.width===1280).map(r=>`${r.engine}-${r.layout}-${r.theme}.png`)},null,2));
|
||||
console.log(`Settings layout: ${results.length} cases, ${failures.length} failures; screenshots in plans/settings-shots/`);
|
||||
if(failures.length) { console.error(failures.slice(0,30).join('\n')); process.exitCode=1; }
|
||||
BIN
plans/settings-shots/chromium-bento-hub-contrast.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
plans/settings-shots/chromium-bento-hub-dark.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
plans/settings-shots/chromium-bento-hub-light.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
plans/settings-shots/chromium-classic-contrast.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
plans/settings-shots/chromium-classic-dark.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
plans/settings-shots/chromium-classic-light.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
plans/settings-shots/chromium-f7-swipe-contrast.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
plans/settings-shots/chromium-f7-swipe-dark.png
Normal file
|
After Width: | Height: | Size: 142 KiB |
BIN
plans/settings-shots/chromium-f7-swipe-light.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
plans/settings-shots/chromium-glass-stage-contrast.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
plans/settings-shots/chromium-glass-stage-dark-dsp.png
Normal file
|
After Width: | Height: | Size: 444 KiB |
BIN
plans/settings-shots/chromium-glass-stage-dark-spatial.png
Normal file
|
After Width: | Height: | Size: 419 KiB |
BIN
plans/settings-shots/chromium-glass-stage-dark.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
plans/settings-shots/chromium-glass-stage-light.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
30
plans/settings-shots/measurements.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"cases": 120,
|
||||
"failures": [],
|
||||
"screenshots": [
|
||||
"chromium-classic-dark.png",
|
||||
"chromium-classic-light.png",
|
||||
"chromium-classic-contrast.png",
|
||||
"chromium-f7-swipe-dark.png",
|
||||
"chromium-f7-swipe-light.png",
|
||||
"chromium-f7-swipe-contrast.png",
|
||||
"chromium-glass-stage-dark.png",
|
||||
"chromium-glass-stage-light.png",
|
||||
"chromium-glass-stage-contrast.png",
|
||||
"chromium-bento-hub-dark.png",
|
||||
"chromium-bento-hub-light.png",
|
||||
"chromium-bento-hub-contrast.png",
|
||||
"webkit-classic-dark.png",
|
||||
"webkit-classic-light.png",
|
||||
"webkit-classic-contrast.png",
|
||||
"webkit-f7-swipe-dark.png",
|
||||
"webkit-f7-swipe-light.png",
|
||||
"webkit-f7-swipe-contrast.png",
|
||||
"webkit-glass-stage-dark.png",
|
||||
"webkit-glass-stage-light.png",
|
||||
"webkit-glass-stage-contrast.png",
|
||||
"webkit-bento-hub-dark.png",
|
||||
"webkit-bento-hub-light.png",
|
||||
"webkit-bento-hub-contrast.png"
|
||||
]
|
||||
}
|
||||
BIN
plans/settings-shots/webkit-bento-hub-contrast.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
plans/settings-shots/webkit-bento-hub-dark.png
Normal file
|
After Width: | Height: | Size: 363 KiB |
BIN
plans/settings-shots/webkit-bento-hub-light.png
Normal file
|
After Width: | Height: | Size: 188 KiB |
BIN
plans/settings-shots/webkit-classic-contrast.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
plans/settings-shots/webkit-classic-dark.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
plans/settings-shots/webkit-classic-light.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
plans/settings-shots/webkit-f7-swipe-contrast.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
plans/settings-shots/webkit-f7-swipe-dark.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
plans/settings-shots/webkit-f7-swipe-light.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
plans/settings-shots/webkit-glass-stage-contrast.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
plans/settings-shots/webkit-glass-stage-dark-dsp.png
Normal file
|
After Width: | Height: | Size: 334 KiB |
BIN
plans/settings-shots/webkit-glass-stage-dark-spatial.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
plans/settings-shots/webkit-glass-stage-dark.png
Normal file
|
After Width: | Height: | Size: 377 KiB |
BIN
plans/settings-shots/webkit-glass-stage-light.png
Normal file
|
After Width: | Height: | Size: 358 KiB |