// 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' }; ok(String(vm.runInContext('keepBtn(X)', Object.assign(ctx, { X: enc42 }))).includes('data-a="offline"'), 'no Keep button in the app, which has no service worker to keep a file'); 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'); if (failed) { console.error(`\n${failed} failed`); process.exit(1); } console.log('OK: native-bridge: the host takes playback and keeping, and the page follows it'); process.exit(0);