// The stub DOM the page's script is loaded against, shared by page-smoke.ts, page-unit.ts and // native-bridge.ts. 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. import vm from 'node:vm'; import fs from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import { webcrypto } from 'node:crypto'; // The VM's side is the page's script, whose types are its own (web/src): a test reaches into it by // name, ctx.selectFeed(...), and gets back what the script made. So both are `any` at this edge. /// A stub element: whatever the script sets on it, and empty answers for what it reads. type Stub = Record; /// The page's globals, as the script in the VM sees them, its own functions among them after it runs. type Globals = Record; function makeContext(html: string, script: string, { 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: string[] = []; const el = (_name: string): Stub => 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 ? new MediaElement() : 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: string) => { classes.add(c); }, remove: (c: string) => { classes.delete(c); }, toggle: (c: string, on?: boolean) => { if (on ?? !classes.has(c)) classes.add(c); else classes.delete(c); }, contains: (c: string) => classes.has(c), }; } const document = { querySelector(sel: string) { 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 location = { href: '', hash: '' }; const ctx: Globals = { document, console, crypto: webcrypto, performance, window: { isSecureContext: false, addEventListener(){}, location }, localStorage: { getItem: () => null, setItem(){}, removeItem(){} }, navigator: { clipboard: undefined, sendBeacon(){}, mediaSession: undefined }, fetch: (url: unknown) => 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: class { close() {} }, MediaMetadata: class {}, Blob: class {}, getComputedStyle: () => ({ backgroundColor: '' }), setTimeout, clearTimeout, setInterval, clearInterval, confirm: () => false, prompt: () => null, alert(){}, Date, Math, JSON, Object, Array, String, Number, Promise, Error, FormData: class {}, URLSearchParams, encodeURIComponent, decodeURIComponent, parseInt, parseFloat, isNaN, location, }; if (media) { ctx.HTMLMediaElement = MediaElement; ctx.Event = StubEvent; ctx.isFinite = isFinite; ctx.CSS = { escape: (s: unknown) => String(s) }; } ctx.globalThis = ctx; return { ctx, missing, audio, body, ids }; } /* ---- just enough HTMLMediaElement for the shim to be worth testing ---- */ class StubEvent { type: string; constructor(type: string) { this.type = type; } } type Listener = { fn: (ev: StubEvent) => void, once: boolean }; class MediaElement { _src = ''; _t = 0; _dur = NaN; _paused = true; _ready = 0; _vol = 1; _rate = 1; _listeners: Record = {}; dataset = {}; 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. calls: string[] = []; play() { this.calls.push('play'); this._paused = false; return Promise.resolve(); } pause() { this.calls.push('pause'); this._paused = true; } load() { this.calls.push('load'); } addEventListener(name: string, fn: Listener['fn'], opts?: { once?: boolean }) { (this._listeners[name] ||= []).push({ fn, once: !!opts?.once }); } dispatchEvent(ev: StubEvent) { 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; } removeAttribute(name: string) { this.calls.push('removeAttribute:' + name); if (name === 'src') this._src = ''; } setAttribute() {} getAttribute() { return null; } // Accessors on the prototype, configurable, as a real element's are: native.ts takes them over. get src() { return this._src; } set src(v) { this.calls.push('src:' + v); this._src = v; } get currentTime() { return this._t; } set currentTime(v) { this.calls.push('currentTime:' + v); this._t = v; } get volume() { return this._vol; } set volume(v) { this._vol = v; } get playbackRate() { return this._rate; } set playbackRate(v) { this._rate = v; } get duration() { return this._dur; } get paused() { return this._paused; } get readyState() { return this._ready; } } /// The name to run the page's script under. In a coverage run (IPX_COVERAGE=1, #407), a file of its /// own named as the browser names it, so NODE_V8_COVERAGE's record of it, which the page's report /// takes in (tests/ui/coverage-teardown.ts), maps back to web/src through its inline source map. function scriptName(js: string, name: string): string { if (!process.env.IPX_COVERAGE) return name; const file = path.resolve('target/coverage/node-page/app.js'); fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, js); return pathToFileURL(file).href; } export { makeContext, vm, MediaElement, scriptName };