Files
ipx/tests/dom-stub.ts
rays f6ba08d506 Coverage measures the page's Node tests and runs the Rust on Postgres (#407)
page-unit.ts and native-bridge.ts run the page's script outside a
browser, so the page's coverage counted none of what they test, and
native.ts read as mostly untested. In a coverage run they now run the
script from a file of its own, named as the browser names it, under
NODE_V8_COVERAGE (scripts/coverage-node.sh, before the browser suite in
npm run coverage:page and coverage.sh api), and the report's teardown
takes their record in, mapped to web/src by the build's source map: the
page's lines went from 73.3% to 80.0% of 3,043, native.ts to 93 of 120.

The cargo tests in coverage.sh's container run on Postgres, as
production does, when IPX_TEST_DATABASE_URL names the _test database,
from the shell or else /src/.envrc (that variable only); on SQLite
without it. The integration tests' own files are left out of the
totals; a crate's inline mod tests cannot be on stable Rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 04:57:32 +00:00

160 lines
7.6 KiB
TypeScript

// 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<string | symbol, any>;
/// The page's globals, as the script in the VM sees them, its own functions among them after it runs.
type Globals = Record<string, any>;
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<Stub>({ 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<string>();
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<string, Listener[]> = {};
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 };