Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes, light and dark), in Playwright against the test daemon. - #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named by what they show, the open one aria-current. Not role=button, which would flatten a folder's triangle inside it. - #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0. - #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers. - #301: the seek slider is named Position, its value read as "12:30 of 45:00". - #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search and filter boxes have names that do not depend on their placeholder. - #303: #toasts is role=status; an error toast stays until dismissed, at most four at once. - #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the daemon's internals, already in its log); play() failures are put in words and reported, an AbortError ignored; a failed download names its item. - #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80% for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks, phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0, so they take the same focus ring as everything else. - #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it, and makes the rest of the body inert until closeModal, which gives focus back to what opened it. Not <dialog>: its top layer would cover the toasts and their Dismiss. - #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes. - #309: Settings shows iPX's and the page's versions at its foot. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
6.6 KiB
JavaScript
133 lines
6.6 KiB
JavaScript
// The stub DOM the page's script is loaded against, shared by page-smoke.js and
|
|
// native-bridge.js. It is deliberately thin: enough for every handler the script wires at load
|
|
// to find what it reaches for, and no more.
|
|
//
|
|
// `media` swaps the bare `#audio` proxy for something with the parts of HTMLMediaElement that
|
|
// matter -- a prototype carrying the real accessors, and events that actually dispatch -- because
|
|
// native.ts replaces that surface on the element and a proxy that answers everything would prove
|
|
// nothing about whether it worked.
|
|
const vm = require('vm');
|
|
|
|
function makeContext(html, script, { media = false } = {}) {
|
|
// Ids in the page, and in the markup the script builds for its dialogs.
|
|
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
|
|
const missing = [];
|
|
|
|
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
|
|
value: '', textContent: '', innerHTML: '', hidden: false, children: [], firstElementChild: null,
|
|
appendChild(){}, removeChild(){}, remove(){}, insertAdjacentHTML(){}, addEventListener(){},
|
|
setAttribute(){}, getAttribute(){ return null; }, removeAttribute(){}, contains(){ return false; }, select(){}, setSelectionRange(){}, focus(){},
|
|
replaceWith(){}, querySelector(){ return el('nested'); }, querySelectorAll(){ return []; },
|
|
play(){ return Promise.resolve(); }, pause(){}, closest(){ return null; } },
|
|
{ get: (t, k) => k in t ? t[k] : undefined, set: (t, k, v) => (t[k] = v, true) });
|
|
|
|
const body = el('body');
|
|
const audio = media ? makeMediaElement() : null;
|
|
if (media) {
|
|
// native.ts reads has-video off the body to decide whether the host takes the file, so this
|
|
// one has to be a real set rather than something that always says no.
|
|
const classes = new Set();
|
|
body.classList = {
|
|
add: c => classes.add(c), remove: c => classes.delete(c),
|
|
toggle: (c, on) => (on === undefined ? (classes.has(c) ? classes.delete(c) : classes.add(c)) : on ? classes.add(c) : classes.delete(c)),
|
|
contains: c => classes.has(c),
|
|
};
|
|
}
|
|
|
|
const document = {
|
|
querySelector(sel) {
|
|
if (sel === '#audio' && audio) return audio;
|
|
if (sel.startsWith('#') && !ids.has(sel.slice(1))) { missing.push(sel); return null; }
|
|
return el(sel);
|
|
},
|
|
querySelectorAll: () => [],
|
|
createElement: () => el('created'),
|
|
addEventListener(){}, body,
|
|
documentElement: { dataset: {} },
|
|
};
|
|
|
|
const ctx = {
|
|
document, console, crypto: require("crypto").webcrypto, performance,
|
|
window: { isSecureContext: false, addEventListener(){} },
|
|
localStorage: { getItem: () => null, setItem(){}, removeItem(){} },
|
|
navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined },
|
|
fetch: (url) => Promise.resolve({
|
|
ok: true, status: 200, text: () => Promise.resolve(''),
|
|
json: () => Promise.resolve(
|
|
String(url).includes('/api/settings')
|
|
? { schedule: 'every 60m', every_mins: 60, download_dir: '/tmp', max_total_gb: 0, max_age_days: 0 }
|
|
: String(url).includes('/api/users')
|
|
? [{ id: 1, name: 'admin', admin: true, password: true }, { id: 2, name: 'sam', admin: false, password: false }]
|
|
: /\/api\/(popular|directory)/.test(String(url))
|
|
? [{ id: 'f', title: 'A Feed', image: null, subscribers: 2, subscribed: true },
|
|
{ id: 'g', title: null, image: null, subscribers: 1, subscribed: false }]
|
|
: /entries/.test(String(url)) ? { total: 0, entries: [] } : []),
|
|
}),
|
|
EventSource: function () { this.close = () => {}; },
|
|
MediaMetadata: function () {},
|
|
Blob: function () {},
|
|
getComputedStyle: () => ({ backgroundColor: '' }),
|
|
setTimeout, clearTimeout, setInterval, clearInterval,
|
|
confirm: () => false, prompt: () => null, alert(){},
|
|
Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: function(){},
|
|
URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN,
|
|
};
|
|
if (media) {
|
|
ctx.HTMLMediaElement = MediaElement;
|
|
ctx.Event = Event;
|
|
ctx.isFinite = isFinite;
|
|
ctx.CSS = { escape: s => String(s) };
|
|
}
|
|
ctx.globalThis = ctx;
|
|
ctx.window.location = { href: '', hash: '' };
|
|
ctx.location = ctx.window.location;
|
|
|
|
return { ctx, missing, audio, body, ids };
|
|
}
|
|
|
|
/* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */
|
|
|
|
function Event(type) { this.type = type; }
|
|
|
|
function MediaElement() {
|
|
this._src = ''; this._t = 0; this._dur = NaN; this._paused = true;
|
|
this._ready = 0; this._vol = 1; this._rate = 1;
|
|
this._listeners = {};
|
|
this.dataset = {}; this.classList = { add(){}, remove(){}, toggle(){}, contains(){ return false; } };
|
|
// Every call that reached the real element, so a test can say the host took over rather than
|
|
// the element quietly playing as well.
|
|
this.calls = [];
|
|
}
|
|
MediaElement.prototype.play = function(){ this.calls.push('play'); this._paused = false; return Promise.resolve(); };
|
|
MediaElement.prototype.pause = function(){ this.calls.push('pause'); this._paused = true; };
|
|
MediaElement.prototype.load = function(){ this.calls.push('load'); };
|
|
MediaElement.prototype.addEventListener = function(name, fn, opts){
|
|
(this._listeners[name] || (this._listeners[name] = [])).push({ fn, once: !!(opts && opts.once) });
|
|
};
|
|
MediaElement.prototype.dispatchEvent = function(ev){
|
|
for (const l of (this._listeners[ev.type] || []).slice()) {
|
|
if (l.once) this._listeners[ev.type] = this._listeners[ev.type].filter(x => x !== l);
|
|
l.fn(ev);
|
|
}
|
|
return true;
|
|
};
|
|
MediaElement.prototype.removeAttribute = function(name){ this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; };
|
|
MediaElement.prototype.setAttribute = function(){};
|
|
MediaElement.prototype.getAttribute = function(){ return null; };
|
|
const accessor = (k, field, log) => Object.defineProperty(MediaElement.prototype, k, {
|
|
configurable: true,
|
|
get(){ return this[field]; },
|
|
set(v){ if (log) this.calls.push(k + ':' + v); this[field] = v; },
|
|
});
|
|
accessor('src', '_src', true);
|
|
accessor('currentTime', '_t', true);
|
|
accessor('volume', '_vol');
|
|
accessor('playbackRate', '_rate');
|
|
Object.defineProperty(MediaElement.prototype, 'duration', { configurable: true, get(){ return this._dur; } });
|
|
Object.defineProperty(MediaElement.prototype, 'paused', { configurable: true, get(){ return this._paused; } });
|
|
Object.defineProperty(MediaElement.prototype, 'readyState', { configurable: true, get(){ return this._ready; } });
|
|
|
|
function makeMediaElement(){ return new MediaElement(); }
|
|
|
|
module.exports = { makeContext, vm };
|