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>
122 lines
5.7 KiB
TypeScript
122 lines
5.7 KiB
TypeScript
// 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, scriptName } 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: scriptName(script, '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: scriptName(script, '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);
|