Files
ipx/tests/dom-stub.ts
rays f8408a3d26 Tests, tooling and the served scripts are TypeScript (#392)
The page and the service worker were strict TypeScript; the tests, the
build, the home page's generator, the load tests, boot.js and the
directory's site.js were plain JavaScript that nothing checked. All of
it is TypeScript now, in five programs, each with its own globals, all
checked by npm run typecheck: the page (tsconfig.json, with boot.ts and
the directory's site.ts), the worker, Node's (tsconfig.tools.json: the
Node tests, the browser suite, the build, the site generator, the load
harness), and k6's (tsconfig.k6.json, with @types/k6).

Node 24 runs the .ts files by stripping their types, so they hold only
TypeScript that erases (erasableSyntaxOnly) and import each other by
.ts name; package.json says "type": "module" for it, and Dockerfile.web
moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types
itself.

The browser suite's page.evaluate calls reach the page's globals; the
page's tsc run now also writes its declarations to target/page-types,
which the suite's program includes, so those calls are typed by the
page's own code rather than by hand-written copies.

boot.ts is compiled by build.mts with swc, as the worker is, and named
by the hash of what is served. The directory's build has no Node, so
site.js is compiled from site.ts and committed (node web/build.mts
--directory-script), and tests/directory-script.ts fails if they drift.

Checked: the site generator's 139 pages are byte for byte what the
JavaScript made; the page build's app.js is identical and only boot.js
(now minified) and its hash differ; contrast, page-smoke, page-unit,
native-bridge, the browser suite (96 passed, 2 skipped) and the
directory's and ipx's Rust tests pass; k6 parses each load script. The
load tests themselves did not run: they build a release binary outside
the capped builder. .agents/skills/security-audit is an installed
skill's, not ours, and is left as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:23:22 +00:00

146 lines
6.9 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 { 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; }
}
export { makeContext, vm, MediaElement };