mirror of
https://github.com/kfattic/cupsadmin.git
synced 2026-11-13 19:06:43 -05:00
cupsadmin: CLI, CupsKit, app in progress
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
218
Tests/CupsKitTests/OptionEditingTests.swift
Normal file
218
Tests/CupsKitTests/OptionEditingTests.swift
Normal 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 })")
|
||||
}
|
||||
}
|
||||
59
Tests/CupsKitTests/QueueActionsTests.swift
Normal file
59
Tests/CupsKitTests/QueueActionsTests.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
95
Tests/CupsKitTests/QuickActionTests.swift
Normal file
95
Tests/CupsKitTests/QuickActionTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user