A shell around the ipodderx-rs web UI whose audio is played by the host rather than the page, so it carries on with the screen locked and a car can control it. The page's half of the bridge is web/src/native.ts in ipodderx-rs (#45): it replaces the playback surface of the page's media element with one that posts here, so player.ts is unchanged and the player bar, the row buttons and the keyboard shortcuts work as they always did. Playback, Library, Bridge and CookieBridge never touch a view, which is what makes a CarPlay scene a later addition rather than a rewrite, and what lets the same target build for Mac Catalyst with no conditional code: AVAudioSession, MPNowPlayingInfoCenter and the remote commands all exist there. The Mac gets media keys and Now Playing; it has no CarPlay and no lock screen, so it is a convenience rather than the reason for any of this. Authentication is the web view's. ipx decides who is asking by cookie -- ipx_session, and CF_Authorization from Cloudflare Access in front of the tunnel -- and its auth layer was written so a plain <audio src> would work, which is why the player needs no API of its own. CookieBridge keeps HTTPCookieStorage in step with the web view's store and hands them to each asset. Named ipodderx-app, not -ios: native.ts already carries the branch for an Android host, and the iOS project is one directory rather than the repo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
78 lines
4.0 KiB
Swift
78 lines
4.0 KiB
Swift
import XCTest
|
|
|
|
/// Playing an episode has to leave the page, reach the host's player, and be written back.
|
|
///
|
|
/// Point it at the daemon the browser suite uses, which has fixture feeds and real files:
|
|
///
|
|
/// node -e "require('./tests/ui/global-setup').prepare()" # in ipodderx-rs
|
|
/// node tests/ui/fixtures/serve.js &
|
|
/// IPX_CONFIG=$ROOT/config/config.toml IPX_DATA_DIR=$ROOT/data ./target/debug/ipx daemon &
|
|
/// TEST_RUNNER_IPX_SERVER="http://127.0.0.1:8791/?token=<the fixture token>" xcodebuild test ...
|
|
///
|
|
/// TEST_RUNNER_ is not decoration: the test runs in its own process on the simulator, and that
|
|
/// prefix is what carries a variable across to it.
|
|
final class PlaybackTests: XCTestCase {
|
|
/// Not "First Episode". The fixture caps downloads at one per scan and takes the newest, so
|
|
/// the second is the one with a file behind it -- and an episode with nothing downloaded
|
|
/// offers Download rather than Play.
|
|
let feed = "test-show", guid = "ui-2", title = "Second Episode"
|
|
|
|
func testPlayingAnEpisodeReachesTheHostAndComesBack() throws {
|
|
let server = ProcessInfo.processInfo.environment["IPX_SERVER"] ?? ""
|
|
try XCTSkipIf(server.isEmpty, "set TEST_RUNNER_IPX_SERVER to a daemon with an episode downloaded")
|
|
|
|
let app = XCUIApplication()
|
|
app.launchArguments = ["-ipx.server", server]
|
|
app.launch()
|
|
|
|
let episode = app.staticTexts[title]
|
|
XCTAssertTrue(episode.waitForExistence(timeout: 30), "the page never listed the fixture episode")
|
|
episode.tap()
|
|
|
|
// On a phone the files sit inside the item; the row's own buttons are hidden by the
|
|
// stylesheet, so this is the only play button on screen.
|
|
// Unread first, so that becoming read again can only be playback's doing -- opening an
|
|
// item marks it read by itself, which would otherwise answer the question before the
|
|
// test asked it.
|
|
let markUnread = app.buttons["Mark unread"].firstMatch
|
|
if markUnread.waitForExistence(timeout: 10) { markUnread.tap() }
|
|
|
|
// On a phone the files sit inside the item; the row's own buttons are hidden by the
|
|
// stylesheet, so this is the only play button on screen.
|
|
let play = app.buttons["Play"].firstMatch
|
|
XCTAssertTrue(play.waitForExistence(timeout: 15),
|
|
"the item offers no way to play it, so it has no downloaded file")
|
|
play.tap()
|
|
|
|
// Read again, rather than a position: the browser suite's fixture is a sixth of a second
|
|
// long, and a position that rounds to zero is never written. Read is set at the end of an
|
|
// episode, and inside the shell the page only learns an episode ended because the host
|
|
// said so -- nothing here is playing the file itself.
|
|
|
|
XCTAssertTrue(waitForRead(in: URL(string: server)!, timeout: 30),
|
|
"the episode never came back read, so the host never played it through: "
|
|
+ "either the message did not reach it, it could not fetch /media with the "
|
|
+ "session cookie, or its answer never came back")
|
|
}
|
|
|
|
private func entry(in server: URL) -> [String: Any]? {
|
|
var components = URLComponents(url: server, resolvingAgainstBaseURL: false)!
|
|
components.path = "/api/entries"
|
|
guard let url = components.url, let data = try? Data(contentsOf: url),
|
|
let page = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let entries = page["entries"] as? [[String: Any]] else { return nil }
|
|
return entries.first { $0["guid"] as? String == guid }
|
|
}
|
|
|
|
/// Polls until the server says the episode is read. Its own request, not the app's -- the
|
|
/// point is to ask the server what it ended up with.
|
|
private func waitForRead(in server: URL, timeout: TimeInterval) -> Bool {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
while Date() < deadline {
|
|
if entry(in: server)?["read"] as? Bool == true { return true }
|
|
Thread.sleep(forTimeInterval: 1)
|
|
}
|
|
return false
|
|
}
|
|
}
|