Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5389e4c2bd | ||
|
|
972368aa85 |
@@ -73,6 +73,26 @@ public enum CupsParsers {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// `lpstat -v` — `device for <name>: <uri>` → queue → device URI.
|
||||
/// Entries with no URI (e.g. `network <name>` 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[..<colon])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
let uri = String(rest[rest.index(after: colon)...])
|
||||
.trimmingCharacters(in: .whitespaces)
|
||||
guard !name.isEmpty, !uri.isEmpty else { continue }
|
||||
result[name] = uri
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - lpoptions -p <queue>
|
||||
|
||||
/// `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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <queue>` → `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 <queue>` 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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -455,4 +455,175 @@ final class CupsParserTests: XCTestCase {
|
||||
processManager: ProcessManager(),
|
||||
binaryDir: bin, ppdDir: ppdDir), root)
|
||||
}
|
||||
|
||||
// MARK: - #202 AirPrint detection
|
||||
|
||||
/// `lpstat -v` — `device for <name>: <uri>` 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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user