diff --git a/TableProTests/Core/Services/ClipboardInstallGuardTests.swift b/TableProTests/Core/Services/ClipboardInstallGuardTests.swift new file mode 100644 index 000000000..ea5736c03 --- /dev/null +++ b/TableProTests/Core/Services/ClipboardInstallGuardTests.swift @@ -0,0 +1,61 @@ +// +// ClipboardInstallGuardTests.swift +// TableProTests +// +// `ClipboardService.shared` is process-wide, so a test that installs a fake and never puts the +// real one back hands that fake to every test after it. A fake whose `writeText` does nothing +// turned every later copy into an empty string, and the suites that read the copy back failed +// on main for as long as the leak ran first. +// + +import Foundation +import Testing + +struct ClipboardInstallGuardTests { + @Test("Every test that installs a clipboard puts the real one back") + func everyInstallIsRestored() throws { + let offenders = try Self.unrestoredInstalls() + #expect( + offenders.isEmpty, + """ + Follow the install with `defer { ClipboardService.shared = NSPasteboardClipboardProvider() }`, \ + or keep the previous provider and restore it in a `defer`: \(offenders.sorted()) + """ + ) + } + + private static let install = "ClipboardService.shared = " + private static let restore = "defer { ClipboardService.shared = " + private static let reach = 3 + + private static func unrestoredInstalls() throws -> [String] { + let testRoot = try repoRoot().appendingPathComponent("TableProTests") + guard let enumerator = FileManager.default.enumerator( + at: testRoot, + includingPropertiesForKeys: [.isRegularFileKey] + ) else { return [] } + + var offenders: [String] = [] + for case let url as URL in enumerator + where url.pathExtension == "swift" && url.lastPathComponent != "ClipboardInstallGuardTests.swift" { + let lines = try String(contentsOf: url, encoding: .utf8).components(separatedBy: .newlines) + for (index, line) in lines.enumerated() where line.contains(install) && !line.contains(restore) { + let window = lines[max(0, index - reach) ... min(lines.count - 1, index + reach)] + guard !window.contains(where: { $0.contains(restore) }) else { continue } + offenders.append("\(url.lastPathComponent):\(index + 1)") + } + } + return offenders + } + + private static func repoRoot() throws -> URL { + var directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + for _ in 0 ..< 12 { + if FileManager.default.fileExists(atPath: directory.appendingPathComponent("project.yml").path) { + return directory + } + directory = directory.deletingLastPathComponent() + } + throw CocoaError(.fileNoSuchFile) + } +} diff --git a/TableProTests/Core/Services/RowOperationsFieldAbsenceTests.swift b/TableProTests/Core/Services/RowOperationsFieldAbsenceTests.swift index e78fa0f5a..de6547ee7 100644 --- a/TableProTests/Core/Services/RowOperationsFieldAbsenceTests.swift +++ b/TableProTests/Core/Services/RowOperationsFieldAbsenceTests.swift @@ -103,6 +103,7 @@ struct RowOperationsFieldAbsenceTests { let (manager, _) = makeManager() let clipboard = AbsenceClipboard() ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let tableRows = rows([["1", "Ada", .null], ["2", "Bo", .null]], absentCells: [1: [2]]) manager.copySelectedRowsToClipboard(selectedIndices: [0, 1], tableRows: tableRows, visibleColumnIndices: [2, 1]) diff --git a/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift index 7ccd04ca3..9073b2fb9 100644 --- a/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift @@ -9,6 +9,19 @@ import Foundation import TableProPluginKit import Testing +private final class BinaryCopyClipboard: ClipboardProvider { + var writtenRows: String? + + func readText() -> String? { nil } + func readGridRows() -> GridRowsClipboardPayload? { nil } + func writeText(_ text: String) {} + func writeCsv(_ csv: String) {} + func writeImage(_ image: NSImage) {} + func writeRows(tsv: String, html: String?, gridRows: GridRowsClipboardPayload) { writtenRows = tsv } + var hasText: Bool { false } + var hasGridRows: Bool { false } +} + @MainActor struct RowOperationsManagerBinaryCopyTests { private func makeManagerAndRows(binaryRow: [PluginCellValue]) -> (RowOperationsManager, TableRows) { @@ -29,6 +42,16 @@ struct RowOperationsManagerBinaryCopyTests { return (rowOps, tableRows) } + /// The copy goes through `ClipboardService.shared`, and the system pasteboard reads back empty + /// on a headless runner, so the text is taken from a clipboard installed for the one copy. + private func copiedText(_ rowOps: RowOperationsManager, _ tableRows: TableRows) -> String { + let clipboard = BinaryCopyClipboard() + ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } + rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) + return clipboard.writtenRows ?? "" + } + @Test("Issue #1188 row copies binary cell as 0xHEX, not NULL") func issue1188CopyAsHex() { let bytes = Data([ @@ -41,12 +64,7 @@ struct RowOperationsManagerBinaryCopyTests { ]) let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.text("1"), .bytes(bytes)]) - let pasteboard = NSPasteboard.general - pasteboard.clearContents() - - rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) - - let copied = pasteboard.string(forType: .string) ?? "" + let copied = copiedText(rowOps, tableRows) #expect(copied.contains("0xD38CE566")) #expect(!copied.contains("NULL")) #expect(copied.contains("\t")) @@ -56,9 +74,7 @@ struct RowOperationsManagerBinaryCopyTests { func emptyBytesCopiesAsZeroX() { let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.text("1"), .bytes(Data())]) - NSPasteboard.general.clearContents() - rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) - let copied = NSPasteboard.general.string(forType: .string) ?? "" + let copied = copiedText(rowOps, tableRows) #expect(copied.contains("0x") || copied.hasSuffix("\t")) #expect(!copied.contains("NULL")) @@ -68,9 +84,7 @@ struct RowOperationsManagerBinaryCopyTests { func mixedNullAndBytes() { let (rowOps, tableRows) = makeManagerAndRows(binaryRow: [.null, .bytes(Data([0xAA, 0xBB]))]) - NSPasteboard.general.clearContents() - rowOps.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) - let copied = NSPasteboard.general.string(forType: .string) ?? "" + let copied = copiedText(rowOps, tableRows) #expect(copied.contains("NULL")) #expect(copied.contains("0xAABB")) diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index ce85e1e1f..f04612fa8 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -74,6 +74,7 @@ struct RowOperationsManagerCopyTests { ) -> String? { let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let tableRows = makeTableRows(rows: rows, columns: columns ?? Self.defaultColumns) manager.copySelectedRowsToClipboard( selectedIndices: indices, @@ -139,6 +140,7 @@ struct RowOperationsManagerCopyTests { let rows = TestFixtures.makeRows(count: 3) let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let tableRows = makeTableRows(rows: rows) manager.copySelectedRowsToClipboard( @@ -285,6 +287,7 @@ struct RowOperationsManagerCopyTests { let rows: [[String?]] = [["1", "Smith, John", nil]] let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let tableRows = makeTableRows(rows: rows) manager.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows) @@ -299,6 +302,7 @@ struct RowOperationsManagerCopyTests { let rows: [[String?]] = [["1", "Alice", "alice@test.com"]] let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let tableRows = makeTableRows(rows: rows) manager.copySelectedRowsToClipboard(selectedIndices: [0], tableRows: tableRows, visibleColumnIndices: [2, 0]) @@ -318,6 +322,7 @@ struct RowOperationsManagerCopyTests { ] let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let tableRows = makeTableRows(rows: rows) let displayIDs: [RowID] = [.existing(0), .existing(2), .existing(3)] @@ -340,6 +345,7 @@ struct RowOperationsManagerCopyTests { ] let clipboard = MockClipboardProvider() ClipboardService.shared = clipboard + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let tableRows = makeTableRows(rows: rows) let displayIDs: [RowID] = [.existing(2), .existing(0)] diff --git a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift index c0cc7be74..d77b4d48f 100644 --- a/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift +++ b/TableProTests/Views/Results/Extensions/CellPasteRoutingTests.swift @@ -69,6 +69,7 @@ struct CellPasteRoutingTests { stub.text = "anything\twith\ttabs" stub.hasGridRowsValue = true ClipboardService.shared = stub + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let coordinator = makeCoordinator(columns: ["a", "b", "c"], rowCount: 5) let result = coordinator.pasteCellsFromClipboard(anchorRow: 0, anchorColumn: 0) @@ -82,6 +83,7 @@ struct CellPasteRoutingTests { stub.text = "x\ty\tz\nq\tw\te" stub.hasGridRowsValue = false ClipboardService.shared = stub + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let coordinator = makeCoordinator(columns: ["a", "b", "c"], rowCount: 5) let result = coordinator.pasteCellsFromClipboard(anchorRow: 0, anchorColumn: 0) @@ -95,6 +97,7 @@ struct CellPasteRoutingTests { stub.text = "x\ty" stub.hasGridRowsValue = false ClipboardService.shared = stub + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let coordinator = makeCoordinator(columns: ["a", "b", "c", "d", "e"], rowCount: 5) let result = coordinator.pasteCellsFromClipboard(anchorRow: 0, anchorColumn: 0) @@ -111,6 +114,7 @@ struct CellPasteRoutingTests { stub.text = "hello" stub.hasGridRowsValue = false ClipboardService.shared = stub + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let coordinator = makeCoordinator(columns: ["a", "b", "c"], rowCount: 5) @@ -124,6 +128,7 @@ struct CellPasteRoutingTests { stub.text = "hello" stub.hasGridRowsValue = true ClipboardService.shared = stub + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let coordinator = makeCoordinator(columns: ["a", "b", "c"], rowCount: 5) @@ -137,6 +142,7 @@ struct CellPasteRoutingTests { stub.text = "hello" stub.hasGridRowsValue = false ClipboardService.shared = stub + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let coordinator = makeCoordinator(columns: ["a"], rowCount: 3) @@ -149,6 +155,7 @@ struct CellPasteRoutingTests { stub.text = "x\ty" stub.hasGridRowsValue = false ClipboardService.shared = stub + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let coordinator = makeCoordinator(columns: ["a", "b", "c", "d", "e"], rowCount: 5) let before = Array(coordinator.tableRowsProvider().rows) @@ -163,6 +170,7 @@ struct CellPasteRoutingTests { let stub = StubClipboard() stub.text = "x\ty" ClipboardService.shared = stub + defer { ClipboardService.shared = NSPasteboardClipboardProvider() } let coordinator = TableViewCoordinator( changeManager: AnyChangeManager(DataChangeManager()),