diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift index 894960f..45d632a 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsParsers.swift @@ -73,6 +73,26 @@ public enum CupsParsers { return nil } + /// `lpstat -v` — `device for : ` → queue → device URI. + /// Entries with no URI (e.g. `network ` remote stubs) are + /// skipped (#202 AirPrint detection). + public static func lpstatDeviceURIs(output: String) -> [String: String] { + var result: [String: String] = [:] + for line in output.split(separator: "\n") { + let text = line.trimmingCharacters(in: .whitespaces) + guard text.hasPrefix("device for ") else { continue } + let rest = text.dropFirst("device for ".count) + guard let colon = rest.firstIndex(of: ":") else { continue } + let name = String(rest[.. /// `lpoptions -p` — `key=value` pairs, values may be @@ -137,6 +157,16 @@ public enum CupsParsers { return value } + /// Make-and-model from `printer-make-and-model` in `lpoptions -p` + /// output — an AirPrint detection input (#202). + public static func lpoptionsMakeAndModel(output: String) -> String? { + guard let value = lpoptions(output) + .first(where: { $0.key == "printer-make-and-model" })?.value, + !value.isEmpty + else { return nil } + return value + } + // MARK: - lpoptions -l /// `lpoptions -l` — `Key/Human Label: *Default choice2 choice3`. @@ -352,4 +382,62 @@ public enum CupsParsers { "EPIJ_OSColMat", "ColorCorrection", "StpColorCorrection", "EpsonColorMode", ] + + // MARK: - AirPrint detection (#202, docs/14 §10.2) + + /// AirPrint queues always colour-manage the URF raster path, so + /// unmanaged device colour cannot be guaranteed — Stage 2 warns + /// via `airPrintWarningBadge`. `true` when ANY rule matches: + /// + /// 1. device URI contains `apple-airprint://` + /// 2. PPD declares `*APAirPrint: True` + /// 3. make-and-model contains "Apple" **and** "AirPrint" + /// 4. `ipps://` URI **and** the PPD text mentions "airprint" + /// (case-insensitive) + /// 5. local unencrypted `ipp://` URI resolved via an AirPrint mDNS + /// subtype (`_universal._sub._ipp._tcp`) + /// 6. PPD carries a `*cupsFilter2` rule whose destination MIME + /// type is `image/urf` — the raster only AirPrint consumes + public static func detectAirPrint( + deviceURI: String?, makeAndModel: String?, ppd: String + ) -> Bool { + if let uri = deviceURI { + let lower = uri.lowercased() + if lower.contains("apple-airprint://") { return true } + if lower.hasPrefix("ipps://"), + ppd.range(of: "airprint", options: .caseInsensitive) != nil { + return true + } + if lower.hasPrefix("ipp://"), + lower.contains("_universal._sub._ipp._tcp") { + return true + } + } + if ppd.contains("*APAirPrint: True") { return true } + if let model = makeAndModel, + model.contains("Apple"), model.contains("AirPrint") { + return true + } + return ppdCarriesURFFilter(ppd) + } + + /// Rule 6 — `*cupsFilter2: "src dst cost program"` (quotes + /// optional); `image/urf` as the destination token means the queue + /// consumes the AirPrint raster. + private static func ppdCarriesURFFilter(_ ppd: String) -> Bool { + for rawLine in ppd.split(separator: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.lowercased().hasPrefix("*cupsfilter2"), + let colon = line.firstIndex(of: ":") + else { continue } + let tokens = line[line.index(after: colon)...] + .replacingOccurrences(of: "\"", with: "") + .split(separator: " ") + if tokens.count >= 2, + tokens[1].lowercased() == "image/urf" { + return true + } + } + return false + } } diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift index 3a82483..3ecec7a 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/CupsService.swift @@ -57,19 +57,29 @@ public struct CupsService: Sendable { "lpstat", ["-p"], id: ProcessID.cupsLpstat("p"), tolerateFailure: true) let defaultOut = try await run( "lpstat", ["-d"], id: ProcessID.cupsLpstat("d"), tolerateFailure: true) + // One-shot device-URI fetch for AirPrint detection (#202). A + // failed `lpstat -v` is tolerated — enumeration proceeds with + // no URIs and no fallback respawn. + let deviceOut = try await run( + "lpstat", ["-v"], id: ProcessID.cupsLpstat("v"), tolerateFailure: true) let names = CupsParsers.lpstatDestinations(destinationsOut.stdout) let statuses = CupsParsers.lpstatStatuses(statusOut.stdout) let defaultName = CupsParsers.lpstatDefault(defaultOut.stdout) + let deviceURIs = CupsParsers.lpstatDeviceURIs(output: deviceOut.stdout) var printers: [Printer] = [] for name in names { - let displayName = try? await displayName(for: name) + let identity = try? await queueIdentity(for: name) printers.append(Printer( name: name, status: statuses[name] ?? .unknown, isDefault: name == defaultName, - displayName: displayName + displayName: identity?.displayName, + isAirPrint: CupsParsers.detectAirPrint( + deviceURI: deviceURIs[name], + makeAndModel: identity?.makeAndModel, + ppd: loadPPD(for: name) ?? "") )) } return printers @@ -78,9 +88,19 @@ public struct CupsService: Sendable { /// `lpoptions -p ` → `printer-info` (the NSPrinter fallback /// display name, docs/11 §binding). public func displayName(for queue: String) async throws -> String? { + try await queueIdentity(for: queue).displayName + } + + /// One `lpoptions -p ` spawn yields both identity fields — + /// `printer-info` (display name) and `printer-make-and-model` + /// (AirPrint rule 3, #202). + private func queueIdentity( + for queue: String + ) async throws -> (displayName: String?, makeAndModel: String?) { let result = try await run( "lpoptions", ["-p", queue], id: ProcessID.cupsLpoptions(queue)) - return CupsParsers.lpoptionsDisplayName(result.stdout) + return (CupsParsers.lpoptionsDisplayName(result.stdout), + CupsParsers.lpoptionsMakeAndModel(output: result.stdout)) } // MARK: - Capabilities (lpoptions -l + PPD) diff --git a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift index 3549610..1947873 100644 --- a/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift +++ b/Packages/ICCeryCore/Sources/ICCeryCore/Print/PrinterModels.swift @@ -17,17 +17,33 @@ public struct Printer: Codable, Equatable, Sendable { public var status: PrinterStatus public var isDefault: Bool public var displayName: String? + /// AirPrint queue (#202, docs/14 §10.2) — the URF pipeline is always + /// colour-managed, so Stage 2 shows a persistent warning badge. + public var isAirPrint: Bool = false public init( name: String, status: PrinterStatus = .unknown, isDefault: Bool = false, - displayName: String? = nil + displayName: String? = nil, + isAirPrint: Bool = false ) { self.name = name self.status = status self.isDefault = isDefault self.displayName = displayName + self.isAirPrint = isAirPrint + } + + /// `isAirPrint` predates #202 payloads — a missing key decodes as + /// `false` instead of failing the whole decode. + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + name = try c.decode(String.self, forKey: .name) + status = try c.decode(PrinterStatus.self, forKey: .status) + isDefault = try c.decode(Bool.self, forKey: .isDefault) + displayName = try c.decodeIfPresent(String.self, forKey: .displayName) + isAirPrint = try c.decodeIfPresent(Bool.self, forKey: .isAirPrint) ?? false } } @@ -128,7 +144,7 @@ public struct PrintOptions: Codable, Equatable, Sendable { public var ppdUncorrectedPassthrough: Bool? /// Space-separated `key=value` captured from /// `PMPrintSettingsToOptions` and filtered (docs/11 layer ⑥) — - /// the Stage 2 mirror only, never a spool payload. + /// the Stage 2 mirror only, never an `lp` payload. public var cupsOptions: String? public init( diff --git a/Sources/ICCery/Stage2View.swift b/Sources/ICCery/Stage2View.swift index 3461d06..2c348ea 100644 --- a/Sources/ICCery/Stage2View.swift +++ b/Sources/ICCery/Stage2View.swift @@ -266,12 +266,19 @@ struct Stage2View: View { .background(Theme.background) .clipShape(Capsule()) .accessibilityIdentifier("printerStatusBadge") + // AirPrint queues colour-manage the URF path — + // unmanaged colour cannot be guaranteed (#202). + if selected.isAirPrint { + Text("AirPrint queue — unmanaged colour " + + "cannot be guaranteed.") + .font(.caption) + .foregroundStyle(.orange) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Color.orange.opacity(0.12)) + .clipShape(Capsule()) + .accessibilityIdentifier("airPrintWarningBadge") + } } - // Placeholder — Phase 5 (AirPrint detection, M12) - // replaces this with the live unmanaged-colour - // warning badge for AirPrint queues. - EmptyView() - .accessibilityIdentifier("airPrintWarningBadge") Button(action: workflow.print.refreshPrinters) { Image(systemName: "arrow.clockwise") } diff --git a/Tests/ICCeryCoreTests/CupsParserTests.swift b/Tests/ICCeryCoreTests/CupsParserTests.swift index b61b8cd..2b58ad6 100644 --- a/Tests/ICCeryCoreTests/CupsParserTests.swift +++ b/Tests/ICCeryCoreTests/CupsParserTests.swift @@ -455,4 +455,175 @@ final class CupsParserTests: XCTestCase { processManager: ProcessManager(), binaryDir: bin, ppdDir: ppdDir), root) } + + // MARK: - #202 AirPrint detection + + /// `lpstat -v` — `device for : ` lines; a `network` + /// remote stub carries no URI and is skipped. + private let lpstatV = """ + device for Canon_Pro9500_II_series_XPS: usb://Canon/PRO-9500%20II%20series?serial=1234AB + device for Epson_XP_55_LPD: lpd://192.168.1.50/queue + device for EPSON_XP_55_Series: ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./ + device for Office_IPPS: ipps://print.example.com/ipp/print + network Remote_Queue + """ + + func testLpstatDeviceURIs() { + let uris = CupsParsers.lpstatDeviceURIs(output: lpstatV) + XCTAssertEqual(uris.count, 4) + XCTAssertEqual(uris["Canon_Pro9500_II_series_XPS"], + "usb://Canon/PRO-9500%20II%20series?serial=1234AB") + XCTAssertEqual(uris["Epson_XP_55_LPD"], "lpd://192.168.1.50/queue") + XCTAssertEqual(uris["EPSON_XP_55_Series"], + "ipp://EPSON%20XP-55%20Series._universal._sub._ipp._tcp.local./") + XCTAssertEqual(uris["Office_IPPS"], + "ipps://print.example.com/ipp/print") + XCTAssertNil(uris["Remote_Queue"]) + XCTAssertEqual(CupsParsers.lpstatDeviceURIs(output: ""), [:]) + } + + func testLpoptionsMakeAndModel() { + XCTAssertEqual(CupsParsers.lpoptionsMakeAndModel( + output: lpoptionsP), "EPSON EPSON XP-55 Series") + XCTAssertNil(CupsParsers.lpoptionsMakeAndModel( + output: "printer-type=42\n")) + } + + /// Rule 1 — an `apple-airprint://` device URI is AirPrint on its + /// own; PPD and make-and-model are irrelevant. + func testAirPrintRuleAppleAirPrintScheme() { + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "apple-airprint://DeskJet._ipps._tcp.local./", + makeAndModel: nil, ppd: "")) + } + + /// Rule 2 — the PPD declares `*APAirPrint: True`. + func testAirPrintRulePPDFlag() { + let ppd = """ + *PPD-Adobe: "4.3" + *APAirPrint: True + *OpenUI *PageSize/Media Size: PickOne + """ + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "socket://10.0.0.9/", makeAndModel: nil, ppd: ppd)) + } + + /// Rule 3 — make-and-model contains "Apple" and "AirPrint". + func testAirPrintRuleMakeAndModel() { + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "socket://10.0.0.9/", + makeAndModel: "Apple AirPrint", ppd: "")) + // Both tokens are required. + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "socket://10.0.0.9/", + makeAndModel: "Apple LaserWriter", ppd: "")) + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "socket://10.0.0.9/", + makeAndModel: "HP AirPrint-Ready", ppd: "")) + } + + /// Rule 4 — `ipps://` URI **and** the PPD text mentions "airprint" + /// case-insensitively. `ipps://` alone is not enough. + func testAirPrintRuleIPPSWithPPDMention() { + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "ipps://print.example.com/ipp/print", + makeAndModel: nil, + ppd: "*Foo: \"AIRPRINT enabled\"\n")) + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "ipps://print.example.com/ipp/print", + makeAndModel: nil, ppd: "*PPD-Adobe: \"4.3\"\n")) + // An unencrypted ipp:// URI does not satisfy rule 4. + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "ipp://print.example.com/ipp/print", + makeAndModel: nil, + ppd: "*Foo: \"airprint enabled\"\n")) + } + + /// Rule 5 — unencrypted `ipp://` resolved via the AirPrint mDNS + /// subtype `_universal._sub._ipp._tcp`. A plain `ipp://` mDNS name + /// without the subtype is not AirPrint. + func testAirPrintRuleMDNSSubtype() { + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: "ipp://EPSON%20XP-55._universal._sub._ipp._tcp.local./", + makeAndModel: nil, ppd: "")) + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "ipp://EPSON%20XP-55._ipp._tcp.local./", + makeAndModel: nil, ppd: "")) + } + + /// Rule 6 — a `*cupsFilter2` rule whose destination MIME is + /// `image/urf` (the AirPrint-only raster). A PWG-raster filter is + /// not AirPrint. + func testAirPrintRuleCupsFilter2URF() { + let ppd = """ + *cupsFilter2: "application/pdf image/urf 0 -" + *cupsFilter2: "image/urf image/urf 100 -" + """ + XCTAssertTrue(CupsParsers.detectAirPrint( + deviceURI: nil, makeAndModel: nil, ppd: ppd)) + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: nil, makeAndModel: nil, + ppd: "*cupsFilter2: \"application/pdf image/pwg-raster 0 -\"\n")) + } + + /// Negative — a standard USB raster-driver queue (Epson XP-55) + /// matches none of the six rules. + func testAirPrintNegativeUSBRaster() { + let ppd = """ + *PPD-Adobe: "4.3" + *EPIJ_Qual 303/Normal: "" + *cupsFilter: "application/vnd.cups-raster 0 rastertoepson" + """ + XCTAssertFalse(CupsParsers.detectAirPrint( + deviceURI: "usb://EPSON/XP-55%20Series?serial=ABC123", + makeAndModel: "EPSON XP-55 Series", ppd: ppd)) + } + + /// `listPrinters` survives a failing `lpstat -v` — the failure is + /// tolerated, enumeration proceeds, every queue reports + /// `isAirPrint == false`, and there is **no** fallback respawn + /// (exactly one `-v` invocation, #202). + func testListPrintersToleratesLpstatVFailure() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-airprint-\(UUID().uuidString)") + let bin = root.appendingPathComponent("bin") + try FileManager.default.createDirectory( + at: bin, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let log = root.appendingPathComponent("lpstat.log") + + let lpstat = """ + #!/bin/sh + printf '%s\\n' "$1" >> '\(log.path)' + case "$1" in + -e) printf 'Mock_Epson\\n' ;; + -p) printf 'printer Mock_Epson is idle.\\n' ;; + -d) printf 'no system default destination\\n' ;; + -v) exit 1 ;; + esac + exit 0 + """ + let lpoptions = """ + #!/bin/sh + printf "printer-info='Mock'\\n" + """ + for (name, body) in [("lpstat", lpstat), ("lpoptions", lpoptions)] { + let url = bin.appendingPathComponent(name) + try body.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: url.path) + } + + let service = CupsService( + processManager: ProcessManager(), binaryDir: bin, + ppdDir: root.appendingPathComponent("ppd")) + let printers = try await service.listPrinters() + XCTAssertEqual(printers.map(\.name), ["Mock_Epson"]) + XCTAssertFalse(printers[0].isAirPrint) + + let calls = ((try? String(contentsOf: log, encoding: .utf8)) ?? "") + .split(separator: "\n") + XCTAssertEqual(calls.filter { $0 == "-v" }.count, 1, + "lpstat -v must be spawned exactly once: \(calls)") + } } diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpoptions b/Tests/ICCeryUITests/Fixtures/bin/lpoptions index 0110476..5afe011 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/lpoptions +++ b/Tests/ICCeryUITests/Fixtures/bin/lpoptions @@ -22,5 +22,5 @@ if [ "$list" = "1" ]; then printf 'ColorModel/Output Mode: *RGB Gray\n' exit 0 fi -printf "printer-info='Mock %s' printer-type=42\n" "$queue" +printf "printer-info='Mock %s' printer-make-and-model='EPSON XP-55 Series' printer-type=42\n" "$queue" exit 0 diff --git a/Tests/ICCeryUITests/Fixtures/bin/lpstat b/Tests/ICCeryUITests/Fixtures/bin/lpstat index 28a7347..8445c37 100755 --- a/Tests/ICCeryUITests/Fixtures/bin/lpstat +++ b/Tests/ICCeryUITests/Fixtures/bin/lpstat @@ -12,6 +12,12 @@ case "$1" in -d) printf 'system default destination: Mock_Epson_7450\n' ;; + -v) + # Mock_Canon_Pro is the AirPrint fixture queue (#202): local + # unencrypted ipp:// resolved via the _universal mDNS subtype. + printf 'device for Mock_Epson_7450: usb://EPSON/XP-55%%20Series?serial=MOCK123\n' + printf 'device for Mock_Canon_Pro: ipp://Mock%%20Canon%%20Pro._universal._sub._ipp._tcp.local./\n' + ;; *) exit 1 ;; diff --git a/Tests/ICCeryUITests/Milestone12AirPrintUITests.swift b/Tests/ICCeryUITests/Milestone12AirPrintUITests.swift new file mode 100644 index 0000000..5a8900d --- /dev/null +++ b/Tests/ICCeryUITests/Milestone12AirPrintUITests.swift @@ -0,0 +1,132 @@ +import XCTest + +/// Milestone 12 UI tests — #202 AirPrint queue detection. The mock +/// `lpstat -v` fixture reports `Mock_Canon_Pro` as a local unencrypted +/// `ipp://` queue resolved via the `_universal._sub._ipp._tcp` mDNS +/// subtype (rule 5), while `Mock_Epson_7450` is a plain USB raster +/// queue — so the badge must track the picker selection. +@MainActor +final class Milestone12AirPrintUITests: XCTestCase { + + private var app: XCUIApplication! + private var testRoot: URL! + private var binDir: URL! + private var workDir: URL! + private var spoolLogURL: URL! + + override func setUp() async throws { + continueAfterFailure = false + testRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("iccery-ui12-\(UUID().uuidString)") + binDir = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/bin") + workDir = testRoot.appendingPathComponent("work") + spoolLogURL = testRoot.appendingPathComponent("spool.log") + try FileManager.default.createDirectory( + at: workDir, withIntermediateDirectories: true) + + app = XCUIApplication() + app.launchEnvironment = [ + "ICCERY_UI_TESTING": "1", + "ICCERY_TEST_ROOT": testRoot.path, + "ICCERY_ARGYLL_BINARY_DIR": binDir.path, + "ICCERY_CUPS_BIN_DIR": binDir.path, + "ICCERY_TEST_SAVE_TARGET": + workDir.appendingPathComponent("mytarget.ti1").path, + "ICCERY_TEST_WORKDIR": workDir.path, + "ICCERY_TEST_SPOOL_LOG": spoolLogURL.path, + ] + } + + override func tearDown() async throws { + app?.terminate() + app = nil + if let testRoot { + try? FileManager.default.removeItem(at: testRoot) + } + testRoot = nil + } + + private func element(_ id: String) -> XCUIElement { + let inApp = app.descendants(matching: .any)[id] + if inApp.exists { return inApp } + return app.sheets.firstMatch.descendants(matching: .any)[id] + } + + private func waitFor(_ id: String, timeout: TimeInterval = 15) -> XCUIElement { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let el = element(id) + if el.exists { return el } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + let el = element(id) + XCTAssertTrue(el.exists, "Expected element \(id)") + return el + } + + /// Poll until `id` no longer resolves — the badge is conditionally + /// rendered, so absence is only meaningful after a settle window. + private func waitForAbsence(_ id: String, timeout: TimeInterval = 5) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if !element(id).exists { return } + RunLoop.current.run(until: Date().addingTimeInterval(0.1)) + } + XCTAssertFalse(element(id).exists, "Element \(id) should be absent") + } + + /// Drive the app through targen + printtarg so the print panel is + /// live with a manifest. + private func reachPrintPanel() { + app.buttons["btnBrowse"].click() + app.buttons["btnGenerate"].click() + _ = waitFor("btnCreateLayout", timeout: 25) + app.buttons["btnCreateLayout"].click() + _ = waitFor("galleryPage-0", timeout: 25) + } + + /// Select the printer-picker menu item whose title contains + /// `needle` (display names come from the `lpoptions` fixture). + private func selectPrinter(containing needle: String) { + let picker = app.popUpButtons["printerSelect"] + XCTAssertTrue(picker.waitForExistence(timeout: 10)) + picker.click() + let item = app.menuItems + .matching(NSPredicate(format: "title CONTAINS %@", needle)) + .firstMatch + XCTAssertTrue(item.waitForExistence(timeout: 5), + "No printer menu item containing \(needle)") + item.click() + } + + // MARK: - Tests + + /// The badge appears while the AirPrint fixture queue is selected + /// and is absent for the USB Epson — in both directions. + func testAirPrintBadgeTracksSelectedQueue() throws { + app.launch() + app.activate() + reachPrintPanel() + _ = waitFor("printerStatusBadge") + + // Default queue is the USB Epson — no badge. + waitForAbsence("airPrintWarningBadge") + + selectPrinter(containing: "Canon") + let badge = element("airPrintWarningBadge") + XCTAssertTrue(badge.waitForExistence(timeout: 10)) + // StaticText exposes its content via AXValue, not the label. + let badgeText = [badge.value as? String, badge.label, badge.title] + .compactMap { $0 } + .first { !$0.isEmpty } ?? "" + XCTAssertTrue( + badgeText.contains( + "AirPrint queue — unmanaged colour cannot be guaranteed."), + "Unexpected badge text: \(badgeText)") + + selectPrinter(containing: "Epson") + waitForAbsence("airPrintWarningBadge") + } +}