The page named no theme-color, so the status bar iOS draws around the Dynamic Island was its own black or white, a band across the top of every theme. The page and the admin page now carry one, set to the page's background whenever the theme or mode changes; Android's toolbar takes it too. Not seen on a phone yet: the browser test checks the colour matches, not what iOS does with it. A browser test at 360px covers this and #146's row of buttons. The stub DOM gains getComputedStyle, which every browser has. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
6.5 KiB
JavaScript
133 lines
6.5 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; }, 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,
|
|
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 };
|