Files
plezy/tvos/RunnerTests/SystemShelfPluginTests.swift
T

618 lines
20 KiB
Swift

import Foundation
import Flutter
import XCTest
@testable import Runner
private final class ShelfArtworkURLProtocol: URLProtocol {
private static let lock = NSLock()
private static var startHandler: ((ShelfArtworkURLProtocol) -> Void)?
private static var stopHandler: (() -> Void)?
static func configure(
start: @escaping (ShelfArtworkURLProtocol) -> Void,
stop: (() -> Void)? = nil
) {
lock.lock()
startHandler = start
stopHandler = stop
lock.unlock()
}
static func reset() {
lock.lock()
startHandler = nil
stopHandler = nil
lock.unlock()
}
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
Self.lock.lock()
let handler = Self.startHandler
Self.lock.unlock()
guard let handler else {
client?.urlProtocol(self, didFailWithError: URLError(.resourceUnavailable))
return
}
handler(self)
}
override func stopLoading() {
Self.lock.lock()
let handler = Self.stopHandler
Self.lock.unlock()
handler?()
}
func sendResponse(contentLength: Int? = nil) {
var headers = ["Content-Type": "image/png"]
if let contentLength { headers["Content-Length"] = String(contentLength) }
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: "HTTP/1.1",
headerFields: headers
)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
}
func send(_ data: Data) {
client?.urlProtocol(self, didLoad: data)
}
func finish() {
client?.urlProtocolDidFinishLoading(self)
}
}
private final class SystemShelfSyncHarness {
let defaults: UserDefaults
let root: URL
private(set) var state = SystemShelfMutationState()
private(set) var epoch: UInt64 = 0
private struct ScheduledPrune {
let deadline: TimeInterval
let batch: SystemShelfPruneBatch
}
private let suiteName: String
private var pruneClock: TimeInterval = 0
private var scheduledPrunes: [ScheduledPrune] = []
var now: () -> Date = { Date(timeIntervalSince1970: 1_000) }
var loaderShouldFail = false
private(set) var requestedURLs: [URL] = []
private(set) var notificationCount = 0
init() throws {
suiteName = "SystemShelfSyncHarness.\(UUID().uuidString)"
defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName))
root = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
epoch = state.beginEngineSession()
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
}
func cleanup() {
defaults.removePersistentDomain(forName: suiteName)
try? FileManager.default.removeItem(at: root)
}
@discardableResult
func beginEngineSession() -> UInt64 {
epoch = state.beginEngineSession()
return epoch
}
func sync(
generation: Int64,
items: [[String: Any]],
ownerId: String = "profile-a",
engineEpoch: UInt64? = nil
) -> Bool {
let environment = SystemShelfSyncEnvironment(
defaults: defaults,
artworkRoot: root,
now: now,
loadArtwork: { [unowned self] url, _, _ in
requestedURLs.append(url)
guard !loaderShouldFail else { return nil }
return BoundedArtworkDownload(data: Self.png, mimeType: "image/png")
},
schedulePrune: { [unowned self] batches in
scheduledPrunes.append(
contentsOf: batches.map {
ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
}
)
},
notifyChange: { [unowned self] in
notificationCount += 1
}
)
return SystemShelfPlugin.sync(
envelope: envelope(
generation: generation,
ownerId: ownerId,
engineEpoch: engineEpoch
),
rawItems: items,
state: &state,
environment: environment
)
}
func clear(generation: Int64, ownerId: String = "profile-a") -> Bool {
SystemShelfPlugin.clearCache(
envelope: envelope(generation: generation, ownerId: ownerId),
state: &state,
defaults: defaults,
artworkRoot: root,
notifyChange: { [unowned self] in
notificationCount += 1
}
)
}
func remove(generation: Int64, contentId: String) -> Bool {
SystemShelfPlugin.removeItem(
envelope: envelope(generation: generation),
contentId: contentId,
state: &state,
defaults: defaults,
artworkRoot: root,
schedulePrune: { [unowned self] batches in
scheduledPrunes.append(
contentsOf: batches.map {
ScheduledPrune(deadline: pruneClock + $0.delay, batch: $0)
}
)
},
notifyChange: { [unowned self] in
notificationCount += 1
}
)
}
func envelope(
generation: Int64,
ownerId: String = "profile-a",
engineEpoch: UInt64? = nil
) -> SystemShelfMutationEnvelope {
SystemShelfMutationEnvelope(
ownerId: ownerId,
generation: generation,
engineEpoch: engineEpoch ?? epoch
)
}
func currentItems() throws -> [[String: Any]] {
let data = try XCTUnwrap(defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
let payload = try XCTUnwrap(
JSONSerialization.jsonObject(with: data) as? [String: Any]
)
let sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
return sections.flatMap { $0["items"] as? [[String: Any]] ?? [] }
}
func artworkURL(for key: String) -> URL? {
guard
let enumerator = FileManager.default.enumerator(
at: root,
includingPropertiesForKeys: nil
)
else { return nil }
return enumerator.compactMap { $0 as? URL }.first { $0.lastPathComponent == key }
}
func advancePrunes(by interval: TimeInterval) {
pruneClock += interval
let due = scheduledPrunes.filter { $0.deadline <= pruneClock }
scheduledPrunes.removeAll { $0.deadline <= pruneClock }
for scheduled in due {
let candidates = state.claimPruning(scheduled.batch)
SystemShelfPlugin.pruneUnreferenced(
candidates,
defaults: defaults,
root: root
)
}
}
static func item(
source: String? = nil,
progress: Int = 0,
contentId: String = "movie-1"
) -> [String: Any] {
var item: [String: Any] = [
"contentId": contentId,
"title": "Movie",
"lastPlaybackPosition": progress,
]
if let source {
item["posterSourceUri"] = source
}
return item
}
static let png = Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)!
}
final class SystemShelfPluginTests: XCTestCase {
override func tearDown() {
ShelfArtworkURLProtocol.reset()
super.tearDown()
}
func testSyncRemoveAndClearUpdateCommittedShelf() throws {
let harness = try SystemShelfSyncHarness()
defer { harness.cleanup() }
XCTAssertTrue(
harness.sync(
generation: 1,
items: [
SystemShelfSyncHarness.item(
source: "https://shelf.test/a.png",
contentId: "movie-a"
),
SystemShelfSyncHarness.item(
source: "https://shelf.test/b.png",
contentId: "movie-b"
),
]
)
)
let initialItems = try harness.currentItems()
XCTAssertEqual(Set(initialItems.compactMap { $0["contentId"] as? String }), ["movie-a", "movie-b"])
let removedKey = try XCTUnwrap(
initialItems.first { $0["contentId"] as? String == "movie-a" }?["artworkKey"] as? String
)
let removedArtwork = try XCTUnwrap(harness.artworkURL(for: removedKey))
XCTAssertTrue(harness.remove(generation: 2, contentId: "movie-a"))
XCTAssertEqual(try harness.currentItems().compactMap { $0["contentId"] as? String }, ["movie-b"])
XCTAssertTrue(FileManager.default.fileExists(atPath: removedArtwork.path))
harness.advancePrunes(by: 60)
XCTAssertFalse(FileManager.default.fileExists(atPath: removedArtwork.path))
XCTAssertTrue(harness.clear(generation: 3))
XCTAssertNil(harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey))
XCTAssertFalse(FileManager.default.fileExists(atPath: harness.root.path))
XCTAssertEqual(harness.notificationCount, 3)
}
func testReplacementEngineRejectsStaleEngineMutation() throws {
let harness = try SystemShelfSyncHarness()
defer { harness.cleanup() }
let staleEpoch = harness.epoch
XCTAssertTrue(
harness.sync(
generation: 50,
items: [SystemShelfSyncHarness.item(progress: 50)]
)
)
_ = harness.beginEngineSession()
XCTAssertTrue(
harness.sync(
generation: 1,
items: [SystemShelfSyncHarness.item(progress: 1)],
ownerId: "profile-b"
)
)
XCTAssertFalse(
harness.sync(
generation: 51,
items: [SystemShelfSyncHarness.item(progress: 99)],
engineEpoch: staleEpoch
)
)
let item = try XCTUnwrap(harness.currentItems().first)
XCTAssertEqual(item["lastPlaybackPosition"] as? Int, 1)
let data = try XCTUnwrap(
harness.defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
)
let payload = try XCTUnwrap(
JSONSerialization.jsonObject(with: data) as? [String: Any]
)
XCTAssertEqual(payload["ownerId"] as? String, "profile-b")
}
func testHigherGenerationRemainsLastWriter() throws {
let harness = try SystemShelfSyncHarness()
defer { harness.cleanup() }
XCTAssertTrue(
harness.sync(
generation: 2,
items: [SystemShelfSyncHarness.item(progress: 20)]
)
)
XCTAssertFalse(
harness.sync(
generation: 1,
items: [SystemShelfSyncHarness.item(progress: 10)]
)
)
XCTAssertEqual(
try harness.currentItems().first?["lastPlaybackPosition"] as? Int,
20
)
XCTAssertEqual(harness.notificationCount, 1)
}
func testProgressOnlySyncReusesCommittedArtworkWithoutRequestingItAgain() throws {
let harness = try SystemShelfSyncHarness()
defer { harness.cleanup() }
let source = "https://shelf.test/movie-a.png"
XCTAssertTrue(
harness.sync(
generation: 1,
items: [SystemShelfSyncHarness.item(source: source, progress: 10)]
)
)
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
XCTAssertTrue(
harness.sync(
generation: 2,
items: [SystemShelfSyncHarness.item(source: source, progress: 45)]
)
)
let updatedItem = try XCTUnwrap(harness.currentItems().first)
XCTAssertEqual(updatedItem["lastPlaybackPosition"] as? Int, 45)
XCTAssertEqual(updatedItem["artworkKey"] as? String, firstKey)
XCTAssertEqual(harness.requestedURLs.map(\.absoluteString), [source])
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
}
func testFailedReplacementKeepsLastCommittedArtwork() throws {
let harness = try SystemShelfSyncHarness()
defer { harness.cleanup() }
let firstSource = "https://shelf.test/movie-a.png"
XCTAssertTrue(
harness.sync(
generation: 1,
items: [SystemShelfSyncHarness.item(source: firstSource)]
)
)
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
harness.loaderShouldFail = true
XCTAssertTrue(
harness.sync(
generation: 2,
items: [
SystemShelfSyncHarness.item(source: "https://shelf.test/movie-b.png", progress: 20)
]
)
)
let item = try XCTUnwrap(harness.currentItems().first)
XCTAssertEqual(item["artworkKey"] as? String, firstKey)
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
XCTAssertEqual(
harness.requestedURLs.map(\.absoluteString),
[firstSource, "https://shelf.test/movie-b.png"]
)
}
func testEachSupersededArtworkReceivesFullPruneGrace() throws {
let harness = try SystemShelfSyncHarness()
defer { harness.cleanup() }
XCTAssertTrue(
harness.sync(
generation: 1,
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
)
)
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
XCTAssertTrue(
harness.sync(
generation: 2,
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
)
)
let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))
harness.advancePrunes(by: 59)
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
XCTAssertTrue(
harness.sync(
generation: 3,
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/c.png")]
)
)
let currentKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
let currentArtwork = try XCTUnwrap(harness.artworkURL(for: currentKey))
harness.advancePrunes(by: 1)
XCTAssertFalse(FileManager.default.fileExists(atPath: firstArtwork.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
harness.advancePrunes(by: 58)
XCTAssertTrue(FileManager.default.fileExists(atPath: secondArtwork.path))
harness.advancePrunes(by: 1)
XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: currentArtwork.path))
}
func testPruneRechecksCurrentArtworkAndRelaunchRecoveryPreservesIt() throws {
let harness = try SystemShelfSyncHarness()
defer { harness.cleanup() }
XCTAssertTrue(
harness.sync(
generation: 1,
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/a.png")]
)
)
let firstKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
let firstArtwork = try XCTUnwrap(harness.artworkURL(for: firstKey))
XCTAssertTrue(
harness.sync(
generation: 2,
items: [SystemShelfSyncHarness.item(source: "https://shelf.test/b.png")]
)
)
let secondKey = try XCTUnwrap(harness.currentItems().first?["artworkKey"] as? String)
let secondArtwork = try XCTUnwrap(harness.artworkURL(for: secondKey))
try replaceCommittedArtworkKey(
in: harness.defaults,
with: firstKey
)
harness.advancePrunes(by: 60)
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
let relaunchedAt = Date(timeIntervalSince1970: 10_000)
let recovered = SystemShelfPlugin.recoverableArtwork(
root: harness.root,
keeping: [firstArtwork],
now: relaunchedAt
)
XCTAssertEqual(
recovered[secondArtwork],
relaunchedAt.addingTimeInterval(60)
)
XCTAssertNil(recovered[firstArtwork])
var relaunchedState = SystemShelfMutationState()
_ = relaunchedState.beginEngineSession()
XCTAssertNotNil(relaunchedState.adoptPersistedOwner("profile-a"))
relaunchedState.cancelPruning(keeping: [firstArtwork])
let batches = relaunchedState.preparePruning(
removing: Set(recovered.keys),
after: 60
)
XCTAssertEqual(batches.count, 1)
XCTAssertEqual(batches.first?.delay, 60)
let candidates = relaunchedState.claimPruning(try XCTUnwrap(batches.first))
SystemShelfPlugin.pruneUnreferenced(
candidates,
defaults: harness.defaults,
root: harness.root
)
XCTAssertFalse(FileManager.default.fileExists(atPath: secondArtwork.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: firstArtwork.path))
}
func testPruneDoesNotEscapeArtworkRoot() throws {
let harness = try SystemShelfSyncHarness()
defer { harness.cleanup() }
let outside = FileManager.default.temporaryDirectory
.appendingPathComponent("\(UUID().uuidString).art")
let inside = harness.root.appendingPathComponent("orphan.art")
let outsideDirectory = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let outsideViaSymlink = outsideDirectory.appendingPathComponent("linked.art")
let linkedDirectory = harness.root.appendingPathComponent("linked", isDirectory: true)
let linkedCandidate = linkedDirectory.appendingPathComponent("linked.art")
try Data([0x01]).write(to: outside)
try Data([0x02]).write(to: inside)
try FileManager.default.createDirectory(
at: outsideDirectory,
withIntermediateDirectories: true
)
try Data([0x03]).write(to: outsideViaSymlink)
try FileManager.default.createSymbolicLink(
at: linkedDirectory,
withDestinationURL: outsideDirectory
)
defer {
try? FileManager.default.removeItem(at: outside)
try? FileManager.default.removeItem(at: outsideDirectory)
}
SystemShelfPlugin.pruneUnreferenced(
[outside, inside, linkedCandidate],
defaults: harness.defaults,
root: harness.root
)
XCTAssertTrue(FileManager.default.fileExists(atPath: outside.path))
XCTAssertTrue(FileManager.default.fileExists(atPath: outsideViaSymlink.path))
XCTAssertFalse(FileManager.default.fileExists(atPath: inside.path))
}
func testArtworkLoaderCancelsUnknownLengthBodyAtConfiguredCap() {
let stopped = expectation(description: "oversized request cancelled")
ShelfArtworkURLProtocol.configure(
start: { request in
request.sendResponse()
request.send(Data(repeating: 0x41, count: 5))
request.send(Data(repeating: 0x42, count: 5))
request.finish()
},
stop: { stopped.fulfill() }
)
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)
let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)
XCTAssertNil(result)
XCTAssertTrue(loader.exceededLimit)
XCTAssertLessThanOrEqual(loader.peakBufferedBytes, 8)
wait(for: [stopped], timeout: 1)
}
func testArtworkLoaderReturnsBoundedChunkedBodyAndMimeType() {
let expected = Data([0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07])
ShelfArtworkURLProtocol.configure { request in
request.sendResponse()
request.send(expected.prefix(3))
request.send(expected.suffix(4))
request.finish()
}
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [ShelfArtworkURLProtocol.self]
let loader = BoundedArtworkLoader(maximumBytes: 8, configuration: configuration)
let result = loader.load(url: URL(string: "https://shelf.test/art.png")!, timeout: 1)
XCTAssertEqual(result?.data, expected)
XCTAssertEqual(result?.mimeType, "image/png")
XCTAssertEqual(loader.peakBufferedBytes, expected.count)
XCTAssertFalse(loader.exceededLimit)
}
private func replaceCommittedArtworkKey(
in defaults: UserDefaults,
with key: String
) throws {
let data = try XCTUnwrap(
defaults.data(forKey: SystemShelfPlugin.cacheDataKey)
)
var payload = try XCTUnwrap(
JSONSerialization.jsonObject(with: data) as? [String: Any]
)
var sections = try XCTUnwrap(payload["sections"] as? [[String: Any]])
var items = try XCTUnwrap(sections.first?["items"] as? [[String: Any]])
items[0]["artworkKey"] = key
sections[0]["items"] = items
payload["sections"] = sections
defaults.set(
try JSONSerialization.data(withJSONObject: payload),
forKey: SystemShelfPlugin.cacheDataKey
)
}
}