// 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 () {}, 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 };