cupsadmin: CLI, CupsKit, app in progress

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kfattic
2026-09-16 13:13:07 -05:00
commit eba94aae4e
44 changed files with 7256 additions and 0 deletions

View File

@@ -0,0 +1,218 @@
@testable import CupsKit
import Foundation
import Testing
/// Parser/control tests read Ricoh's vendor PPDs (read-only); each test is skipped when its PPD isn't installed.
let vendorPPDs = "/Library/Printers/PPDs/Contents/Resources/"
func vendorPPD(_ name: String) -> PPD? {
(try? String(contentsOfFile: vendorPPDs + name, encoding: .isoLatin1)).map(PPD.init(text:))
}
func hasVendorPPD(_ name: String) -> Bool {
FileManager.default.fileExists(atPath: vendorPPDs + name)
}
@Suite struct PPDParsing {
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func ricohGroupsTypesAndCustomParameters() throws {
let ppd = try #require(vendorPPD("RICOH IM C4500"))
#expect(ppd.groups.contains("User Authentication"))
#expect(ppd.groups.contains(PPD.generalGroup))
let duplex = try #require(ppd.option("Duplex"))
#expect(duplex.group == PPD.generalGroup)
#expect(duplex.choiceList.contains(PPD.Choice(keyword: "DuplexNoTumble", text: "Long Edge")))
let enable = try #require(ppd.option("RIEnableUserCode"))
#expect(enable.group == "Job Log")
#expect(enable.control == .toggle(on: "True", off: "False"))
guard case .field(let code) = try #require(ppd.option("RIUserCode")).control else {
Issue.record("RIUserCode should be a field"); return
}
#expect(code.kind == .string)
#expect(code.maximum == 8)
#expect(!code.isSecret)
guard case .field(let pin) = try #require(ppd.option("RIPassword")).control else {
Issue.record("RIPassword should be a field"); return
}
#expect(pin.kind == .passcode)
#expect(pin.isSecret)
#expect(pin.minimum == 4 && pin.maximum == 8)
guard case .field(let loginPassword) = try #require(ppd.option("RIAuthLoginPassword")).control else {
Issue.record("RIAuthLoginPassword should be a field"); return
}
#expect(loginPassword.kind == .password)
// PageSize has a multi-parameter points custom size: stays a picker.
guard case .picker = try #require(ppd.option("PageSize")).control else {
Issue.record("PageSize should be a picker"); return
}
guard case .picker = try #require(ppd.option("ColorModel")).control else {
Issue.record("ColorModel should be a picker"); return
}
}
@Test(.enabled(if: hasVendorPPD("RICOH M C251FW.ppd")))
func booleanWithThreeChoicesFallsBackToPicker() throws {
let ppd = try #require(vendorPPD("RICOH M C251FW.ppd"))
let mode = try #require(ppd.option("HQPrintMode"))
#expect(mode.uiType == .boolean)
#expect(mode.choices.count == 3)
guard case .picker(let choices) = mode.control else { Issue.record("expected picker"); return }
#expect(choices.map(\.keyword) == ["661", "662", "664"])
}
@Test func inlineGroupsAndJCL() {
let ppd = PPD(text: """
*PPD-Adobe: "4.3"
*OpenUI *Duplex/Two-Sided: PickOne
*DefaultDuplex: None
*Duplex None/Off: ""
*Duplex DuplexNoTumble/Long Edge: ""
*CloseUI: *Duplex
*OpenGroup: Auth/Authentication
*OpenSubGroup: Codes/Codes
*JCLOpenUI *JCLCode/Code: PickOne
*DefaultJCLCode: None
*JCLCode None/None: ""
*JCLCloseUI: *JCLCode
*CustomJCLCode True/Custom: ""
*ParamCustomJCLCode Code/Account Code: 1 string 1 12
*CloseSubGroup: Codes
*CloseGroup: Auth
""")
#expect(ppd.groups == ["General", "Authentication / Codes"])
let code = ppd.option("JCLCode")
#expect(code?.isJCL == true)
#expect(code?.customParameters.first?.text == "Account Code")
#expect(code?.customChoice == "Custom.STRING")
}
}
@Suite struct CustomValues {
let field: CustomField = {
let ppd = PPD(text: """
*OpenUI *RIPassword/Password: PickOne
*DefaultRIPassword: None
*RIPassword None/None: ""
*CloseUI: *RIPassword
*CustomRIPassword True/Custom: ""
*ParamCustomRIPassword Password: 1 passcode 4 8
""")
guard case .field(let f) = ppd.option("RIPassword")!.control else { fatalError() }
return f
}()
@Test func prefixIsInvisible() {
#expect(field.text(fromValue: "Custom.1234") == "1234")
#expect(field.text(fromValue: "None") == "")
#expect(field.value(fromText: "1234") == "Custom.1234")
#expect(field.value(fromText: "") == "None")
}
@Test func passcodeValidation() {
#expect(field.validationError("123") == "At least 4 characters")
#expect(field.validationError("123456789") == "At most 8 characters")
#expect(field.validationError("12a4") == "Digits only")
#expect(field.validationError("1234") == nil)
#expect(field.validationError("") == nil)
}
@Test func quotingAndMasking() {
#expect(OptionEncoding.argument(key: "RIUserCode", value: "Custom.12345") == "RIUserCode=Custom.12345")
#expect(OptionEncoding.argument(key: "RIAuthLoginUserNameText", value: "Custom.John Smith")
== "RIAuthLoginUserNameText=\"Custom.John Smith\"")
#expect(OptionEncoding.argument(key: "k", value: "a\"b") == "k=\"a\\\"b\"")
#expect(OptionEncoding.masked("Custom.1234") == "Custom.••••")
let command = OptionApplier.displayCommand(queue: "Q", changes: [
OptionChange(key: "RIPassword", value: "Custom.9876", isSecret: true),
OptionChange(key: "RIUserCode", value: "Custom.42", isSecret: false),
])
#expect(command == "/usr/sbin/lpadmin -p Q -o RIPassword=Custom.•••• -o RIUserCode=Custom.42")
#expect(!command.contains("9876"))
}
}
@Suite struct ReadBackAndOrder {
private func state(_ ppdText: String, attributes: [IPPAttribute] = []) -> OptionState {
OptionState(queue: "Q", snapshot: QueueSnapshot(attributes: IPPGroup(tag: .printerAttributes, attributes: attributes),
ppd: PPD(text: ppdText)), userOptions: [:])
}
@Test func readBackFlagsTyposAndValuesThatDidNotStick() {
let s = state("*OpenUI *Duplex/Duplex: PickOne\n*DefaultDuplex: None\n*Duplex None/Off: \"\"\n*CloseUI: *Duplex\n",
attributes: [.string("job-sheets-default", .nameWithoutLanguage, ["none", "none"])])
#expect(OptionApplier.verify(OptionChange(key: "Duplex", value: "None", isSecret: false), state: s).applied)
#expect(!OptionApplier.verify(OptionChange(key: "Duplex", value: "DuplexTumble", isSecret: false), state: s).applied)
let typo = OptionApplier.verify(OptionChange(key: "Duplx", value: "None", isSecret: false), state: s)
#expect(!typo.applied)
#expect(typo.reason == "Not applied: Q has no option Duplx")
#expect(OptionApplier.verify(OptionChange(key: "job-sheets-default", value: "none", isSecret: false), state: s).applied)
#expect(OptionApplier.command(queue: "Q", changes: []).executable == "/usr/sbin/lpadmin")
}
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func generalBasicPaperFirstInstallableOptionsLast() throws {
let ppdText = try #require(try? String(contentsOfFile: vendorPPDs + "RICOH IM C4500", encoding: .isoLatin1))
let titles = state(ppdText).sections().map(\.title)
#expect(Array(titles.prefix(3)) == ["General", "Basic", "Paper"])
#expect(titles.last == "Installable Options")
#expect(titles.filter { $0 == "Installable Options" }.count == 1)
}
}
@Suite struct LpOptionsParsing {
@Test func destLineWithQuotesAndBraces() {
let text = """
Default OldPrinter
Dest Office sides=two-sided-long-edge RIAuthLoginUserNameText="Custom.My Name" PageSize={Width=100 Height=200}
Dest Office/draft print-quality=3
"""
let office = LpOptionsFile.options(for: "Office", in: text)
#expect(office["sides"] == "two-sided-long-edge")
#expect(office["RIAuthLoginUserNameText"] == "Custom.My Name")
#expect(office["PageSize"] == "{Width=100 Height=200}")
#expect(office["print-quality"] == nil) // instance line ignored
#expect(LpOptionsFile.options(for: "OldPrinter", in: text).isEmpty)
}
}
/// Live apply + read-back through the same code path the app uses.
/// Opt-in: CUPSKIT_LIVE_QUEUE=<throwaway Ricoh queue> swift test. Restores every value it changes.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUEUE"] != nil))
struct LiveApply {
// Queue defaults only: the app writes with lpadmin, never lpoptions.
let queue = ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUEUE"] ?? ""
@Test func queueScopeCustomCodeToggleAndIPP() async throws {
let client = CupsClient()
let before = try await OptionState.load(client: client, queue: queue)
let originals = ["RIEnableUserCode", "RIUserCode", "RIAuthLoginUserNameText", "copies-default", "RIPassword"]
.map { ($0, before.queueValue($0)) }
let changes = [
OptionChange(key: "RIEnableUserCode", value: "True", isSecret: false),
OptionChange(key: "RIUserCode", value: "Custom.12345", isSecret: false),
OptionChange(key: "RIAuthLoginUserNameText", value: "Custom.John Smith", isSecret: false),
OptionChange(key: "RIPassword", value: "Custom.2468", isSecret: true),
OptionChange(key: "copies-default", value: "2", isSecret: false),
OptionChange(key: "RIUsrCode", value: "Custom.1", isSecret: false), // typo: must fail read-back
]
let (result, readBack, _) = try await OptionApplier.apply(client: client, queue: queue, changes: changes)
#expect(result.succeeded)
let byKey = Dictionary(uniqueKeysWithValues: readBack.map { ($0.key, $0) })
for key in ["RIEnableUserCode", "RIUserCode", "RIAuthLoginUserNameText", "RIPassword", "copies-default"] {
#expect(byKey[key]?.applied == true, "\(key): \(byKey[key]?.reason ?? "")")
}
#expect(byKey["RIUsrCode"]?.applied == false)
let restore = originals.compactMap { key, value in value.map { OptionChange(key: key, value: $0, isSecret: false) } }
let (_, restored, _) = try await OptionApplier.apply(client: client, queue: queue, changes: restore)
let allRestored = restored.allSatisfy { $0.applied }
#expect(allRestored, "\(restored.filter { !$0.applied })")
}
}

View File

@@ -0,0 +1,59 @@
import CupsKit
import Foundation
import Testing
/// Opt-in: CUPSKIT_LIVE_JOBS=<queueA>,<queueB> ./test.sh — two throwaway, paused queues.
/// Submits /etc/hosts as jobs, drives every action, and leaves both queues paused with no active jobs.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_JOBS"] != nil), .serialized)
struct LiveQueueActions {
let queues = (ProcessInfo.processInfo.environment["CUPSKIT_LIVE_JOBS"] ?? "").split(separator: ",").map(String.init)
private func submit(_ queue: String) throws -> Int {
let result = try CupsTools.run(CupsTools.lpPath, ["-d", queue, "-t", "cupskit live test", "/etc/hosts"])
let text = String(decoding: result.standardOutput, as: UTF8.self) // "request id is Q-123 (1 file(s))"
guard result.succeeded, let id = text.split(separator: " ").compactMap({ $0.split(separator: "-").last.flatMap { Int($0) } }).first else {
throw CupsAdminError.failed("lp failed: \(result.message)")
}
return id
}
@Test func jobHoldReleaseMoveCancel() async throws {
try #require(queues.count == 2)
let client = CupsClient()
let id = try submit(queues[0])
let held = await QueueActions.perform(.hold, jobIDs: [id], client: client)[id]
#expect(held?.succeeded == true, "\(String(describing: held))")
#expect(held?.command == "/usr/bin/lp -i \(id) -H hold")
let released = await QueueActions.perform(.release, jobIDs: [id], client: client)[id]
#expect(released?.succeeded == true, "\(String(describing: released))")
let moved = await QueueActions.perform(.move(to: queues[1]), jobIDs: [id], client: client)[id]
#expect(moved?.succeeded == true, "\(String(describing: moved))")
let canceled = await QueueActions.perform(.cancel, jobIDs: [id], client: client)[id]
#expect(canceled?.succeeded == true, "\(String(describing: canceled))")
// Releasing an already-canceled job must fail, not report success.
let bogus = await QueueActions.perform(.hold, jobIDs: [id], client: client)[id]
#expect(bogus?.succeeded == false)
}
@Test func cancelAllPauseResumeAcceptReject() async throws {
try #require(queues.count == 2)
let client = CupsClient()
let queue = queues[1]
_ = try submit(queue)
_ = try submit(queue)
let cancelAll = try await QueueActions.perform(.cancelAllJobs, queue: queue, client: client)
#expect(cancelAll.succeeded, "\(cancelAll)")
#expect(try await client.getJobs(queue: queue, which: "not-completed", requested: ["job-id"]).isEmpty)
for action in [PrinterAction.reject, .accept, .resume, .pause] {
let outcome = try await QueueActions.perform(action, queue: queue, client: client)
#expect(outcome.succeeded, "\(action): \(outcome)")
}
}
}

View File

@@ -0,0 +1,95 @@
import CupsKit
import Foundation
import Testing
private func available(_ id: String, _ ppd: PPD?) -> Bool {
QuickAction.named(id)!.isAvailable(for: ppd)
}
@Suite struct QuickActionAvailability {
@Test(.enabled(if: hasVendorPPD("RICOH IM C4500")))
func ricohPSHasEverything() throws {
let ppd = try #require(vendorPPD("RICOH IM C4500"))
for id in ["color", "bw", "usercode", "duplex", "simplex", "letter", "default"] {
#expect(available(id, ppd), "\(id)")
}
let letter = try QuickAction.named("letter")!.changes(for: ppd).get()
#expect(letter == [OptionChange(key: "PageSize", value: "Letter", isSecret: false),
OptionChange(key: "RIPaperPolicy", value: "NearestSizeAdjust", isSecret: false)])
let code = try QuickAction.named("usercode")!.changes(for: ppd, value: "4321").get()
#expect(code.map(\.value) == ["True", "Custom.4321"])
let clear = try QuickAction.named("usercode")!.changes(for: ppd, value: "").get()
#expect(clear.map(\.value) == ["False", "None"])
}
@Test(.enabled(if: hasVendorPPD("RICOH SP 3710DN.ppd") && hasVendorPPD("RICOH M C251FW.ppd")))
func otherDriversOnlyGetWhatTheirPPDHas() throws {
// SP 3710DN: mono PCL — no color option, no fit-to-paper; has Duplex.
let sp = try #require(vendorPPD("RICOH SP 3710DN.ppd"))
#expect(!available("color", sp) && !available("bw", sp))
#expect(!available("letter", sp) && !available("usercode", sp))
#expect(available("duplex", sp) && available("simplex", sp))
// M C251FW: color via HQColorMode.
let fw = try #require(vendorPPD("RICOH M C251FW.ppd"))
#expect(try QuickAction.named("color")!.changes(for: fw).get() == [OptionChange(key: "HQColorMode", value: "COLOR", isSecret: false)])
// AirPrint-generated PPD whose printer reported only grayscale: B&W yes, color no.
let airprint = PPD(text: """
*OpenUI *ColorModel/Color Mode: PickOne
*DefaultColorModel: Gray
*ColorModel Gray/Grayscale: ""
*CloseUI: *ColorModel
*OpenUI *PageSize/Media Size: PickOne
*DefaultPageSize: Letter
*PageSize Letter/US Letter: ""
*CloseUI: *PageSize
""")
#expect(!available("color", airprint))
#expect(available("bw", airprint))
#expect(!available("duplex", airprint))
}
@Test func userCodeValidation() {
let action = QuickAction.named("usercode")!
let ppd = vendorPPD("RICOH IM C4500")
#expect(action.validationError("12345", ppd: ppd) == nil)
#expect(action.validationError("12a", ppd: ppd) == "Digits only")
#expect(action.validationError("123456789", ppd: ppd) == "At most 8 digits")
}
}
/// Opt-in: CUPSKIT_LIVE_QUICK=<throwaway Ricoh PS queue>,<throwaway generic queue> ./test.sh
/// Changes both queues; the generic one becomes the server default and is DELETED at the end.
@Suite(.enabled(if: ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUICK"] != nil), .serialized)
struct LiveQuickActions {
let queues = (ProcessInfo.processInfo.environment["CUPSKIT_LIVE_QUICK"] ?? "").split(separator: ",").map(String.init)
@Test func everyActionOnRicohQueue() async throws {
try #require(queues.count == 2)
let client = CupsClient()
for (id, value) in [("letter", nil), ("bw", nil), ("color", nil), ("usercode", "2468"),
("usercode", ""), ("simplex", nil), ("duplex", nil)] as [(String, String?)] {
let outcome = try await QuickAction.named(id)!.run(queue: queues[0], value: value, client: client)
#expect(outcome.succeeded, "\(id): \(outcome.message ?? "")")
}
}
@Test func testPageDefaultAndDelete() async throws {
try #require(queues.count == 2)
let client = CupsClient()
let (page, jobID) = try await QueueActions.printTestPage(queue: queues[1], client: client)
#expect(page.succeeded && jobID != nil, "\(page)")
let unavailable = try? await QuickAction.named("letter")!.run(queue: queues[1], client: client)
#expect(unavailable == nil) // generic PPD has no RIPaperPolicy: throws, writes nothing
let made = try await QuickAction.named("default")!.run(queue: queues[1], client: client)
#expect(made.succeeded, "\(made.message ?? "")")
#expect(try await client.getDefaultPrinter() == queues[1])
let deleted = try await QueueActions.delete(queue: queues[1], client: client)
#expect(deleted.succeeded, "\(deleted)")
#expect(try await client.queueExists(queues[1]) == false)
}
}