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>
160 lines
7.6 KiB
TypeScript
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 };
|