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>
This commit is contained in:
121
tests/native-bridge.ts
Normal file
121
tests/native-bridge.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// The page inside a native shell: web/src/native.ts should take playback off the element and
|
||||
// hand it to the host, while everything in player.ts carries on talking to the element.
|
||||
//
|
||||
// This is the check that the shim and player.ts still agree. The surface native.ts replaces --
|
||||
// play, pause, src, currentTime, duration, paused, readyState, the events -- is player.ts's
|
||||
// alone, so a change there that steps outside it would otherwise break the app in a car, on a
|
||||
// road, with nothing to look at.
|
||||
//
|
||||
// node tests/native-bridge.ts
|
||||
import { makeContext, vm } from './dom-stub.ts';
|
||||
import { buildPage } from '../web/build.mts';
|
||||
|
||||
const { html, js: script } = buildPage('index.html');
|
||||
|
||||
let failed = 0;
|
||||
const ok = (cond: unknown, what: string) => { if (!cond) { console.error('FAIL: ' + what); failed++; } };
|
||||
|
||||
/* ---- a browser: nothing installs ---- */
|
||||
{
|
||||
const { ctx } = makeContext(html, script, { media: true });
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(script, ctx, { filename: 'browser', timeout: 5000 });
|
||||
ok(ctx.window.ipxNative === undefined, 'the bridge installed in a plain browser');
|
||||
const audio = ctx.document.querySelector('#audio');
|
||||
audio.src = '/media/1';
|
||||
ok(audio.calls.includes('src:/media/1'), 'a browser did not set the real src');
|
||||
}
|
||||
|
||||
/* ---- inside the shell ---- */
|
||||
/// What the page sent the shell: a kind, `t`, and its fields.
|
||||
type Posted = { t: string, [field: string]: unknown };
|
||||
const posted: Posted[] = [];
|
||||
const { ctx, audio: media, body } = makeContext(html, script, { media: true });
|
||||
if (!media) throw new Error('makeContext gave no media element for { media: true }');
|
||||
const audio = media;
|
||||
ctx.window.webkit = { messageHandlers: { ipx: { postMessage: (m: Posted) => posted.push(m) } } };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(script, ctx, { filename: 'shell', timeout: 5000 });
|
||||
|
||||
const last = (t: string) => [...posted].reverse().find(m => m.t === t);
|
||||
const since = () => posted.splice(0, posted.length);
|
||||
|
||||
ok(ctx.window.ipxNative && ctx.window.ipxNative.version === 1, 'window.ipxNative is not there for the host to call');
|
||||
ok(last('ready'), 'the host was never told the bridge is in');
|
||||
since();
|
||||
|
||||
// What play() does: player.ts fills in `player`, marks the body, sets the src, then plays.
|
||||
const entry = { guid: 'g1', feed_id: 'f', title: 'Episode One', image: null, position: 0, duration: 1800, read: false,
|
||||
enclosures: [{ id: 42, mime: 'audio/mpeg', path: '/downloads/f/ep1.mp3', url: 'https://x/ep1.mp3' }] };
|
||||
vm.runInContext('S.feeds=[{id:"f",title:"A Feed",image:"/art.jpg"}]', ctx);
|
||||
vm.runInContext('player.guid="g1";player.feed="f";player.enc=42;player.entry=E', Object.assign(ctx, { E: entry }));
|
||||
|
||||
body.classList.toggle('has-video', false);
|
||||
audio.calls.length = 0;
|
||||
audio.src = '/media/42';
|
||||
|
||||
const load = last('load');
|
||||
ok(load, 'setting the src told the host nothing');
|
||||
if (load) {
|
||||
ok(load.url === '/media/42' && load.enc === 42, 'the host was not told which file');
|
||||
ok(load.feedId === 'f' && load.guid === 'g1', 'the host cannot save a position without the feed and guid');
|
||||
ok(load.title === 'Episode One' && load.feedTitle === 'A Feed', 'now-playing has nothing to show');
|
||||
ok(load.artwork === '/art.jpg', "the feed's art did not stand in for an episode without its own");
|
||||
}
|
||||
ok(!audio.calls.some(c => c.startsWith('src:')), 'the element loaded the file as well as the host');
|
||||
ok(audio.calls.includes('load'), 'the element was not made to let go of what it held');
|
||||
|
||||
// player.ts sets currentTime=0 straight after the src.
|
||||
since();
|
||||
audio.currentTime = 0;
|
||||
ok(last('seek')?.to === 0, 'a seek did not reach the host');
|
||||
|
||||
since();
|
||||
audio.play();
|
||||
ok(last('play'), 'play did not reach the host');
|
||||
ok(!audio.calls.includes('play'), 'the element played too -- two engines on one file');
|
||||
|
||||
// The host answers, and the page must move as it would have on its own.
|
||||
let played = 0, timed = 0;
|
||||
audio.addEventListener('play', () => played++);
|
||||
audio.addEventListener('timeupdate', () => timed++);
|
||||
ctx.window.ipxNative.on({ t: 'state', playing: true });
|
||||
ok(played === 1, 'the page never saw the host start playing');
|
||||
ok(audio.paused === false, 'audio.paused still says paused while the host plays');
|
||||
|
||||
ctx.window.ipxNative.on({ t: 'meta', dur: 1800 });
|
||||
ok(audio.duration === 1800, 'the duration the host measured did not reach the page');
|
||||
ok(audio.readyState > 0, 'readyState stayed 0, which is what stops a position being saved');
|
||||
|
||||
ctx.window.ipxNative.on({ t: 'time', cur: 30 });
|
||||
ok(audio.currentTime === 30, "the host's clock did not reach the page");
|
||||
ok(timed > 0, 'no timeupdate, so the player bar would sit at zero');
|
||||
|
||||
// The 15-second key: a read and a write through the shim.
|
||||
since();
|
||||
audio.currentTime -= 15;
|
||||
ok(last('seek')?.to === 15, 'back 15 seconds did not land at 15');
|
||||
|
||||
// Position saving is the host's: a frozen WebView must not write a time from minutes ago.
|
||||
since();
|
||||
const saved = ctx.navigator.sendBeacon('/api/entries/f/g1/position', {});
|
||||
ok(saved === true, 'sendBeacon reported a failure the page would treat as unsaved');
|
||||
ok(/\/position$/.test(String(last('position')?.url ?? '')), 'the position write did not become a request to the host');
|
||||
|
||||
// Closing the player has to stop the host, not just blank the element.
|
||||
since();
|
||||
audio.removeAttribute('src');
|
||||
ok(last('stop'), 'closing the player left the host playing');
|
||||
|
||||
// Video stays on the element: CarPlay is audio-only, and a native video layer under a WebView
|
||||
// buys nothing.
|
||||
since();
|
||||
audio.calls.length = 0;
|
||||
body.classList.toggle('has-video', true);
|
||||
audio.src = '/media/99';
|
||||
ok(!last('load'), 'a video was handed to the host');
|
||||
ok(audio.calls.includes('src:/media/99'), 'a video did not play on the element');
|
||||
|
||||
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
|
||||
console.log('OK: native-bridge: the host takes playback and the page follows it');
|
||||
process.exit(0);
|
||||
Reference in New Issue
Block a user