This commit is contained in:
Akshay Kolli
2026-06-30 01:12:19 -07:00
commit 4c1c6b2f37
55 changed files with 13180 additions and 0 deletions

View File

@@ -0,0 +1,100 @@
import AppKit
import XCTest
@testable import ClipBored
final class AppDelegateTests: XCTestCase {
func testStatusItemMenuRoutingSeparatesLeftAndRightClick() {
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: []))
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .rightMouseUp, modifierFlags: []))
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: .control))
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .otherMouseUp, modifierFlags: []))
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseDragged, modifierFlags: .control))
}
func testStatusMenuIncludesStateRowsAndBoundedActions() {
let settingsTitle = "Settings\u{2026}"
let presentation = AppDelegate.statusMenuPresentation(
historyCount: 42,
isCapturePaused: false,
captureStatus: "Captured text from Safari.",
pasteStatus: "",
shortcutStatus: "",
accessibilityStatus: "",
launchAtLoginStatus: ""
)
let menu = AppDelegate.makeStatusMenu(
presentation: presentation,
isCapturePaused: false,
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: AppConfiguration.defaultSettingsShortcut,
target: nil
)
XCTAssertEqual(
menu.items.map { $0.isSeparatorItem ? "-" : $0.title },
[
"ClipBored",
"Capture Running - 42 clips",
"Captured text from Safari.",
"-",
"Show Clipboard",
settingsTitle,
"-",
"Pause Capture",
"-",
"Quit ClipBored"
]
)
let showClipboard = menu.items.first { $0.title == "Show Clipboard" }
XCTAssertEqual(showClipboard?.keyEquivalent, "v")
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.command) == true)
XCTAssertTrue(showClipboard?.keyEquivalentModifierMask.contains(.option) == true)
let settings = menu.items.first { $0.title == settingsTitle }
XCTAssertEqual(settings?.keyEquivalent, ",")
XCTAssertTrue(settings?.keyEquivalentModifierMask.contains(.command) == true)
}
func testStatusMenuPausedStateTakesPriorityOverOlderCaptureStatus() {
let presentation = AppDelegate.statusMenuPresentation(
historyCount: 1,
isCapturePaused: true,
captureStatus: "Captured link from Safari.",
pasteStatus: "Copied",
shortcutStatus: "",
accessibilityStatus: "",
launchAtLoginStatus: ""
)
let menu = AppDelegate.makeStatusMenu(
presentation: presentation,
isCapturePaused: true,
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: AppConfiguration.defaultSettingsShortcut,
target: nil
)
XCTAssertEqual(presentation.summary, "Capture Paused - 1 clip")
XCTAssertEqual(presentation.detail, "Capture is paused.")
XCTAssertEqual(menu.items.first { $0.title == "Resume Capture" }?.state, .on)
XCTAssertNil(menu.items.first { $0.title == "Pause Capture" })
}
func testStatusMenuPresentationTruncatesLongStatusText() {
let presentation = AppDelegate.statusMenuPresentation(
historyCount: 2000,
isCapturePaused: false,
captureStatus: "Skipped:\n" + String(repeating: "A very long ignored source application name ", count: 4),
pasteStatus: "",
shortcutStatus: "",
accessibilityStatus: "",
launchAtLoginStatus: ""
)
XCTAssertEqual(presentation.summary, "Capture Running - 2000 clips")
XCTAssertNotNil(presentation.detail)
XCTAssertLessThanOrEqual(presentation.detail?.count ?? 0, 68)
XCTAssertTrue(presentation.detail?.hasSuffix("...") == true)
XCTAssertFalse(presentation.detail?.contains("\n") == true)
}
}

View File

@@ -0,0 +1,319 @@
import AppKit
import CryptoKit
import XCTest
@testable import ClipBored
final class ClipboardCacheServiceTests: XCTestCase {
private var tempURLs: [URL] = []
override func tearDown() {
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
tempURLs.removeAll()
try? FileManager.default.removeItem(at: temporaryPreviewRoot())
super.tearDown()
}
func testPurgeRemovesImageCacheFilesOverByteLimit() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
let first = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemRed), id: UUID()))
let second = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
XCTAssertTrue(FileManager.default.fileExists(atPath: first.full))
XCTAssertTrue(FileManager.default.fileExists(atPath: first.thumb))
XCTAssertTrue(FileManager.default.fileExists(atPath: second.full))
XCTAssertTrue(FileManager.default.fileExists(atPath: second.thumb))
cacheService.purgeIfNeeded(maxBytes: 1)
cacheService.flushForTesting()
let remaining = try imageCacheFileURLs(in: baseURL)
XCTAssertTrue(remaining.isEmpty)
}
func testClearCacheRemovesOnlyImageCacheFiles() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
let image = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemGreen), id: UUID()))
let pdfPath = try XCTUnwrap(cacheService.cachePDF(Data("%PDF-1.4\n%%EOF".utf8), id: UUID()))
let pdfItem = pdfItem(path: pdfPath)
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: pdfItem))
XCTAssertTrue(FileManager.default.fileExists(atPath: image.full))
XCTAssertTrue(FileManager.default.fileExists(atPath: image.thumb))
XCTAssertTrue(FileManager.default.fileExists(atPath: pdfPath))
XCTAssertTrue(FileManager.default.fileExists(atPath: previewURL.path))
cacheService.clearCache()
cacheService.flushForTesting()
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
XCTAssertTrue(FileManager.default.fileExists(atPath: pdfPath))
XCTAssertFalse(FileManager.default.fileExists(atPath: previewURL.path))
}
func testImageCacheFilesAreEncryptedAndLoadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let image = makeImage(color: .systemPurple)
let paths = try XCTUnwrap(cacheService.cacheImage(image, id: UUID()))
let rawFull = try Data(contentsOf: URL(fileURLWithPath: paths.full))
let rawThumb = try Data(contentsOf: URL(fileURLWithPath: paths.thumb))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawFull))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawThumb))
XCTAssertNil(NSImage(data: rawFull))
XCTAssertNotNil(cacheService.image(for: paths.full))
XCTAssertNotNil(cacheService.image(for: paths.thumb))
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: paths.full)), 0o600)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: paths.thumb)), 0o600)
}
func testPreviewThumbnailUsesExistingFilePreview() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
let imageURL = baseURL.appendingPathComponent("Copied Image.png")
let imageData = try XCTUnwrap(makeImage(color: .systemOrange).pngData())
try imageData.write(to: imageURL)
let item = fileItem(path: imageURL.path)
XCTAssertNotNil(cacheService.previewThumbnail(for: item))
}
func testPreviewThumbnailUsesManagedPDFPreviewFallback() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let pdfData = try makePDFData()
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
XCTAssertNotNil(cacheService.previewThumbnail(for: pdfItem(path: path)))
}
func testPDFCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
let rawPDF = try Data(contentsOf: URL(fileURLWithPath: path))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawPDF))
XCTAssertNotEqual(rawPDF, pdfData)
XCTAssertEqual(cacheService.data(for: path), pdfData)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
}
func testAudioCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let audioData = Data([0, 1, 2, 3, 5, 8, 13])
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
let rawAudio = try Data(contentsOf: URL(fileURLWithPath: path))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawAudio))
XCTAssertNotEqual(rawAudio, audioData)
XCTAssertEqual(cacheService.data(for: path), audioData)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
}
func testRichTextCacheFilesAreEncryptedAndReadable() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let rtfData = Data("{\\rtf1\\ansi ClipBored}".utf8)
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
let rawRTF = try Data(contentsOf: URL(fileURLWithPath: path))
XCTAssertTrue(ClipboardEncryptionService.isProtected(rawRTF))
XCTAssertNotEqual(rawRTF, rtfData)
XCTAssertEqual(cacheService.data(for: path), rtfData)
XCTAssertEqual(try posixPermissions(URL(fileURLWithPath: path)), 0o600)
}
func testLegacyManagedSidecarIsEncryptedAfterRead() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let attachmentDirectory = baseURL.appendingPathComponent("attachments", isDirectory: true)
try FileManager.default.createDirectory(at: attachmentDirectory, withIntermediateDirectories: true)
let url = attachmentDirectory.appendingPathComponent("\(UUID().uuidString).pdf")
let pdfData = Data("%PDF-1.4\nlegacy\n%%EOF".utf8)
try pdfData.write(to: url)
XCTAssertEqual(cacheService.data(for: url.path), pdfData)
let migrated = try Data(contentsOf: url)
XCTAssertTrue(ClipboardEncryptionService.isProtected(migrated))
XCTAssertFalse(String(decoding: migrated, as: UTF8.self).contains("legacy"))
}
func testTemporaryReadableURLWritesPrivateCopyAndCleanupRemovesIt() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let pdfData = Data("%PDF-1.4\ntemporary preview\n%%EOF".utf8)
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: pdfItem(path: path)))
XCTAssertEqual(try Data(contentsOf: previewURL), pdfData)
XCTAssertEqual(try posixPermissions(previewURL.deletingLastPathComponent()), 0o700)
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
cacheService.clearTemporaryPreviews(wait: true)
XCTAssertFalse(FileManager.default.fileExists(atPath: previewURL.path))
}
func testTemporaryReadableURLWorksForAudio() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let audioData = Data([9, 8, 7, 6])
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: audioItem(path: path)))
XCTAssertEqual(try Data(contentsOf: previewURL), audioData)
XCTAssertEqual(previewURL.pathExtension, "sound")
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
}
func testTemporaryReadableURLWorksForRichText() throws {
let baseURL = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: fixedEncryptionService())
let rtfData = Data("{\\rtf1\\ansi Temporary Rich Text}".utf8)
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
let previewURL = try XCTUnwrap(cacheService.temporaryReadableURL(for: richTextItem(path: path)))
XCTAssertEqual(try Data(contentsOf: previewURL), rtfData)
XCTAssertEqual(previewURL.pathExtension, "rtf")
XCTAssertEqual(try posixPermissions(previewURL), 0o600)
}
private func makeTempDirectory() throws -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
tempURLs.append(url)
return url
}
private func imageCacheFileURLs(in baseURL: URL) throws -> [URL] {
let imageDirectory = baseURL.appendingPathComponent("images", isDirectory: true)
return try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil)
}
private func pdfItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .pdf,
displayText: "PDF",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func fileItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .file,
displayText: "File",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func audioItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .audio,
displayText: "Audio",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func richTextItem(path: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Rich Text",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func temporaryPreviewRoot() -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(AppConfiguration.appName, isDirectory: true)
}
private func makeImage(color: NSColor) -> NSImage {
let size = NSSize(width: 24, height: 24)
let image = NSImage(size: size)
image.lockFocus()
color.setFill()
NSRect(origin: .zero, size: size).fill()
image.unlockFocus()
return image
}
private func makePDFData() throws -> Data {
let data = NSMutableData()
guard let consumer = CGDataConsumer(data: data as CFMutableData) else {
throw NSError(domain: "ClipBoredTests", code: 1)
}
var box = CGRect(x: 0, y: 0, width: 160, height: 120)
guard let context = CGContext(consumer: consumer, mediaBox: &box, nil) else {
throw NSError(domain: "ClipBoredTests", code: 2)
}
context.beginPDFPage(nil)
context.setFillColor(NSColor.systemOrange.cgColor)
context.fill(CGRect(x: 0, y: 0, width: 160, height: 120))
context.setFillColor(NSColor.systemBlue.cgColor)
context.fillEllipse(in: CGRect(x: 45, y: 30, width: 70, height: 60))
context.endPDFPage()
context.closePDF()
return data as Data
}
private func posixPermissions(_ url: URL) throws -> Int {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
return try XCTUnwrap(attributes[.posixPermissions] as? Int) & 0o777
}
private func noOpEncryptionService() -> ClipboardEncryptionService {
ClipboardEncryptionService(keyProvider: { nil })
}
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
let keyData = Data(repeating: byte, count: 32)
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
}
}

View File

@@ -0,0 +1,73 @@
import CryptoKit
import XCTest
@testable import ClipBored
final class ClipboardEncryptionServiceTests: XCTestCase {
func testProtectAndUnprotectRoundTrip() throws {
let service = makeService(byte: 7)
let value = "secret clipboard value \(UUID().uuidString)"
let protected = try XCTUnwrap(service.protect(value))
XCTAssertTrue(ClipboardEncryptionService.isProtected(protected))
XCTAssertFalse(protected.contains(value))
XCTAssertEqual(service.unprotect(protected), value)
}
func testUnprotectLeavesPlaintextValuesUntouched() {
let service = makeService(byte: 7)
let value = "plain clipboard value"
XCTAssertEqual(service.unprotect(value), value)
}
func testMarkerLookingPlaintextIsStillEncryptable() throws {
let service = makeService(byte: 7)
let value = ClipboardEncryptionService.marker + "not encrypted user text"
let protected = try XCTUnwrap(service.protect(value))
XCTAssertNotEqual(protected, value)
XCTAssertEqual(service.unprotect(protected), value)
XCTAssertEqual(service.unprotect(value), value)
}
func testWrongKeyCannotDecryptProtectedValue() throws {
let service = makeService(byte: 7)
let wrongService = makeService(byte: 9)
let protected = try XCTUnwrap(service.protect("keyed secret"))
XCTAssertNil(wrongService.unprotect(protected))
}
func testProtectDataAndUnprotectDataRoundTrip() throws {
let service = makeService(byte: 7)
let data = Data((0..<128).map { UInt8($0) })
let protected = service.protectData(data)
XCTAssertTrue(ClipboardEncryptionService.isProtected(protected))
XCTAssertNotEqual(protected, data)
XCTAssertEqual(service.unprotectData(protected), data)
}
func testWrongKeyCannotDecryptProtectedData() {
let service = makeService(byte: 7)
let wrongService = makeService(byte: 9)
let protected = service.protectData(Data("binary secret".utf8))
XCTAssertNil(wrongService.unprotectData(protected))
}
func testProtectFallsBackToPlaintextWhenKeyIsUnavailable() {
let service = ClipboardEncryptionService(keyProvider: { nil })
XCTAssertEqual(service.protect("available only in memory"), "available only in memory")
XCTAssertEqual(service.protectData(Data("available only in memory".utf8)), Data("available only in memory".utf8))
}
private func makeService(byte: UInt8) -> ClipboardEncryptionService {
let keyData = Data(repeating: byte, count: 32)
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
}
}

View File

@@ -0,0 +1,722 @@
import XCTest
import Foundation
import AppKit
@testable import ClipBored
final class ClipboardMonitorServiceTests: XCTestCase {
private var tempURLs: [URL] = []
private var suiteNames: [String] = []
override func tearDown() {
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
tempURLs.removeAll()
suiteNames.forEach {
UserDefaults(suiteName: $0)?.removePersistentDomain(forName: $0)
}
suiteNames.removeAll()
super.tearDown()
}
func testClampedIntervalEnforcesResponsiveMinimum() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pollProfile = .responsive
let monitor = ClipboardMonitorService(
store: makeStore(settings: settings),
cacheService: ClipboardCacheService(),
settings: settings
)
XCTAssertEqual(monitor.clampedInterval(0.03), AppConfiguration.minResponsiveActiveInterval)
XCTAssertEqual(monitor.clampedInterval(0.05), AppConfiguration.minResponsiveActiveInterval)
XCTAssertEqual(monitor.clampedInterval(0.075), AppConfiguration.minResponsiveActiveInterval)
XCTAssertEqual(monitor.clampedInterval(0.2), 0.2)
}
func testClampedIntervalDoesNotIncreaseBalancedProfileWindow() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pollProfile = .balanced
let monitor = ClipboardMonitorService(
store: makeStore(settings: settings),
cacheService: ClipboardCacheService(),
settings: settings
)
XCTAssertEqual(monitor.clampedInterval(settings.pollProfile.activeInterval), settings.pollProfile.activeInterval)
XCTAssertGreaterThanOrEqual(monitor.clampedInterval(settings.pollProfile.idleInterval), settings.pollProfile.idleInterval)
}
func testPollProfileChangeEmitsDedicatedNotification() {
let settings = SettingsModel(defaults: makeTestDefaults())
var sawPollProfileChange = false
settings.observe { change in
if case .pollProfile = change {
sawPollProfileChange = true
}
}
settings.pollProfile = .responsive
XCTAssertTrue(sawPollProfileChange)
}
func testSetPausedReschedulesWithCurrentPollingProfile() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pollProfile = .battery
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
monitor.setPaused(false)
XCTAssertEqual(monitor.scheduledIntervalForTesting, settings.pollProfile.idleInterval)
settings.pollProfile = .responsive
monitor.setPaused(false)
XCTAssertEqual(monitor.scheduledIntervalForTesting, settings.pollProfile.idleInterval)
monitor.stop()
}
func testPollNowCapturesCopiedTextOnce() {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.pruneDuplicates = false
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(
store: store,
cacheService: cacheService,
settings: settings
)
let text = "ClipBored monitor smoke \(UUID().uuidString)"
let captured = expectation(description: "copied text captured")
store.observeItems { items in
if items.contains(where: { $0.payload == text }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertEqual(store.items.filter { $0.payload == text }.count, 1)
}
func testPollNowIgnoresClipBoredPasteboardWrites() {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Internal copy",
payload: "Internal copy \(UUID().uuidString)",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(PasteActionService().copy(item), .copied)
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(store.items.isEmpty)
}
func testPollNowCapturesPDFAsRestorableAttachment() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
let captured = expectation(description: "PDF captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .pdf }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(pdfData, forType: .pdf))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .pdf }))
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
XCTAssertEqual(cacheService.data(for: item.payload), pdfData)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
}
func testPollNowCapturesAudioAsRestorableAttachment() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let audioData = Data([1, 2, 3, 4, 8, 16])
let captured = expectation(description: "audio captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .audio }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(audioData, forType: .sound))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .audio }))
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
XCTAssertEqual(cacheService.data(for: item.payload), audioData)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
}
func testPollNowCapturesFileReference() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let fileURL = try makeTempFile(contents: "file reference")
let captured = expectation(description: "file reference captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .file && $0.payload == fileURL.path }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .file)
XCTAssertEqual(store.items.first?.payload, fileURL.path)
}
func testPollNowCapturesMultipleFileReferencesAsOneRestorableItem() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let firstURL = try makeTempFile(contents: "first file reference")
let secondURL = try makeTempFile(contents: "second file reference")
let payload = FilePayload.payload(from: [firstURL, secondURL])
let captured = expectation(description: "multiple file references captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .file && $0.payload == payload }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([firstURL as NSURL, secondURL as NSURL]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .file)
XCTAssertEqual(item.displayText, "2 files")
XCTAssertEqual(item.payload, payload)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
XCTAssertEqual(objects?.map(\.standardizedFileURL), [firstURL.standardizedFileURL, secondURL.standardizedFileURL])
}
func testPollNowPrefersFileReferenceOverStringFallback() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let fileURL = try makeTempFile(contents: "file reference with fallback")
let captured = expectation(description: "file reference captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .file && $0.payload == fileURL.path }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([fileURL as NSURL]))
XCTAssertTrue(pasteboard.setString(fileURL.path, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .file)
XCTAssertEqual(store.items.first?.payload, fileURL.path)
}
func testPollNowCapturesBareURLAsLink() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/releases"
let captured = expectation(description: "URL captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .url && $0.payload == url }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(url, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .url)
XCTAssertEqual(store.items.first?.payload, url)
}
func testPollNowUsesPasteboardURLNameAsLinkTitle() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/releases"
let title = "Release notes"
let captured = expectation(description: "URL with title captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .url && $0.payload == url && $0.displayText == title }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
XCTAssertTrue(pasteboard.setString(title, forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")))
XCTAssertTrue(pasteboard.setString(url, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .url)
XCTAssertEqual(store.items.first?.displayText, title)
XCTAssertEqual(store.items.first?.payload, url)
}
func testPollNowUsesHTMLAnchorTextAsLinkTitle() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/releases"
let title = "Read the release notes"
let html = "<a href=\"\(url)\">\(title)</a>"
let captured = expectation(description: "URL with HTML title captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .url && $0.payload == url && $0.displayText == title }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
XCTAssertTrue(pasteboard.setString(html, forType: .html))
XCTAssertTrue(pasteboard.setString(url, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .url)
XCTAssertEqual(store.items.first?.displayText, title)
XCTAssertEqual(store.items.first?.payload, url)
}
func testPollNowCapturesURLWithLocalImagePreviewAsLink() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/lookbook"
let title = "Lookbook"
let preview = makeImage(color: .systemTeal)
let captured = expectation(description: "URL with local preview captured")
store.observeItems { items in
if let item = items.first, item.kind == .url, item.payload == url, item.thumbnailPath != nil {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(url, forType: .URL))
XCTAssertTrue(pasteboard.setString(title, forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")))
XCTAssertTrue(pasteboard.setData(try XCTUnwrap(preview.tiffRepresentation), forType: .tiff))
XCTAssertTrue(pasteboard.setString(url, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
cacheService.flushForTesting()
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .url)
XCTAssertEqual(item.displayText, title)
XCTAssertNotNil(item.imagePath)
XCTAssertNotNil(item.thumbnailPath)
XCTAssertNotNil(cacheService.previewThumbnail(for: item))
}
func testPollNowCapturesImageWithRecognizedTextWhenSearchIsEnabled() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.includeImageTextInSearch = true
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(
store: store,
cacheService: cacheService,
settings: settings,
imageTextExtractor: { _ in " Receipt total $42\nOrder 1001 " }
)
let image = makeImage(color: .systemOrange)
let captured = expectation(description: "image with recognized text captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .image && $0.ocrText == "Receipt total $42 Order 1001" }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([image]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .image)
XCTAssertEqual(item.ocrText, "Receipt total $42 Order 1001")
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
}
func testPollNowSkipsImageTextExtractionWhenSearchIsDisabled() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.includeImageTextInSearch = false
let (store, cacheService) = makeStoreAndCache(settings: settings)
var extractionCount = 0
let monitor = ClipboardMonitorService(
store: store,
cacheService: cacheService,
settings: settings,
imageTextExtractor: { _ in
extractionCount += 1
return "Should not be captured"
}
)
let image = makeImage(color: .systemPurple)
let captured = expectation(description: "image captured without recognized text")
store.observeItems { items in
if items.contains(where: { $0.kind == .image }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([image]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .image)
XCTAssertNil(item.ocrText)
XCTAssertEqual(extractionCount, 0)
}
func testIgnoredFileSettingDoesNotBlockWebURLObjects() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.file.rawValue]
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let url = "https://example.com/releases"
let captured = expectation(description: "web URL captured when files are ignored")
store.observeItems { items in
if items.contains(where: { $0.kind == .url && $0.payload == url }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([NSURL(string: url)!]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .url)
XCTAssertEqual(store.items.first?.payload, url)
}
func testPollNowKeepsTextContainingURLAsText() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let text = "Review https://example.com/releases before shipping"
let captured = expectation(description: "text with URL captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .text && $0.payload == text }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .text)
XCTAssertEqual(store.items.first?.payload, text)
}
func testPollNowPrefersRichTextOverPlainStringFallback() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let text = "Rich clipboard text"
let attributed = NSAttributedString(
string: text,
attributes: [.font: NSFont.boldSystemFont(ofSize: 13)]
)
let rtfData = try XCTUnwrap(
attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:])
)
let captured = expectation(description: "rich text captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .richText && $0.displayText == text }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(rtfData, forType: .rtf))
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
XCTAssertEqual(store.items.first?.kind, .richText)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.displayText, text)
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
XCTAssertEqual(cacheService.data(for: item.payload), rtfData)
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .rtf), rtfData)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), text)
}
func testPollNowCapturesHTMLClipboardDataAsRestorableRichText() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let text = "Styled HTML clipboard text"
let html = """
<span style="font-weight: 700; color: #0a84ff;">Styled HTML</span> clipboard text
"""
let htmlData = Data(html.utf8)
let captured = expectation(description: "HTML rich text captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .richText && $0.displayText == text }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(htmlData, forType: .html))
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(item.kind, .richText)
XCTAssertEqual(item.displayText, text)
XCTAssertTrue(FileManager.default.fileExists(atPath: item.payload))
let cachedRTF = try XCTUnwrap(cacheService.data(for: item.payload))
XCTAssertNotNil(NSAttributedString(rtf: cachedRTF, documentAttributes: nil))
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
XCTAssertNotNil(NSPasteboard.general.data(forType: .rtf))
XCTAssertEqual(NSPasteboard.general.string(forType: .string), text)
}
func testIgnoredImageKindDoesNotWriteCacheFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.image.rawValue]
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let image = makeImage(color: .systemOrange)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([image]))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
cacheService.flushForTesting()
XCTAssertTrue(store.items.isEmpty)
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
}
func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue]
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(Data("%PDF-1.4\n%%EOF".utf8), forType: .pdf))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
cacheService.flushForTesting()
XCTAssertTrue(store.items.isEmpty)
XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty)
XCTAssertEqual(settings.captureStatusMessage, "Skipped: PDF items are ignored in capture settings.")
}
func testIgnoredAudioKindDoesNotWriteAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.audio.rawValue]
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(Data([3, 1, 4, 1, 5]), forType: .sound))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
cacheService.flushForTesting()
XCTAssertTrue(store.items.isEmpty)
XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty)
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Audio items are ignored in capture settings.")
}
func testIgnoredRichTextKindDoesNotWriteHTMLAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.richText.rawValue]
let (store, cacheService, baseURL) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let text = "Ignored HTML clipboard text"
let html = "<strong>Ignored HTML</strong> clipboard text"
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setData(Data(html.utf8), forType: .html))
XCTAssertTrue(pasteboard.setString(text, forType: .string))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
cacheService.flushForTesting()
XCTAssertTrue(store.items.isEmpty)
XCTAssertTrue(try attachmentFileURLs(in: baseURL).isEmpty)
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Rich Text items are ignored in capture settings.")
}
private func makeTestDefaults() -> UserDefaults {
let suiteName = "com.clipbored.testmonitor.\(UUID().uuidString)"
suiteNames.append(suiteName)
return UserDefaults(suiteName: suiteName)!
}
private func makeStore(settings: SettingsModel) -> ClipboardStore {
makeStoreAndCache(settings: settings).store
}
private func makeStoreAndCache(settings: SettingsModel) -> (store: ClipboardStore, cacheService: ClipboardCacheService) {
let result = makeStoreCacheAndBaseURL(settings: settings)
return (result.store, result.cacheService)
}
private func makeStoreCacheAndBaseURL(settings: SettingsModel) -> (store: ClipboardStore, cacheService: ClipboardCacheService, baseURL: URL) {
let baseURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
tempURLs.append(baseURL)
let cacheService = ClipboardCacheService(
baseURL: baseURL,
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
)
return (
ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: baseURL,
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
),
cacheService,
baseURL
)
}
private func makeTempFile(contents: String) throws -> URL {
let baseURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
tempURLs.append(baseURL)
let fileURL = baseURL.appendingPathComponent("payload.txt")
try contents.write(to: fileURL, atomically: true, encoding: .utf8)
return fileURL
}
private func imageCacheFileURLs(in baseURL: URL) throws -> [URL] {
let imageDirectory = baseURL.appendingPathComponent("images", isDirectory: true)
return try FileManager.default.contentsOfDirectory(at: imageDirectory, includingPropertiesForKeys: nil)
}
private func attachmentFileURLs(in baseURL: URL) throws -> [URL] {
let attachmentDirectory = baseURL.appendingPathComponent("attachments", isDirectory: true)
return try FileManager.default.contentsOfDirectory(at: attachmentDirectory, includingPropertiesForKeys: nil)
}
private func makeImage(color: NSColor) -> NSImage {
let size = NSSize(width: 24, height: 24)
let image = NSImage(size: size)
image.lockFocus()
color.setFill()
NSRect(origin: .zero, size: size).fill()
image.unlockFocus()
return image
}
}

View File

@@ -0,0 +1,147 @@
import XCTest
@testable import ClipBored
final class ClipboardPanelControllerTests: XCTestCase {
func testPanelFrameUsesFullWidthBottomShelf() {
let screenFrame = CGRect(x: -1200, y: -200, width: 1200, height: 800)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
XCTAssertEqual(frames.shown.minX, screenFrame.minX)
XCTAssertEqual(frames.shown.maxX, screenFrame.maxX)
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
XCTAssertEqual(frames.shown.height, 408)
XCTAssertLessThan(frames.hidden.maxY, screenFrame.minY)
}
func testPanelFrameUsesVisibleFrameAroundDock() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
XCTAssertEqual(frames.shown.height, 408)
}
func testPanelFrameSitsAboveVisibleBottomDock() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown.minX, visibleFrame.minX)
XCTAssertEqual(frames.shown.maxX, visibleFrame.maxX)
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
XCTAssertEqual(frames.shown.height, 408)
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
}
func testPanelFrameClampsTallDisplaysToShelfMaximum() {
let screenFrame = CGRect(x: 0, y: 0, width: 3008, height: 2000)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: screenFrame)
XCTAssertEqual(frames.shown.width, 3008)
XCTAssertEqual(frames.shown.height, 430)
XCTAssertEqual(frames.shown.minY, screenFrame.minY)
}
func testPanelFrameFitsTinyVisibleFrameWithoutOverflowing() {
let screenFrame = CGRect(x: 0, y: 0, width: 640, height: 320)
let visibleFrame = CGRect(x: 0, y: 48, width: 640, height: 220)
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown.minY, visibleFrame.minY)
XCTAssertEqual(frames.shown.height, visibleFrame.height)
XCTAssertLessThan(frames.hidden.maxY, visibleFrame.minY)
}
func testPanelFramePlanningIsDeterministicAcrossRepeatedToggles() {
let screenFrame = CGRect(x: -1512, y: -120, width: 1512, height: 982)
let visibleFrame = CGRect(x: -1512, y: -24, width: 1512, height: 861)
let first = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
for _ in 0..<50 {
let frames = ClipboardPanelController.panelFrames(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(frames.shown, first.shown)
XCTAssertEqual(frames.hidden, first.hidden)
XCTAssertEqual(frames.hidden.maxY, frames.shown.minY - 1)
}
}
func testPanelAnimationProfileStaysShortForSixtyFpsFeel() {
let profile = ClipboardPanelController.animationProfile
XCTAssertEqual(profile.showDuration, 0.16)
XCTAssertEqual(profile.hideDuration, 0.12)
XCTAssertEqual(profile.reflowDuration, 0.10)
XCTAssertLessThanOrEqual(profile.showDuration * 60, 10)
XCTAssertLessThanOrEqual(profile.hideDuration * 60, 8)
XCTAssertLessThanOrEqual(profile.reflowDuration * 60, 6)
XCTAssertEqual(profile.easing, .easeInEaseOut)
}
func testReflowPlanMovesOpenPanelAboveNewBottomDockVisibleFrame() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 112, width: 1512, height: 845)
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(plan.frame.minX, visibleFrame.minX)
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
XCTAssertEqual(plan.frame.minY, visibleFrame.minY)
XCTAssertEqual(plan.frame.height, 408)
XCTAssertEqual(plan.bottomSafeInset, 20)
}
func testReflowPlanTracksSideDockVisibleWidthWithoutBottomInsetInflation() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 86, y: 0, width: 1426, height: 957)
let plan = ClipboardPanelController.reflowPlan(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(plan.frame.minX, visibleFrame.minX)
XCTAssertEqual(plan.frame.maxX, visibleFrame.maxX)
XCTAssertEqual(plan.frame.minY, visibleFrame.minY)
XCTAssertEqual(plan.frame.height, 408)
XCTAssertEqual(plan.bottomSafeInset, 18)
}
func testContentBottomInsetReservesBottomDockSpace() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 0, y: 96, width: 1512, height: 861)
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(inset, 20)
}
func testContentBottomInsetUsesMinimumWhenDockIsNotAtBottom() {
let screenFrame = CGRect(x: 0, y: 0, width: 1512, height: 982)
let visibleFrame = CGRect(x: 80, y: 0, width: 1432, height: 957)
let inset = ClipboardPanelController.contentBottomInset(forScreenFrame: screenFrame, visibleFrame: visibleFrame)
XCTAssertEqual(inset, 18)
}
func testCommandNumberShortcutsMapToCollections() {
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: .command), .mostRecent)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 19, modifiers: .command), .mostUsed)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 20, modifiers: .command), .text)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 21, modifiers: .command), .links)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 23, modifiers: .command), .images)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: .command), .files)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: .command), .pinned)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: .command), .audio)
}
func testCollectionShortcutsRequireCommandOnlySoSearchTypingIsUntouched() {
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: []))
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 18, modifiers: [.command, .shift]))
XCTAssertNil(ClipboardPanelController.collectionShortcutMode(forKeyCode: 29, modifiers: .command))
}
}

View File

@@ -0,0 +1,551 @@
import AppKit
import XCTest
@testable import ClipBored
final class ClipboardPanelViewModelTests: XCTestCase {
private var tempURLs: [URL] = []
override func tearDownWithError() throws {
for url in tempURLs {
try? FileManager.default.removeItem(at: url)
}
tempURLs.removeAll()
try super.tearDownWithError()
}
func testComputeVisibleItemsFiltersAndSortsByMode() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let sampleItems = makeSampleItems()
let filteredLinks = viewModel.computeVisibleItems(from: sampleItems, query: "https://", sortMode: .links)
XCTAssertEqual(filteredLinks.map(\.payload), ["https://apple.com"])
let recentByUse = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .mostUsed)
XCTAssertEqual(recentByUse.map(\.payload), ["two", "/tmp/report.pdf", "/tmp/voice.sound", "one", "https://apple.com", "four"])
let textOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .text)
XCTAssertEqual(textOnly.map(\.payload), ["four", "two", "one"])
let filesOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .files)
XCTAssertEqual(filesOnly.map(\.payload), ["/tmp/report.pdf"])
let audioOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .audio)
XCTAssertEqual(audioOnly.map(\.payload), ["/tmp/voice.sound"])
let pinnedOnly = viewModel.computeVisibleItems(from: sampleItems, query: "", sortMode: .pinned)
XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"])
}
func testSearchMatchesIndependentTokensCaseInsensitively() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let items = [
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "GitHub release token",
payload: "Copied from github.com",
payloadHash: hash("github-token"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil
),
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Unrelated note",
payload: "release notes",
payloadHash: hash("note"),
createdAt: Date(timeIntervalSince1970: 90),
lastUsedAt: Date(timeIntervalSince1970: 90),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil
)
]
let result = viewModel.computeVisibleItems(from: items, query: "TOKEN github", sortMode: .mostRecent)
XCTAssertEqual(result.map(\.displayText), ["GitHub release token"])
}
func testCollectionsFilterSearchAndPersistSelection() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let link = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "Release notes",
payload: "https://example.com/releases",
payloadHash: hash("https://example.com/releases"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil
)
let note = makeTextItem("Important meeting note", createdAt: Date(timeIntervalSince1970: 200))
store.upsert(link)
store.upsert(note)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.assignSelected(to: " Client Work ")
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.collectionNames, ["Client Work"])
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 1)
XCTAssertEqual(viewModel.statusMessage, "Added to Client Work")
viewModel.selectCollection(named: "Client Work")
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["https://example.com/releases"])
viewModel.searchText = "release"
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 1)
XCTAssertEqual(viewModel.visibleItems.map(\.displayText), ["Release notes"])
viewModel.searchText = "meeting"
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 0)
XCTAssertTrue(viewModel.visibleItems.isEmpty)
}
func testSearchTextRecomputesVisibleItemsImmediately() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("alpha note", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("needle note", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.searchText = "needle"
XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["needle note"])
XCTAssertEqual(viewModel.selectedItem?.payload, "needle note")
}
func testSelectFirstItemSelectsFirstVisibleItem() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("newer", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.selectFirstItem()
XCTAssertEqual(viewModel.selectedItem?.payload, "newer")
}
func testSelectFirstItemPrefersLatestOnSubsequentUpdates() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("newer", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
viewModel.selectItem(at: 1)
viewModel.selectFirstItem()
store.upsert(makeTextItem("latest", createdAt: Date(timeIntervalSince1970: 300)))
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 3)
XCTAssertEqual(viewModel.selectedItem?.payload, "latest")
}
func testDuplicateRecopyMovesExistingClipToMostRecentFront() {
let settings = makeSettings()
settings.pruneDuplicates = true
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
store.upsert(makeTextItem("older duplicate text", createdAt: Date(timeIntervalSince1970: 100)))
store.upsert(makeTextItem("new unique text", createdAt: Date(timeIntervalSince1970: 200)))
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "new unique text")
store.upsert(makeTextItem("older duplicate text", createdAt: Date(timeIntervalSince1970: 50)))
store.flushPersistenceForTesting()
waitForVisibleItems(in: viewModel, count: 2)
XCTAssertEqual(viewModel.visibleItems.first?.payload, "older duplicate text")
}
func testPollThenSearchAndCopyFlow() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let payload = "clipbored-flow-test-\(UUID().uuidString)"
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.setString(payload, forType: .string))
monitor.pollNowAndWait()
waitForStoreCount(store, count: 1, matching: payload)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.searchText = payload
waitForVisibleItems(in: viewModel, count: 1)
XCTAssertEqual(viewModel.visibleItems.first?.payload, payload)
viewModel.copySelected()
XCTAssertEqual(NSPasteboard.general.string(forType: .string), payload)
XCTAssertEqual(viewModel.statusMessage, "Copied")
}
func testCopySelectedWritesTextToPasteboard() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("panel copy text", createdAt: Date(timeIntervalSince1970: 100))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), item.payload)
}
func testCopySelectedWritesURLToPasteboardTypes() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "https://example.com",
payload: "https://example.com",
payloadHash: hash("https://example.com"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), item.payload)
XCTAssertEqual(NSPasteboard.general.string(forType: .URL), item.payload)
}
func testFailedCopyDoesNotMarkItemUsed() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeMissingFileItem(useCount: 0)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Could not write item to clipboard.")
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 0)
XCTAssertEqual(store.items.first?.lastUsedAt, item.lastUsedAt)
}
func testFailedPasteDoesNotMarkItemUsed() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeMissingFileItem(useCount: 2)
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
var didRequestHide = false
viewModel.willPasteToTarget = { didRequestHide = true }
waitForVisibleItems(in: viewModel, count: 1)
viewModel.pasteSelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Could not write item to clipboard.")
XCTAssertFalse(didRequestHide)
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 2)
XCTAssertEqual(store.items.first?.lastUsedAt, item.lastUsedAt)
}
func testPasteWithoutTargetCopiesButDoesNotRequestPanelHide() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("manual paste fallback", createdAt: Date(timeIntervalSince1970: 10))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
var didRequestHide = false
viewModel.willPasteToTarget = { didRequestHide = true }
viewModel.targetApplicationProvider = { nil }
waitForVisibleItems(in: viewModel, count: 1)
viewModel.pasteSelected()
store.flushPersistenceForTesting()
XCTAssertEqual(viewModel.statusMessage, "Copied")
XCTAssertFalse(didRequestHide)
XCTAssertEqual(store.items.first?.id, item.id)
XCTAssertEqual(store.items.first?.useCount, 1)
}
func testActionStatusIsNotOverwrittenUntilCaptureStatusChanges() {
let settings = makeSettings()
let cacheService = makeCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let item = makeTextItem("status visibility", createdAt: Date(timeIntervalSince1970: 10))
store.upsert(item)
store.flushPersistenceForTesting()
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
waitForVisibleItems(in: viewModel, count: 1)
viewModel.copySelected()
XCTAssertEqual(viewModel.statusMessage, "Copied")
settings.setCaptureStatus(message: "Capture status updated while panel is open")
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertEqual(viewModel.statusMessage, "")
}
private func makeSettings() -> SettingsModel {
let settings = SettingsModel(defaults: UserDefaults(suiteName: "com.clipbored.testmodel.\(UUID().uuidString)")!)
settings.maxHistoryItems = 10
settings.includeImageTextInSearch = false
settings.pruneDuplicates = false
return settings
}
private func makeStore(settings: SettingsModel) -> ClipboardStore {
let cacheService = makeCacheService()
return makeStore(settings: settings, cacheService: cacheService)
}
private func makeStore(settings: SettingsModel, cacheService: ClipboardCacheService) -> ClipboardStore {
let tempURL = makeTempDirectory()
return ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: tempURL,
encryptionService: ClipboardEncryptionService(keyProvider: { nil })
)
}
private func makeCacheService() -> ClipboardCacheService {
ClipboardCacheService(baseURL: makeTempDirectory(), encryptionService: ClipboardEncryptionService(keyProvider: { nil }))
}
private func makeTempDirectory() -> URL {
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try? FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true)
tempURLs.append(tempURL)
return tempURL
}
private func makeMissingFileItem(useCount: Int) -> ClipboardItem {
let date = Date(timeIntervalSince1970: 2_000)
let missingPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clipbored-missing-\(UUID().uuidString)")
.path
return ClipboardItem(
id: UUID(),
kind: .file,
displayText: "Missing file",
payload: missingPath,
payloadHash: hash(missingPath),
createdAt: date,
lastUsedAt: date,
useCount: useCount,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func makeTextItem(_ text: String, createdAt: Date) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .text,
displayText: text,
payload: text,
payloadHash: hash(text),
createdAt: createdAt,
lastUsedAt: createdAt,
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func waitForStoreCount(
_ store: ClipboardStore,
count: Int,
matching payload: String,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(1)
while store.items.filter({ $0.payload == payload }).count != count && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
XCTAssertEqual(store.items.filter({ $0.payload == payload }).count, count, file: file, line: line)
}
private func waitForVisibleItems(
in viewModel: ClipboardPanelViewModel,
count: Int,
file: StaticString = #filePath,
line: UInt = #line
) {
let deadline = Date().addingTimeInterval(1)
while viewModel.visibleItems.count != count && Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
XCTAssertEqual(viewModel.visibleItems.count, count, file: file, line: line)
}
private func makeSampleItems() -> [ClipboardItem] {
[
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Project notes",
payload: "one",
payloadHash: hash("one"),
createdAt: Date(timeIntervalSince1970: 1000),
lastUsedAt: Date(timeIntervalSince1970: 1000),
useCount: 2,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: true
),
ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Two",
payload: "two",
payloadHash: hash("two"),
createdAt: Date(timeIntervalSince1970: 1100),
lastUsedAt: Date(timeIntervalSince1970: 1080),
useCount: 4,
sourceApp: "Mail",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .url,
displayText: "Apple",
payload: "https://apple.com",
payloadHash: hash("https://apple.com"),
createdAt: Date(timeIntervalSince1970: 1030),
lastUsedAt: Date(timeIntervalSince1970: 1050),
useCount: 1,
sourceApp: "Safari",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .file,
displayText: "report.pdf",
payload: "/tmp/report.pdf",
payloadHash: hash("/tmp/report.pdf"),
createdAt: Date(timeIntervalSince1970: 1060),
lastUsedAt: Date(timeIntervalSince1970: 1070),
useCount: 3,
sourceApp: "Finder",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .audio,
displayText: "Voice memo",
payload: "/tmp/voice.sound",
payloadHash: hash("/tmp/voice.sound"),
createdAt: Date(timeIntervalSince1970: 1040),
lastUsedAt: Date(timeIntervalSince1970: 1060),
useCount: 2,
sourceApp: "Voice Memos",
imagePath: nil,
thumbnailPath: nil,
isPinned: false
),
ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Four",
payload: "four",
payloadHash: hash("four"),
createdAt: Date(timeIntervalSince1970: 1200),
lastUsedAt: Date(timeIntervalSince1970: 1200),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil,
isPinned: true
)
]
}
private func hash(_ value: String) -> String {
value
}
}

View File

@@ -0,0 +1,623 @@
import AppKit
import XCTest
@testable import ClipBored
final class ClipboardPanelViewTests: XCTestCase {
private var tempURLs: [URL] = []
private struct PanelFixture {
let window: NSWindow
let view: ClipboardPanelView
let viewModel: ClipboardPanelViewModel
let settings: SettingsModel
let store: ClipboardStore
let cacheService: ClipboardCacheService
}
override func tearDown() {
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
tempURLs.removeAll()
super.tearDown()
}
func testSearchFieldEditingWhenSearchFieldIsFirstResponder() {
let (window, view) = makePanelWithPanelView()
window.makeFirstResponder(view)
XCTAssertFalse(view.isSearchFieldEditing)
view.focusSearchField()
XCTAssertTrue(view.isSearchFieldEditing)
}
func testSearchFieldEditingWhenFieldEditorIsFirstResponder() {
let (window, view) = makePanelWithPanelView()
view.focusSearchField()
guard let editor = window.fieldEditor(false, for: nil) else {
return XCTFail("Expected a search field editor")
}
window.makeFirstResponder(editor)
XCTAssertTrue(view.isSearchFieldEditing)
}
func testCapturedTextItemCreatesVisibleCardDocument() {
let fixture = makePanelFixture()
let item = makeTextItem("Bruh it said copied text but it does not appear.", store: fixture.store)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.id), [item.id])
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
XCTAssertEqual(fixture.view.debugResultCountText, "1 clip")
XCTAssertTrue(fixture.view.debugDocumentViewIsCardStack)
XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.width, 292)
XCTAssertGreaterThanOrEqual(fixture.view.debugDocumentViewFrame.height, 244)
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"])
}
func testFooterShowsCaptureStatusInsteadOfShortcutInstructions() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Footer status item", store: fixture.store))
drainMainQueue()
XCTAssertEqual(fixture.view.debugStatusText, "Capture running")
XCTAssertEqual(fixture.view.debugStatusTone, "ready")
XCTAssertFalse(fixture.view.debugStatusText.contains("Enter paste"))
}
func testSkippedCaptureStatusUsesWarningTone() {
let fixture = makePanelFixture()
fixture.settings.setCaptureStatus(message: "Skipped: Audio items are ignored in capture settings.")
drainMainQueue()
XCTAssertEqual(fixture.view.debugStatusText, "Skipped: Audio items are ignored in capture settings.")
XCTAssertEqual(fixture.view.debugStatusTone, "warning")
}
func testPanelShellRendersAsSquareDockedSurface() {
let (_, view) = makePanelWithPanelView()
drainMainQueue()
view.layoutSubtreeIfNeeded()
view.displayIfNeeded()
let rep = try! XCTUnwrap(view.bitmapImageRepForCachingDisplay(in: view.bounds))
rep.size = view.bounds.size
view.cacheDisplay(in: view.bounds, to: rep)
let width = rep.pixelsWide
let height = rep.pixelsHigh
func alphaAt(_ x: Int, _ y: Int) -> CGFloat {
rep.colorAt(x: x, y: y)?.alphaComponent ?? 0
}
XCTAssertEqual(view.debugPanelCornerRadius, 0)
XCTAssertGreaterThan(alphaAt(8, 8), 0.9)
XCTAssertGreaterThan(alphaAt(width - 9, 8), 0.9)
XCTAssertGreaterThan(alphaAt(8, height - 9), 0.9)
XCTAssertGreaterThan(alphaAt(width - 9, height - 9), 0.9)
}
func testOpeningTransitionDefersCardRailReloadUntilAnimationCompletes() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Existing clip", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
fixture.view.beginOpeningTransition()
XCTAssertTrue(fixture.view.debugIsDeferringVisualReloads)
fixture.store.upsert(makeTextItem("New clip during open", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugVisibleCardCount, 1)
XCTAssertEqual(fixture.view.debugResultCountText, "2 clips")
fixture.view.finishOpeningTransition()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertFalse(fixture.view.debugIsDeferringVisualReloads)
XCTAssertEqual(fixture.view.debugVisibleCardCount, 2)
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels.first, "Text: New clip during open")
}
func testCollectionRailUsesPasteStyleLabelsAndTracksSelection() {
let fixture = makePanelFixture()
XCTAssertEqual(
fixture.view.debugCollectionTitles,
["Clipboard", "Frequent", "Text", "Links", "Images", "Audio", "Files", "Pinned"]
)
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard")
fixture.viewModel.sortMode = .links
drainMainQueue()
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links")
}
func testSelectedCardActionsRespectSelectedKind() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Plain text", store: fixture.store))
drainMainQueue()
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Delete"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 126)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
fixture.store.upsert(makeItem(kind: .file, text: "/tmp/report.txt", store: fixture.store))
drainMainQueue()
fixture.viewModel.selectFirstItem()
XCTAssertEqual(fixture.viewModel.visibleItems.first?.kind, .file)
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Open", "Reveal", "Delete"])
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionRailWidth, 182)
XCTAssertFalse(fixture.view.debugFirstCardFooterDetailIsHidden)
XCTAssertTrue(fixture.view.debugFirstCardHeaderBadgeIsHidden)
}
func testCardHeaderUsesKindSymbolBadgeWhenSourceIconIsUnavailable() {
let fixture = makePanelFixture()
fixture.store.upsert(makeItem(kind: .url, text: "https://example.com", store: fixture.store))
drainMainQueue()
XCTAssertEqual(fixture.view.debugCardHeaderBadgeSymbols, ["link"])
}
func testCollectionRailShowsLiveCounts() {
let fixture = makePanelFixture()
var pinned = makeTextItem("Pinned note", store: fixture.store)
pinned.isPinned = true
let rich = makeItem(kind: .richText, text: "Rich note", store: fixture.store)
let link = makeItem(kind: .url, text: "https://example.com/releases", store: fixture.store)
let image = makeItem(kind: .image, text: "image payload", store: fixture.store)
let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store)
let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store)
[pinned, rich, link, image, audio, file].forEach {
fixture.store.upsert($0)
drainMainQueue()
}
XCTAssertEqual(fixture.viewModel.visibleItems.count, 6)
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [6, 6, 2, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.view.debugCollectionCounts, [6, 6, 2, 1, 1, 1, 1, 1])
}
func testCollectionRailShowsAssignedCollections() {
let fixture = makePanelFixture()
var link = makeItem(kind: .url, text: "https://example.com/read", store: fixture.store)
link.collectionName = "Useful Links"
var note = makeTextItem("Meeting note", store: fixture.store)
note.collectionName = "Important Notes"
var file = makeItem(kind: .file, text: "/tmp/client-brief.pdf", store: fixture.store)
file.collectionName = "Client Work"
fixture.store.upsert(link)
fixture.store.upsert(note)
fixture.store.upsert(file)
drainMainQueue()
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Useful Links", "Important Notes", "Client Work"])
XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [1, 1, 1])
fixture.viewModel.selectCollection(named: "Useful Links")
drainMainQueue()
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["https://example.com/read"])
}
func testCollectionRailUsesScrollableDocumentForCrowdedCustomCollections() {
let fixture = makePanelFixture()
let names = [
"Client Work",
"Research Archive",
"Launch Planning",
"Design QA",
"Product References",
"Reading Stack",
"Invoices",
"Hiring Pipeline"
]
for name in names {
var item = makeTextItem("Collection item \(name)", store: fixture.store)
item.collectionName = name
fixture.store.upsert(item)
}
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertGreaterThan(fixture.view.debugCollectionRailVisibleWidth, 0)
XCTAssertGreaterThan(
fixture.view.debugCollectionRailDocumentWidth,
fixture.view.debugCollectionRailVisibleWidth + 1
)
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Client Work"))
XCTAssertTrue(fixture.view.debugCustomCollectionTitles.contains("Product References"))
}
func testFilteredEmptyStateNamesCurrentCollection() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Only text exists", store: fixture.store))
drainMainQueue()
fixture.viewModel.sortMode = .images
drainMainQueue()
XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No images yet")
XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Image clips are saved when the clipboard contains image data.")
}
func testCardsExposeContextMenuActions() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Context menu text", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(
fixture.view.debugFirstCardMenuTitles,
["Paste", "Copy", "Pin", "Add to Collection", "-", "Open", "Reveal in Finder", "-", "Delete"]
)
XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles,
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "-", "New Collection..."]
)
}
func testCollectionMenuOffersExistingCustomCollections() {
let fixture = makePanelFixture()
var existing = makeTextItem("Existing client note", store: fixture.store)
existing.collectionName = "Client Work"
fixture.store.upsert(existing)
fixture.store.upsert(makeTextItem("Unsorted card", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(
fixture.view.debugFirstCardCollectionMenuTitles,
["Useful Links", "Important Notes", "Code Snippets", "Read Later", "Client Work", "-", "New Collection..."]
)
}
func testBottomSafeInsetIsAppliedToPanelContent() {
let fixture = makePanelFixture()
fixture.view.setBottomSafeInset(108)
XCTAssertEqual(fixture.view.debugContentInsets.bottom, 108)
}
func testInternalLookingTextDoesNotBecomePrimaryCardTitle() {
let fixture = makePanelFixture()
let item = makeTextItem("clipbored-flow-test-\(UUID().uuidString)", store: fixture.store)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Text: Copied text"])
}
func testLinkCardsUseReadableTitleAndAddressPreview() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .url,
displayText: "Release notes",
payload: "https://www.example.com/releases/v1?utm_source=copy",
store: fixture.store
)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Release notes"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Release notes|example.com/releases/v1|example.com"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-preview"])
}
func testLinkCardsUseMediaPreviewWhenThumbnailExists() throws {
let fixture = makePanelFixture()
let id = UUID()
let paths = try XCTUnwrap(fixture.cacheService.cacheImage(sampleImage(), id: id))
let item = ClipboardItem(
id: id,
kind: .url,
displayText: "Lookbook",
payload: "https://example.com/lookbook",
payloadHash: fixture.store.hashString("https://example.com/lookbook"),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: "Safari",
imagePath: paths.full,
thumbnailPath: paths.thumb
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .links
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Link: Lookbook"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Lookbook|example.com/lookbook|example.com"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["link-media-preview"])
}
func testRichTextCardsUseDisplayTextInsteadOfManagedPayloadPath() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .richText,
displayText: "Styled note",
payload: "/tmp/clipbored-managed-rich-text.rtf",
store: fixture.store
)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Rich Text: Styled note"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Styled note|Styled note|11 characters"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["rich-text-preview"])
}
func testFileCardsUseFilenameLocationAndType() {
let fixture = makePanelFixture()
let fileURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Documents")
.appendingPathComponent("Project Plan.pdf")
let item = makeItem(kind: .file, displayText: "File", payload: fileURL.path, store: fixture.store)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Project Plan.pdf"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Project Plan.pdf|~/Documents|PDF"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
}
func testMultipleFileCardsUseCountAndSharedLocation() throws {
let fixture = makePanelFixture()
let directory = makeTempDirectory()
let firstURL = directory.appendingPathComponent("Brief.pdf")
let secondURL = directory.appendingPathComponent("Invoice.csv")
try Data("brief".utf8).write(to: firstURL)
try Data("invoice".utf8).write(to: secondURL)
let item = makeItem(
kind: .file,
displayText: "2 files",
payload: FilePayload.payload(from: [firstURL, secondURL]),
store: fixture.store
)
fixture.store.upsert(item)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: 2 files"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["2 files|\(directory.path)|2 files"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
}
func testExistingFileCardsUseFullBleedMediaPreviewLayout() throws {
let fixture = makePanelFixture()
let imageURL = makeTempDirectory().appendingPathComponent("Campaign Reference.png")
let imageData = try XCTUnwrap(sampleImage().pngData())
try imageData.write(to: imageURL)
let item = makeItem(kind: .file, displayText: "File", payload: imageURL.path, store: fixture.store)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .files
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["File: Campaign Reference.png"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-media-preview"])
}
func testPdfAndImageCardsUseSpecificPreviewText() {
let fixture = makePanelFixture()
let pdfURL = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Downloads")
.appendingPathComponent("Reference Guide.pdf")
let pdf = makeItem(
kind: .pdf,
displayText: "PDF",
payload: pdfURL.path,
store: fixture.store,
ocrText: "Quarterly metrics\nSecond page"
)
fixture.store.upsert(pdf)
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["PDF: Reference Guide.pdf"])
XCTAssertEqual(
fixture.view.debugCardPreviewSummaries,
["Reference Guide.pdf|Quarterly metrics Second page|PDF"]
)
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["file-preview"])
let image = makeItem(
kind: .image,
displayText: "Image",
payload: "",
store: fixture.store,
ocrText: "Receipt total $42"
)
fixture.store.upsert(image)
fixture.viewModel.sortMode = .images
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Image: Receipt total $42"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Receipt total $42|Receipt total $42|OCR text"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-fallback-preview"])
}
func testImageCardsUseMediaPreviewWhenThumbnailExists() {
let fixture = makePanelFixture()
let item = makeCachedImageItem(store: fixture.store, cacheService: fixture.cacheService)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .images
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Image: Campaign portrait"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["media-preview"])
}
func testAudioCardsUseSpecificPreviewText() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .audio,
displayText: "Audio (14 KB)",
payload: "/tmp/clipbored-audio.sound",
store: fixture.store
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .audio
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Audio: Audio (14 KB)"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["Audio (14 KB)|Sound clip|Audio"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"])
}
private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) {
let fixture = makePanelFixture()
return (fixture.window, fixture.view)
}
private func makePanelFixture() -> PanelFixture {
let settings = makeSettings()
let cacheService = ClipboardCacheService()
let store = makeStore(settings: settings, cacheService: cacheService)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
let view = ClipboardPanelView(
viewModel: viewModel,
onClose: {},
onSettings: {}
)
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 1200, height: 520),
styleMask: [.titled, .closable],
backing: .buffered,
defer: false
)
window.contentView = view
window.makeKeyAndOrderFront(nil)
return PanelFixture(
window: window,
view: view,
viewModel: viewModel,
settings: settings,
store: store,
cacheService: cacheService
)
}
private func makeSettings() -> SettingsModel {
let settings = SettingsModel(defaults: UserDefaults(suiteName: "com.clipbored.viewtest.\(UUID().uuidString)")!)
settings.maxHistoryItems = 10
settings.pruneDuplicates = false
return settings
}
private func makeStore(settings: SettingsModel, cacheService: ClipboardCacheService) -> ClipboardStore {
ClipboardStore(settings: settings, cacheService: cacheService, baseURL: makeTempDirectory())
}
private func makeTempDirectory() -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("clipbored-viewtest")
.appendingPathComponent(UUID().uuidString)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
tempURLs.append(directory)
return directory
}
private func makeTextItem(_ text: String, store: ClipboardStore) -> ClipboardItem {
makeItem(kind: .text, text: text, store: store)
}
private func makeItem(kind: ClipboardItemKind, text: String, store: ClipboardStore) -> ClipboardItem {
makeItem(kind: kind, displayText: text, payload: text, store: store)
}
private func makeItem(
kind: ClipboardItemKind,
displayText: String,
payload: String,
store: ClipboardStore,
ocrText: String? = nil
) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: kind,
displayText: displayText,
payload: payload,
payloadHash: store.hashString(payload),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: "Ghostty",
imagePath: nil,
thumbnailPath: nil,
ocrText: ocrText
)
}
private func makeCachedImageItem(store: ClipboardStore, cacheService: ClipboardCacheService) -> ClipboardItem {
let id = UUID()
let paths = cacheService.cacheImage(sampleImage(), id: id)
return ClipboardItem(
id: id,
kind: .image,
displayText: "Campaign portrait",
payload: paths?.full ?? "",
payloadHash: store.hashString("campaign-portrait"),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: "Photos",
imagePath: paths?.full,
thumbnailPath: paths?.thumb,
ocrText: nil
)
}
private func sampleImage() -> NSImage {
let image = NSImage(size: NSSize(width: 180, height: 140))
image.lockFocus()
NSColor(calibratedRed: 1.0, green: 0.76, blue: 0.20, alpha: 1).setFill()
NSRect(x: 0, y: 0, width: 180, height: 140).fill()
NSColor(calibratedRed: 0.92, green: 0.20, blue: 0.26, alpha: 1).setFill()
NSBezierPath(ovalIn: NSRect(x: 34, y: 24, width: 82, height: 82)).fill()
NSColor(calibratedRed: 0.05, green: 0.42, blue: 0.86, alpha: 1).setFill()
NSBezierPath(roundedRect: NSRect(x: 92, y: 45, width: 58, height: 48), xRadius: 12, yRadius: 12).fill()
image.unlockFocus()
return image
}
private func drainMainQueue() {
for _ in 0..<20 {
RunLoop.main.run(until: Date().addingTimeInterval(0.01))
}
}
}

View File

@@ -0,0 +1,547 @@
import CryptoKit
import XCTest
import Foundation
import AppKit
@testable import ClipBored
final class ClipboardStoreTests: XCTestCase {
private var defaults: UserDefaults!
private var defaultsSuiteName: String!
private var cacheService: ClipboardCacheService!
private var baseURL: URL!
override func setUpWithError() throws {
try super.setUpWithError()
defaultsSuiteName = "com.clipbored.teststore.\(UUID().uuidString)"
defaults = UserDefaults(suiteName: defaultsSuiteName)
defaults.removePersistentDomain(forName: defaultsSuiteName)
baseURL = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: baseURL, withIntermediateDirectories: true)
cacheService = ClipboardCacheService(baseURL: baseURL, encryptionService: noOpEncryptionService())
}
override func tearDownWithError() throws {
defaults.removePersistentDomain(forName: defaultsSuiteName)
if let baseURL {
try? FileManager.default.removeItem(at: baseURL)
}
cacheService = nil
defaults = nil
baseURL = nil
try super.tearDownWithError()
}
func testUpsertMovesDuplicateToFrontAndPersists() throws {
let settings = makeSettings(maxHistory: 4)
let store = makeStore(settings: settings)
let start = Date()
store.upsert(makeItem("one", displayText: "One", created: start))
store.upsert(makeItem("two", displayText: "Two", created: start.addingTimeInterval(-10)))
store.upsert(makeItem("three", displayText: "Three", created: start.addingTimeInterval(-20)))
store.upsert(makeItem("one", displayText: "One (updated)", created: start.addingTimeInterval(-30)))
store.flushPersistenceForTesting()
XCTAssertEqual(store.items.count, 3)
XCTAssertEqual(store.items.map(\.payload), ["one", "three", "two"])
XCTAssertEqual(store.items.first?.useCount, 2)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.count, 3)
XCTAssertEqual(restored.items.first?.payload, "one")
XCTAssertEqual(restored.items.first?.useCount, 2)
}
func testHistoryLimitIsEnforcedByOverflowPurge() throws {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let start = Date()
(0...50).forEach { i in
store.upsert(makeItem("item-\(i)", displayText: "\(i)", created: start.addingTimeInterval(-Double(i))))
}
store.flushPersistenceForTesting()
XCTAssertEqual(store.items.count, 50)
XCTAssertEqual(store.items.first?.payload, "item-50")
XCTAssertEqual(store.items.last?.payload, "item-1")
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.count, 50)
XCTAssertFalse(restored.items.contains(where: { $0.payload == "item-0" }))
XCTAssertTrue(restored.items.contains(where: { $0.payload == "item-50" }))
XCTAssertTrue(restored.items.contains(where: { $0.payload == "item-1" }))
}
func testMarkUsedUpdatesStateAndWritesMutationOnlyOnce() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("same", displayText: "First", created: Date()))
store.upsert(makeItem("same", displayText: "First duplicate", created: Date().addingTimeInterval(1)))
store.flushPersistenceForTesting()
let first = try! XCTUnwrap(store.items.first)
let firstID = first.id
store.markUsed(firstID)
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.count, 1)
XCTAssertEqual(restored.items.first?.useCount, 3)
XCTAssertEqual(restored.items.first?.displayText, "First duplicate")
}
func testTogglePinPersistsAcrossReload() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("alpha", displayText: "A", created: Date()))
store.flushPersistenceForTesting()
let itemID = try! XCTUnwrap(store.items.first?.id)
store.togglePin(itemID)
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.isPinned, true)
}
func testSetCollectionPersistsAcrossReload() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("alpha", displayText: "A", created: Date()))
store.flushPersistenceForTesting()
let itemID = try! XCTUnwrap(store.items.first?.id)
store.setCollection(itemID, name: " Client Work ")
store.flushPersistenceForTesting()
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.collectionName, "Client Work")
let restoredID = try! XCTUnwrap(restored.items.first?.id)
restored.setCollection(restoredID, name: nil)
restored.flushPersistenceForTesting()
let cleared = makeStore(settings: settings)
cleared.flushPersistenceForTesting()
XCTAssertNil(cleared.items.first?.collectionName)
}
func testLegacyJSONHistoryMigratesToSQLite() throws {
let settings = makeSettings(maxHistory: 50)
let itemID = UUID()
let legacyJSON = """
[
{
"id": "\(itemID.uuidString)",
"kind": 0,
"displayText": "Legacy Note",
"payload": "legacy payload",
"payloadHash": "legacy-hash",
"createdAt": "2026-06-27T12:00:00Z",
"lastUsedAt": "2026-06-27T12:01:00Z",
"useCount": 3,
"sourceApp": "Notes",
"imagePath": null,
"thumbnailPath": null
}
]
"""
try legacyJSON.data(using: .utf8)!.write(to: baseURL.appendingPathComponent("history.json"))
let store = makeStore(settings: settings)
store.flushPersistenceForTesting()
XCTAssertEqual(store.items.count, 1)
XCTAssertEqual(store.items.first?.id, itemID)
XCTAssertEqual(store.items.first?.payload, "legacy payload")
XCTAssertEqual(store.items.first?.useCount, 3)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.payload, "legacy payload")
}
func testPinnedItemsSurviveNormalHistoryPrune() {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let start = Date()
store.upsert(makeItem("pinned-old", displayText: "Pinned", created: start.addingTimeInterval(-500)))
let pinnedID = try! XCTUnwrap(store.items.first?.id)
store.togglePin(pinnedID)
(0..<60).forEach { index in
store.upsert(makeItem("new-\(index)", displayText: "New \(index)", created: start.addingTimeInterval(Double(index))))
}
store.flushPersistenceForTesting()
XCTAssertTrue(store.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
XCTAssertEqual(store.items.filter { !$0.isPinned }.count, 50)
XCTAssertEqual(store.items.filter(\.isPinned).count, 1)
let restored = makeStore(settings: settings)
restored.flushPersistenceForTesting()
XCTAssertTrue(restored.items.contains(where: { $0.payload == "pinned-old" && $0.isPinned }))
}
func testStorageFilesUsePrivatePermissions() throws {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
store.upsert(makeItem("private", displayText: "Private", created: Date()))
store.flushPersistenceForTesting()
XCTAssertEqual(try posixPermissions(baseURL), 0o700)
XCTAssertEqual(try posixPermissions(baseURL.appendingPathComponent("history.sqlite")), 0o600)
}
func testStorageDirectoryHonorsEnvironmentOverride() throws {
let overrideURL = baseURL.appendingPathComponent("OverrideStorage", isDirectory: true)
setenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey, overrideURL.path, 1)
defer { unsetenv(AppConfiguration.storageDirectoryOverrideEnvironmentKey) }
let resolved = ClipboardStore.storageDirectory()
XCTAssertEqual(resolved.path, overrideURL.standardizedFileURL.path)
XCTAssertTrue(FileManager.default.fileExists(atPath: overrideURL.path))
XCTAssertEqual(try posixPermissions(overrideURL), 0o700)
}
func testRemoveAllResetsEncryptionKeyAfterClearingDatabase() throws {
let settings = makeSettings(maxHistory: 50)
var resetCount = 0
let keyData = Data(repeating: 7, count: 32)
let encryptionService = ClipboardEncryptionService(
keyProvider: { SymmetricKey(data: keyData) },
resetProvider: { resetCount += 1 }
)
let store = makeStore(settings: settings, encryptionService: encryptionService)
store.upsert(makeItem("first", displayText: "First", created: Date()))
store.upsert(makeItem("second", displayText: "Second", created: Date()))
store.flushPersistenceForTesting()
let firstID = try XCTUnwrap(store.items.first?.id)
store.remove(firstID)
store.flushPersistenceForTesting()
XCTAssertEqual(resetCount, 0)
store.removeAll()
store.flushPersistenceForTesting()
XCTAssertEqual(resetCount, 1)
XCTAssertTrue(store.items.isEmpty)
let restored = makeStore(settings: settings, encryptionService: encryptionService)
restored.flushPersistenceForTesting()
XCTAssertTrue(restored.items.isEmpty)
}
func testRemoveAllCompactsDatabaseFile() throws {
let settings = makeSettings(maxHistory: 2000)
settings.pruneDuplicates = false
let store = makeStore(settings: settings)
let payload = String(repeating: "clipbored database compaction payload ", count: 180)
for index in 0..<60 {
store.upsert(makeItem("\(payload)\(index)", displayText: "Large \(index)", created: Date(timeIntervalSince1970: Double(index))))
}
store.flushPersistenceForTesting()
let dbURL = baseURL.appendingPathComponent("history.sqlite")
let sizeBeforeClear = try fileSize(dbURL)
XCTAssertGreaterThan(sizeBeforeClear, 200_000)
store.removeAll()
store.flushPersistenceForTesting()
let sizeAfterClear = try fileSize(dbURL)
XCTAssertLessThan(sizeAfterClear, sizeBeforeClear / 2)
XCTAssertEqual(try posixPermissions(dbURL), 0o600)
}
func testPersistedTextFieldsAreEncryptedAndReload() throws {
let settings = makeSettings(maxHistory: 50)
let encryptionService = fixedEncryptionService()
let store = makeStore(settings: settings, encryptionService: encryptionService)
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Displayed secret \(UUID().uuidString)",
payload: "Payload secret \(UUID().uuidString)",
payloadHash: "Hash secret \(UUID().uuidString)",
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 1,
sourceApp: "Secret app \(UUID().uuidString)",
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: "com.example.secret.\(UUID().uuidString)",
ocrText: "OCR secret \(UUID().uuidString)",
collectionName: "Collection secret \(UUID().uuidString)"
)
store.upsert(item)
store.flushPersistenceForTesting()
let rawDatabaseText = try databaseText()
XCTAssertTrue(rawDatabaseText.contains(ClipboardEncryptionService.marker))
XCTAssertFalse(rawDatabaseText.contains(item.displayText))
XCTAssertFalse(rawDatabaseText.contains(item.payload))
XCTAssertFalse(rawDatabaseText.contains(item.payloadHash))
XCTAssertFalse(rawDatabaseText.contains(item.sourceApp!))
XCTAssertFalse(rawDatabaseText.contains(item.sourceAppBundleId!))
XCTAssertFalse(rawDatabaseText.contains(item.ocrText!))
XCTAssertFalse(rawDatabaseText.contains(item.collectionName!))
let restored = makeStore(settings: settings, encryptionService: encryptionService)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.displayText, item.displayText)
XCTAssertEqual(restored.items.first?.payload, item.payload)
XCTAssertEqual(restored.items.first?.payloadHash, item.payloadHash)
XCTAssertEqual(restored.items.first?.sourceApp, item.sourceApp)
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
}
func testPlaintextDatabaseMigratesToEncryptedFieldsOnLoad() throws {
let settings = makeSettings(maxHistory: 50)
let plaintextStore = makeStore(settings: settings, encryptionService: noOpEncryptionService())
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Legacy display \(UUID().uuidString)",
payload: "Legacy payload \(UUID().uuidString)",
payloadHash: "Legacy hash \(UUID().uuidString)",
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 1,
sourceApp: "Legacy source \(UUID().uuidString)",
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: "com.example.legacy.\(UUID().uuidString)",
ocrText: "Legacy OCR \(UUID().uuidString)",
collectionName: "Legacy collection \(UUID().uuidString)"
)
plaintextStore.upsert(item)
plaintextStore.flushPersistenceForTesting()
XCTAssertTrue(try databaseText().contains(item.payload))
let encryptionService = fixedEncryptionService()
let restored = makeStore(settings: settings, encryptionService: encryptionService)
restored.flushPersistenceForTesting()
XCTAssertEqual(restored.items.first?.displayText, item.displayText)
XCTAssertEqual(restored.items.first?.payload, item.payload)
XCTAssertEqual(restored.items.first?.payloadHash, item.payloadHash)
XCTAssertEqual(restored.items.first?.sourceApp, item.sourceApp)
XCTAssertEqual(restored.items.first?.sourceAppBundleId, item.sourceAppBundleId)
XCTAssertEqual(restored.items.first?.ocrText, item.ocrText)
XCTAssertEqual(restored.items.first?.collectionName, item.collectionName)
let migratedDatabaseText = try databaseText()
XCTAssertTrue(migratedDatabaseText.contains(ClipboardEncryptionService.marker))
XCTAssertFalse(migratedDatabaseText.contains(item.displayText))
XCTAssertFalse(migratedDatabaseText.contains(item.payload))
XCTAssertFalse(migratedDatabaseText.contains(item.payloadHash))
XCTAssertFalse(migratedDatabaseText.contains(item.sourceApp!))
XCTAssertFalse(migratedDatabaseText.contains(item.sourceAppBundleId!))
XCTAssertFalse(migratedDatabaseText.contains(item.ocrText!))
XCTAssertFalse(migratedDatabaseText.contains(item.collectionName!))
}
func testDuplicatePDFReplacementRemovesOldAttachment() throws {
let settings = makeSettings(maxHistory: 50)
let store = makeStore(settings: settings)
let hash = "same-pdf"
let oldPath = try XCTUnwrap(cacheService.cachePDF(Data("old".utf8), id: UUID()))
let newPath = try XCTUnwrap(cacheService.cachePDF(Data("new".utf8), id: UUID()))
store.upsert(makePDFItem(path: oldPath, hash: hash, created: Date(timeIntervalSince1970: 10)))
store.upsert(makePDFItem(path: newPath, hash: hash, created: Date(timeIntervalSince1970: 20)))
store.flushPersistenceForTesting()
cacheService.flushForTesting()
XCTAssertEqual(store.items.count, 1)
XCTAssertEqual(store.items.first?.payload, newPath)
XCTAssertFalse(FileManager.default.fileExists(atPath: oldPath))
XCTAssertTrue(FileManager.default.fileExists(atPath: newPath))
}
func testDuplicateReplacementClearsStaleImageSearchMetadata() throws {
let settings = makeSettings(maxHistory: 50)
settings.keepFirstImage = false
let store = makeStore(settings: settings)
let staleImage = try XCTUnwrap(cacheService.cacheImage(makeImage(color: .systemBlue), id: UUID()))
let hash = "same-content"
let imageItem = makeImageItem(
fullPath: staleImage.full,
thumbPath: staleImage.thumb,
hash: hash,
ocrText: "stale search marker",
created: Date(timeIntervalSince1970: 10)
)
let textItem = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Replacement text",
payload: "Replacement text",
payloadHash: hash,
createdAt: Date(timeIntervalSince1970: 20),
lastUsedAt: Date(timeIntervalSince1970: 20),
useCount: 1,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: "com.apple.Notes",
ocrText: nil
)
store.upsert(imageItem)
store.upsert(textItem)
store.flushPersistenceForTesting()
cacheService.flushForTesting()
let item = try XCTUnwrap(store.items.first)
XCTAssertEqual(store.items.count, 1)
XCTAssertEqual(item.kind, .text)
XCTAssertEqual(item.payload, textItem.payload)
XCTAssertNil(item.imagePath)
XCTAssertNil(item.thumbnailPath)
XCTAssertNil(item.ocrText)
XCTAssertFalse(item.searchableText.contains("stale search marker"))
XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.full))
XCTAssertFalse(FileManager.default.fileExists(atPath: staleImage.thumb))
}
private func makeSettings(maxHistory: Int) -> SettingsModel {
let settings = SettingsModel(defaults: defaults)
settings.maxHistoryItems = maxHistory
settings.pruneDuplicates = true
settings.keepFirstImage = true
return settings
}
private func makeStore(settings: SettingsModel) -> ClipboardStore {
makeStore(settings: settings, encryptionService: noOpEncryptionService())
}
private func makeStore(settings: SettingsModel, encryptionService: ClipboardEncryptionService) -> ClipboardStore {
ClipboardStore(
settings: settings,
cacheService: cacheService,
baseURL: baseURL,
encryptionService: encryptionService
)
}
private func makeItem(_ payload: String, displayText: String, created: Date) -> ClipboardItem {
let hash = settingsHash(for: payload)
return ClipboardItem(
id: UUID(),
kind: .text,
displayText: displayText,
payload: payload,
payloadHash: hash,
createdAt: created,
lastUsedAt: created,
useCount: 1,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: false
)
}
private func makePDFItem(path: String, hash: String, created: Date) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .pdf,
displayText: "PDF",
payload: path,
payloadHash: hash,
createdAt: created,
lastUsedAt: created,
useCount: 1,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil,
isPinned: false
)
}
private func makeImageItem(fullPath: String, thumbPath: String, hash: String, ocrText: String?, created: Date) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .image,
displayText: "Image",
payload: fullPath,
payloadHash: hash,
createdAt: created,
lastUsedAt: created,
useCount: 1,
sourceApp: "Preview",
imagePath: fullPath,
thumbnailPath: thumbPath,
isPinned: false,
sourceAppBundleId: "com.apple.Preview",
ocrText: ocrText
)
}
private func makeImage(color: NSColor) -> NSImage {
let size = NSSize(width: 24, height: 24)
let image = NSImage(size: size)
image.lockFocus()
color.setFill()
NSRect(origin: .zero, size: size).fill()
image.unlockFocus()
return image
}
private func settingsHash(for payload: String) -> String {
payload
}
private func posixPermissions(_ url: URL) throws -> Int {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
return try XCTUnwrap(attributes[.posixPermissions] as? Int) & 0o777
}
private func fileSize(_ url: URL) throws -> Int64 {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
let size = try XCTUnwrap(attributes[.size] as? NSNumber)
return size.int64Value
}
private func databaseText() throws -> String {
let data = try Data(contentsOf: baseURL.appendingPathComponent("history.sqlite"))
return String(decoding: data, as: UTF8.self)
}
private func noOpEncryptionService() -> ClipboardEncryptionService {
ClipboardEncryptionService(keyProvider: { nil })
}
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
let keyData = Data(repeating: byte, count: 32)
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
}
}

View File

@@ -0,0 +1,35 @@
import XCTest
@testable import ClipBored
final class DiagnosticsServiceTests: XCTestCase {
func testCountersCanBeIncrementedAndReset() {
let diagnostics = DiagnosticsService.shared
diagnostics.reset()
diagnostics.incrementMonitorTick()
diagnostics.incrementPasteboardChange()
diagnostics.incrementExtractionAttempt()
diagnostics.incrementDatabaseMutation()
diagnostics.incrementCachePurge()
// The counters use a serial async queue for low overhead; sync through reset's queue by reading a snapshot.
let snapshot = waitForSnapshot { diagnostics.currentSnapshot() }
XCTAssertEqual(snapshot.monitorTicks, 1)
XCTAssertEqual(snapshot.pasteboardChanges, 1)
XCTAssertEqual(snapshot.extractionAttempts, 1)
XCTAssertEqual(snapshot.databaseMutations, 1)
XCTAssertEqual(snapshot.cachePurges, 1)
diagnostics.reset()
XCTAssertEqual(diagnostics.currentSnapshot(), .init(monitorTicks: 0, pasteboardChanges: 0, extractionAttempts: 0, databaseMutations: 0, cachePurges: 0))
}
private func waitForSnapshot(_ snapshot: @escaping () -> DiagnosticsService.Snapshot) -> DiagnosticsService.Snapshot {
let expectation = expectation(description: "diagnostics queue")
DispatchQueue.global().asyncAfter(deadline: .now() + 0.05) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 1)
return snapshot()
}
}

View File

@@ -0,0 +1,426 @@
import AppKit
import CryptoKit
import XCTest
@testable import ClipBored
final class PasteActionServiceTests: XCTestCase {
private var tempURLs: [URL] = []
override func tearDown() {
tempURLs.forEach { try? FileManager.default.removeItem(at: $0) }
tempURLs.removeAll()
super.tearDown()
}
func testCopyWritesTextToPasteboard() {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Hello",
payload: "Hello",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Hello")
}
func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "No target",
payload: "No target",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.paste(item, targetApp: nil), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "No target")
}
func testAutomaticPasteActivatesTargetAndSchedulesKeyboardPasteWhenPermissionGranted() throws {
var activatedProcessID: pid_t?
let targetApp = try makeRunningTargetApp()
var didScheduleKeyboardPaste = false
let service = PasteActionService(
accessibilityPermissionProvider: { true },
targetActivator: { app in
activatedProcessID = app.processIdentifier
return true
},
keyboardPasteScheduler: { _ in
didScheduleKeyboardPaste = true
}
)
XCTAssertEqual(service.paste(makeTextItem("Paste into target"), targetApp: targetApp), .pasted)
XCTAssertEqual(activatedProcessID, targetApp.processIdentifier)
XCTAssertTrue(didScheduleKeyboardPaste)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Paste into target")
}
func testAutomaticPasteDoesNotPostShortcutWhenTargetActivationFails() throws {
var didAttemptActivation = false
let targetApp = try makeRunningTargetApp()
let service = PasteActionService(
accessibilityPermissionProvider: { true },
targetActivator: { _ in
didAttemptActivation = true
return false
},
keyboardPasteScheduler: { _ in
XCTFail("Keyboard paste should not be scheduled when target activation fails")
}
)
XCTAssertEqual(service.paste(makeTextItem("Activation failed"), targetApp: targetApp), .copied)
XCTAssertTrue(didAttemptActivation)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Activation failed")
}
func testAutomaticPasteWithoutPermissionDoesNotActivateTarget() throws {
let targetApp = try makeRunningTargetApp()
let service = PasteActionService(
accessibilityPermissionProvider: { false },
targetActivator: { _ in
XCTFail("Target should not be activated without Accessibility permission")
return true
},
keyboardPasteScheduler: { _ in
XCTFail("Keyboard paste should not be scheduled without Accessibility permission")
}
)
XCTAssertEqual(service.paste(makeTextItem("Needs permission"), targetApp: targetApp), .copiedNeedsPermission)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Needs permission")
}
func testCopyMissingFileDoesNotClearExistingPasteboard() {
let service = PasteActionService()
let board = NSPasteboard.general
board.clearContents()
XCTAssertTrue(board.setString("keep me", forType: .string))
let item = ClipboardItem(
id: UUID(),
kind: .file,
displayText: "Missing file",
payload: "/tmp/clipbored-missing-\(UUID().uuidString)",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .failed("Could not write item to clipboard."))
XCTAssertEqual(board.string(forType: .string), "keep me")
}
func testCopyEmptyTextDoesNotClearExistingPasteboard() {
let service = PasteActionService()
let board = NSPasteboard.general
board.clearContents()
XCTAssertTrue(board.setString("keep me", forType: .string))
let item = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "",
payload: "",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .failed("Could not write item to clipboard."))
XCTAssertEqual(board.string(forType: .string), "keep me")
}
func testCopyWritesURLType() {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .url,
displayText: "Apple",
payload: "https://apple.com",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "https://apple.com")
XCTAssertEqual(NSPasteboard.general.string(forType: .URL), "https://apple.com")
XCTAssertEqual(
NSPasteboard.general.string(forType: NSPasteboard.PasteboardType(rawValue: "public.url-name")),
"Apple"
)
}
func testCopyWritesRichTextRTFAndPlainStringFallback() throws {
let directory = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
let attributed = NSAttributedString(
string: "Styled clipboard text",
attributes: [.font: NSFont.boldSystemFont(ofSize: 15)]
)
let rtfData = try XCTUnwrap(
attributed.rtf(from: NSRange(location: 0, length: attributed.length), documentAttributes: [:])
)
let path = try XCTUnwrap(cacheService.cacheRichText(rtfData, id: UUID()))
let service = PasteActionService(cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .richText,
displayText: attributed.string,
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .rtf), rtfData)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), attributed.string)
}
func testCopyLegacyRichTextWritesPlainPayloadWhenRTFCacheIsUnavailable() {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Legacy rich text",
payload: "Legacy rich text",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Legacy rich text")
XCTAssertNil(NSPasteboard.general.data(forType: .rtf))
}
func testCopyRichTextWithMissingCacheWritesDisplayTextInsteadOfPath() throws {
let missingPath = try makeTempDirectory().appendingPathComponent("missing-rich-text.rtf").path
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .richText,
displayText: "Readable rich text",
payload: missingPath,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertFalse(FileManager.default.fileExists(atPath: missingPath))
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "Readable rich text")
}
func testCopyWritesFileReferenceType() throws {
let fileURL = try makeTempFile(contents: "file contents")
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .file,
displayText: fileURL.path,
payload: fileURL.path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
XCTAssertEqual(objects?.first?.standardizedFileURL, fileURL.standardizedFileURL)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), fileURL.path)
}
func testCopyWritesMultipleFileReferences() throws {
let firstURL = try makeTempFile(contents: "first file")
let secondURL = try makeTempFile(contents: "second file")
let payload = FilePayload.payload(from: [firstURL, secondURL])
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .file,
displayText: "2 files",
payload: payload,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
let objects = NSPasteboard.general.readObjects(forClasses: [NSURL.self], options: nil) as? [URL]
XCTAssertEqual(objects?.map(\.standardizedFileURL), [firstURL.standardizedFileURL, secondURL.standardizedFileURL])
XCTAssertEqual(NSPasteboard.general.string(forType: .string), payload)
}
func testCopyWritesPDFData() throws {
let pdfData = Data("%PDF-1.4\nclipbored\n%%EOF".utf8)
let fileURL = try makeTempFile(contents: pdfData)
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .pdf,
displayText: "PDF",
payload: fileURL.path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
}
func testCopyWritesAudioData() throws {
let directory = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
let audioData = Data([1, 3, 5, 7, 9])
let path = try XCTUnwrap(cacheService.cacheAudio(audioData, id: UUID()))
let service = PasteActionService(cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .audio,
displayText: "Audio",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
}
func testCopyWritesEncryptedPDFData() throws {
let directory = try makeTempDirectory()
let cacheService = ClipboardCacheService(baseURL: directory, encryptionService: fixedEncryptionService())
let pdfData = Data("%PDF-1.4\nencrypted clipbored\n%%EOF".utf8)
let path = try XCTUnwrap(cacheService.cachePDF(pdfData, id: UUID()))
let service = PasteActionService(cacheService: cacheService)
let item = ClipboardItem(
id: UUID(),
kind: .pdf,
displayText: "PDF",
payload: path,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertTrue(ClipboardEncryptionService.isProtected(try Data(contentsOf: URL(fileURLWithPath: path))))
XCTAssertEqual(service.copy(item), .copied)
XCTAssertEqual(NSPasteboard.general.data(forType: .pdf), pdfData)
}
private func makeTempFile(contents: String) throws -> URL {
try makeTempFile(contents: Data(contents.utf8))
}
private func makeTextItem(_ value: String) -> ClipboardItem {
ClipboardItem(
id: UUID(),
kind: .text,
displayText: value,
payload: value,
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
}
private func makeRunningTargetApp() throws -> NSRunningApplication {
try XCTUnwrap(
NSWorkspace.shared.runningApplications.first {
!$0.isTerminated && $0.processIdentifier > 0
}
)
}
private func makeTempFile(contents: Data) throws -> URL {
let directory = try makeTempDirectory()
let url = directory.appendingPathComponent("payload")
try contents.write(to: url)
return url
}
private func makeTempDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("clipboredtests", isDirectory: true)
.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
tempURLs.append(directory)
return directory
}
private func fixedEncryptionService(byte: UInt8 = 7) -> ClipboardEncryptionService {
let keyData = Data(repeating: byte, count: 32)
return ClipboardEncryptionService(keyProvider: { SymmetricKey(data: keyData) })
}
}

View File

@@ -0,0 +1,70 @@
import XCTest
@testable import ClipBored
final class SensitiveContentDetectorTests: XCTestCase {
func testDetectsKnownSecretFormats() {
XCTAssertEqual(
SensitiveContentDetector.detect("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"),
.privateKey
)
XCTAssertEqual(
SensitiveContentDetector.detect("Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456"),
.bearerToken
)
XCTAssertEqual(
SensitiveContentDetector.detect("ghp_abcdefghijklmnopqrstuvwxyzABCDE1234567890"),
.githubToken
)
XCTAssertEqual(
SensitiveContentDetector.detect("AKIA1234567890ABCDEF"),
.awsAccessKey
)
XCTAssertEqual(
SensitiveContentDetector.detect("xoxb-abcdefghijklmnopqrst"),
.slackToken
)
XCTAssertEqual(
SensitiveContentDetector.detect("sk_live_abcdefghijklmnop"),
.stripeKey
)
XCTAssertEqual(
SensitiveContentDetector.detect("sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"),
.openAIToken
)
XCTAssertEqual(
SensitiveContentDetector.detect("AIzaabcdefghijklmnopqrstuvwxyz123456789"),
.googleAPIKey
)
XCTAssertEqual(
SensitiveContentDetector.detect("eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature123"),
.jsonWebToken
)
}
func testDetectsCreditCardWithLuhnCheck() {
XCTAssertEqual(SensitiveContentDetector.detect("4242424242424242"), .creditCard)
XCTAssertNil(SensitiveContentDetector.detect("4242424242424241"))
}
func testAllowsNormalClipboardText() {
XCTAssertNil(SensitiveContentDetector.detect("Project notes for tomorrow"))
XCTAssertNil(SensitiveContentDetector.detect("https://www.apple.com/mac/"))
XCTAssertNil(SensitiveContentDetector.detect("Remember to request the API key from the platform team"))
XCTAssertNil(SensitiveContentDetector.detect("Release token cleanup notes"))
}
func testDetectsOtpOnlyForSensitiveSources() {
XCTAssertNil(SensitiveContentDetector.detect("123456"))
XCTAssertEqual(
SensitiveContentDetector.detect("123456", sourceBundleId: "com.1password.1password", sourceApp: "1Password"),
.oneTimeCode
)
}
func testDetectsSecretAssignments() {
XCTAssertEqual(SensitiveContentDetector.detect("OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwxyz"), .openAIToken)
XCTAssertEqual(SensitiveContentDetector.detect("client_secret: supersecretvalue"), .keyword)
XCTAssertEqual(SensitiveContentDetector.detect("refresh_token = \"abc1234567890\""), .keyword)
XCTAssertEqual(SensitiveContentDetector.detect("passwd='correct-horse-battery'"), .keyword)
}
}

View File

@@ -0,0 +1,70 @@
import AppKit
import Carbon
import XCTest
@testable import ClipBored
final class ShortcutManagerTests: XCTestCase {
func testVirtualKeyCodeMappingSupportsDefaults() {
XCTAssertEqual(ShortcutManager.virtualKeyCode(for: "v"), UInt16(kVK_ANSI_V))
XCTAssertEqual(ShortcutManager.virtualKeyCode(for: ","), UInt16(kVK_ANSI_Comma))
}
func testVirtualKeyCodeRejectsUnsupportedKeys() {
XCTAssertNil(ShortcutManager.virtualKeyCode(for: "space"))
XCTAssertNil(ShortcutManager.virtualKeyCode(for: ""))
}
func testCarbonModifierMapping() {
let flags = NSEvent.ModifierFlags.command.union(.option).rawValue
let carbon = ShortcutManager.carbonModifiers(for: flags)
XCTAssertTrue(carbon & UInt32(cmdKey) != 0)
XCTAssertTrue(carbon & UInt32(optionKey) != 0)
XCTAssertFalse(carbon & UInt32(controlKey) != 0)
}
func testRejectsBareGlobalShortcut() {
let manager = makeManager(openShortcut: ShortcutBinding(key: "v", modifierFlags: 0))
XCTAssertEqual(manager.start(), .unsupportedShortcut("V"))
manager.stop()
}
func testRejectsShiftOnlyGlobalShortcut() {
let manager = makeManager(openShortcut: ShortcutBinding(key: "v", modifierFlags: NSEvent.ModifierFlags.shift.rawValue))
XCTAssertEqual(manager.start(), .unsupportedShortcut("⇧V"))
manager.stop()
}
func testRejectsUnsupportedSettingsShortcutBeforeRegistration() {
let manager = makeManager(
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: ShortcutBinding(key: "space", modifierFlags: NSEvent.ModifierFlags.command.rawValue)
)
XCTAssertEqual(manager.start(), .unsupportedShortcut("⌘SPACE"))
manager.stop()
}
func testRejectsDuplicateShortcutBindingsBeforeRegistration() {
let manager = makeManager(
openShortcut: AppConfiguration.defaultOpenShortcut,
settingsShortcut: AppConfiguration.defaultOpenShortcut
)
XCTAssertEqual(manager.start(), .conflict(AppConfiguration.defaultOpenShortcut.displayText))
manager.stop()
}
private func makeManager(
openShortcut: ShortcutBinding,
settingsShortcut: ShortcutBinding = AppConfiguration.defaultSettingsShortcut
) -> ShortcutManager {
ShortcutManager(
onOpenClipboardPanel: {},
onOpenSettings: {},
openShortcut: openShortcut,
settingsShortcut: settingsShortcut
)
}
}