Nothing the page did in the browser reached the logs: a DeadAir playback failure on the phone was a toast and nothing more. POST /api/client takes page errors, unhandled rejections, hls.js and media errors, the time from play to sound and the length of a stall, and logs them as ev lines under ipx::client, inside the request's span. Only those kinds are taken, the text cut to 500 characters, and the page sends each at most once a minute. The page's API requests send traceparent, a new trace id for each tap or key, and the server's request span takes it as its parent, so one action's requests are one trace in Tempo, the report included. The stub DOM the smoke and native-bridge tests run in gains crypto and performance, which every browser the page runs in has. 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; }, 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 };
|