Files
ipx/tests/native-bridge.ts
rays 5ef01593d7 One place to get an episode's file where you are (#457)
Getting an episode onto a phone took two buttons in two states: download
it to the server, wait, then keep it on the device, beside Save to this
computer, Share and Delete, five buttons that did not say where each
copy went. Now a file has one button that brings it to where you are.
On a phone or in the iOS app it ends on the device; when iPX has not got
the file yet, iPX fetches it and the device's copy follows once iPX says
it is done (remembered in localStorage, so a page closed meanwhile
catches up when a list next shows the file). On a computer it has iPX
fetch the file, and with iPX's copy there saves one. A line beside the
size says where the file is, and the rarer actions, two of them
destructive, are under a menu.

isPlayable is false for any file not on iPX, so the phone's button is
decided by playsKind, the same test without the path. download_done now
marks the file as on iPX in what the page holds, so the open file's box
changes at once rather than after the list is fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
2026-11-13 18:40:49 +00:00

236 lines
13 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');
// Kept on the phone (#425): no service worker in a WebView, so the button is the app's to answer.
const enc42 = { id: 42, deadair: 0, mime: 'audio/mpeg' };
// ⬇ ends on the phone in the app, whatever the width (#457).
ok(vm.runInContext('toDevice(X)', Object.assign(ctx, { X: enc42 })) === true,
'in the app, a file\'s download button did not bring it to the phone, which has no service worker to keep it');
since();
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
ok(last('keep')?.url === '/media/42', 'Keep did not ask the app for the file');
ok(vm.runInContext('keeping.has("/media/42")', ctx), 'the button did not show it was on its way');
// The app's answer, which comes whether or not anything is playing.
audio.removeAttribute('src');
ctx.window.ipxNative.on({ t: 'kept', urls: ['/media/42'], keeping: [] });
ok(vm.runInContext('isKept(X)', ctx), "the app's list did not reach the page");
ok(!vm.runInContext('keeping.has("/media/42")', ctx), 'still shown as on its way once kept');
ok(vm.runInContext('mediaSrc(X)', ctx) === '/media/42?offline', 'a kept file is not asked for as kept');
since();
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
ok(last('unkeep')?.url === '/media/42', 'pressing it again did not ask the app to let it go');
ctx.window.ipxNative.on({ t: 'kept', urls: [], keeping: [] });
ok(!vm.runInContext('isKept(X)', ctx), 'still kept after the app let it go');
// One that could not be kept stops showing as on its way.
await vm.runInContext('toggleKept(X, "Episode One")', ctx);
ctx.window.ipxNative.on({ t: 'keepFailed', url: '/media/42', message: 'The server said 404.' });
ok(!vm.runInContext('keeping.has("/media/42")', ctx), 'a failed keep still shows as on its way');
since();
await vm.runInContext('clearOffline()', ctx);
ok(last('clearKept'), 'signing out left the phone holding what was kept');
// The sleep timer and AirPlay (#429): the app's to do, once it says it can.
// The stub hands out a new element on each lookup, so the select's painting is left out.
vm.runInContext('paintSleep=()=>{}', ctx);
since();
vm.runInContext('sleepIn(900)', ctx);
ok(!last('sleep'), 'the sleep timer went to an app that never said it has one');
ok(vm.runInContext('sleepTimer', ctx), 'with an app that has no timer, the page did not keep the time');
vm.runInContext('sleepIn(0)', ctx);
ctx.window.ipxNative.on({ t: 'host', can: ['sleep', 'route'] });
ok(vm.runInContext('hostCan.has("route")', ctx), "the app's picker was not taken up");
since();
vm.runInContext('sleepIn(900)', ctx);
ok(last('sleep')?.secs === 900, 'the sleep timer did not reach the app');
ok(!vm.runInContext('sleepTimer', ctx), 'the page kept a timer of its own beside the app\'s');
ctx.window.ipxNative.on({ t: 'slept' });
ok(vm.runInContext('sleepAt', ctx) === 0, 'the timer still shows after the app paused for it');
ctx.window.ipxNative.on({ t: 'sleep', left: 600 });
ok(vm.runInContext('sleepAt', ctx) > Date.now() + 590_000, 'a reloaded page was not told how long is left');
since();
vm.runInContext('sleepIn(0)', ctx);
ok(last('sleep')?.secs === 0, 'turning the timer off did not reach the app');
// Haptics (#430): the page asks, the phone does.
since();
vm.runInContext('toast("Unable to play", true)', ctx);
ok(last('haptic')?.kind === 'warn', 'an error was not felt');
// The icon's count (#421): the page's own, sent when it changes.
since();
vm.runInContext('hostBadge(7); hostBadge(7); hostBadge(3)', ctx);
ok(posted.filter(m => m.t === 'unread').map(m => m.n).join() === '7,3', 'the app was not told the unread count once per change');
// Push (#433): the app's token is registered with ipx by the page, and dropped on signing out.
const fetched: string[] = [];
ctx.fetch = (url: unknown, init?: { method?: string, body?: string }) => {
fetched.push(`${init?.method || 'GET'} ${url} ${init?.body || ''}`);
return Promise.resolve({ ok: true, status: 204, headers: { get: () => null }, text: () => Promise.resolve(''), json: () => Promise.resolve(null) });
};
ctx.window.ipxNative.on({ t: 'push', token: 'ab'.repeat(32), sandbox: false });
await new Promise(r => setTimeout(r, 0));
ok(fetched.some(f => f.startsWith('POST /api/push/devices') && f.includes('"sandbox":false')), 'the phone was not registered with ipx');
await vm.runInContext('forgetPush()', ctx);
ok(fetched.some(f => f.startsWith(`DELETE /api/push/devices/${'ab'.repeat(32)}`)), 'signing out left ipx counting for this phone');
// The app's own settings (#445), in Settings: only once the app has sent them.
ok(vm.runInContext('deviceSettingsHTML()', ctx) === '', 'Settings offered app settings the app never sent');
ctx.window.ipxNative.on({ t: 'appSettings', server: 'https://ipx.example"><b>', api: 'https://api.example', tokenSet: false });
const device = String(vm.runInContext('deviceSettingsHTML()', ctx));
ok(device.includes('https://api.example') && device.includes('Not set'), "Settings does not show the app's server and token");
ok(!device.includes('"><b>'), 'the server address went into Settings unescaped');
ctx.window.ipxNative.on({ t: 'appSettings', server: 'https://ipx.example', api: 'https://api.example', tokenSet: true });
ok(String(vm.runInContext('deviceSettingsHTML()', ctx)).includes('placeholder="Set"'), 'a saved token still shows as not set');
// CarPlay (#420): the car picks another episode, and the page's bar lets go of the old one.
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.src = '/media/42';
ctx.window.ipxNative.on({ t: 'state', playing: true });
since();
ctx.window.ipxNative.on({ t: 'replaced' });
ok(vm.runInContext('player.guid', ctx) === null, "the bar still shows the episode the car replaced");
ok(audio.paused === true, 'the page still thinks its episode plays');
audio.removeAttribute('src');
ok(!last('stop'), "closing the page's player stopped what the car chose");
ok(!posted.some(m => m.t === 'position'), 'the old episode saved its place by the new one\'s clock');
// The car's pick, or one already playing as the page loads (#442): the bar takes it up, and its
// buttons drive the phone's player, where it used to close and leave nothing to pause.
since();
ctx.window.ipxNative.on({ t: 'nowPlaying', feedId: 'f', guid: 'g2', enc: 7, title: 'From the Car', feedTitle: 'A Feed',
playing: true, cur: 120, dur: 1800 });
ok(vm.runInContext('player.guid', ctx) === 'g2', 'the bar did not take up what the phone plays');
ok(audio.paused === false && audio.currentTime === 120 && audio.duration === 1800, 'the bar is not at the phone\'s place');
ok(!last('load') && !last('play'), 'taking it up started it again');
audio.pause();
ok(last('pause'), "the bar's pause did not reach the phone's player");
ctx.window.ipxNative.on({ t: 'state', playing: false });
ctx.window.ipxNative.on({ t: 'nowPlaying', feedId: 'f', guid: 'g2', enc: 7, title: 'From the Car', feedTitle: 'A Feed',
playing: false, cur: 130 });
ok(audio.paused === true && audio.currentTime === 130, 'told again on coming forward, the bar did not follow');
if (failed) { console.error(`\n${failed} failed`); process.exit(1); }
console.log('OK: native-bridge: the host takes playback, keeping, the sleep timer and haptics, and the page follows it');
process.exit(0);