diff --git a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift index 89cad3806..ea37326d5 100644 --- a/Sources/Testing/ABI/EntryPoints/EntryPoint.swift +++ b/Sources/Testing/ABI/EntryPoints/EntryPoint.swift @@ -275,6 +275,9 @@ public struct __CommandLineArguments_v0: Sendable { /// The value of the `--repeat-until` argument. public var repeatUntil: String? + + /// The value of the `--experimental-attachments-path` argument. + public var experimentalAttachmentsPath: String? } extension __CommandLineArguments_v0: Codable { @@ -295,6 +298,7 @@ extension __CommandLineArguments_v0: Codable { case skip case repetitions case repeatUntil + case experimentalAttachmentsPath } } @@ -355,6 +359,11 @@ func parseCommandLineArguments(from args: [String]) throws -> __CommandLineArgum if let xunitOutputIndex = args.firstIndex(of: "--xunit-output"), !isLastArgument(at: xunitOutputIndex) { result.xunitOutput = args[args.index(after: xunitOutputIndex)] } + + // Attachment output + if let attachmentsPathIndex = args.firstIndex(of: "--experimental-attachments-path"), !isLastArgument(at: attachmentsPathIndex) { + result.experimentalAttachmentsPath = args[args.index(after: attachmentsPathIndex)] + } #endif if args.contains("--list-tests") { @@ -464,6 +473,14 @@ public func configurationForEntryPoint(from args: __CommandLineArguments_v0) thr } } + // Attachment output. + if let attachmentsPath = args.experimentalAttachmentsPath { + guard fileExists(atPath: attachmentsPath) else { + throw _EntryPointError.invalidArgument("--experimental-attachments-path", value: attachmentsPath) + } + configuration.attachmentsPath = attachmentsPath + } + #if canImport(Foundation) // Event stream output (experimental) if let eventStreamOutputPath = args.eventStreamOutputPath { diff --git a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedAttachment.swift b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedAttachment.swift new file mode 100644 index 000000000..525f8718f --- /dev/null +++ b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedAttachment.swift @@ -0,0 +1,32 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +extension ABIv0 { + /// A type implementing the JSON encoding of ``Test/Attachment`` for the ABI + /// entry point and event stream output. + /// + /// This type is not part of the public interface of the testing library. It + /// assists in converting values to JSON; clients that consume this JSON are + /// expected to write their own decoders. + /// + /// - Warning: Attachments are not yet part of the JSON schema. + struct EncodedAttachment: Sendable { + /// The path where the attachment was written. + var path: String? + + init(encoding attachment: borrowing Test.Attachment, in eventContext: borrowing Event.Context) { + path = attachment.fileSystemPath + } + } +} + +// MARK: - Codable + +extension ABIv0.EncodedAttachment: Codable {} diff --git a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift index 65cd78234..fd9dc464a 100644 --- a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift +++ b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedEvent.swift @@ -27,6 +27,7 @@ extension ABIv0 { case testStarted case testCaseStarted case issueRecorded + case valueAttached = "_valueAttached" case testCaseEnded case testEnded case testSkipped @@ -45,6 +46,14 @@ extension ABIv0 { /// ``kind-swift.property`` property is ``Kind-swift.enum/issueRecorded``. var issue: EncodedIssue? + /// The value that was attached, if any. + /// + /// The value of this property is `nil` unless the value of the + /// ``kind-swift.property`` property is ``Kind-swift.enum/valueAttached``. + /// + /// - Warning: Attachments are not yet part of the JSON schema. + var _attachment: EncodedAttachment? + /// Human-readable messages associated with this event that can be presented /// to the user. var messages: [EncodedMessage] @@ -71,6 +80,9 @@ extension ABIv0 { case let .issueRecorded(recordedIssue): kind = .issueRecorded issue = EncodedIssue(encoding: recordedIssue, in: eventContext) + case let .valueAttached(attachment): + kind = .valueAttached + _attachment = EncodedAttachment(encoding: attachment, in: eventContext) case .testCaseEnded: if eventContext.test?.isParameterized == false { return nil diff --git a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedMessage.swift b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedMessage.swift index e67b15309..5cfbf647c 100644 --- a/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedMessage.swift +++ b/Sources/Testing/ABI/v0/Encoded/ABIv0.EncodedMessage.swift @@ -30,6 +30,7 @@ extension ABIv0 { case difference case warning case details + case attachment = "_attachment" init(encoding symbol: Event.Symbol) { self = switch symbol { @@ -51,6 +52,8 @@ extension ABIv0 { .warning case .details: .details + case .attachment: + .attachment } } } diff --git a/Sources/Testing/Attachments/Test.Attachable.swift b/Sources/Testing/Attachments/Test.Attachable.swift new file mode 100644 index 000000000..0053bec62 --- /dev/null +++ b/Sources/Testing/Attachments/Test.Attachable.swift @@ -0,0 +1,171 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@_spi(Experimental) +extension Test { + /// A protocol describing a type that can be attached to a test report or + /// written to disk when a test is run. + /// + /// To attach an attachable value to a test report or test run output, use it + /// to initialize a new instance of ``Test/Attachment``, then call + /// ``Test/Attachment/attach()``. An attachment can only be attached once. + /// + /// The testing library provides default conformances to this protocol for a + /// variety of standard library types. Most user-defined types do not need to + /// conform to this protocol. + /// + /// A type should conform to this protocol if it can be represented as a + /// sequence of bytes that would be diagnostically useful if a test fails. + public protocol Attachable: ~Copyable { + /// An estimate of the number of bytes of memory needed to store this value + /// as an attachment. + /// + /// The testing library uses this property to determine if an attachment + /// should be held in memory or should be immediately persisted to storage. + /// Larger attachments are more likely to be persisted, but the algorithm + /// the testing library uses is an implementation detail and is subject to + /// change. + /// + /// The value of this property is approximately equal to the number of bytes + /// that will actually be needed, or `nil` if the value cannot be computed + /// efficiently. The default implementation of this property returns `nil`. + /// + /// - Complexity: O(1) unless `Self` conforms to `Collection`, in which case + /// up to O(_n_) where _n_ is the length of the collection. + var estimatedAttachmentByteCount: Int? { get } + + /// Call a function and pass a buffer representing this instance to it. + /// + /// - Parameters: + /// - attachment: The attachment that is requesting a buffer (that is, the + /// attachment containing this instance.) + /// - body: A function to call. A temporary buffer containing a data + /// representation of this instance is passed to it. + /// + /// - Returns: Whatever is returned by `body`. + /// + /// - Throws: Whatever is thrown by `body`, or any error that prevented the + /// creation of the buffer. + /// + /// The testing library uses this function when writing an attachment to a + /// test report or to a file on disk. The format of the buffer is + /// implementation-defined, but should be "idiomatic" for this type: for + /// example, if this type represents an image, it would be appropriate for + /// the buffer to contain an image in PNG format, JPEG format, etc., but it + /// would not be idiomatic for the buffer to contain a textual description + /// of the image. + borrowing func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R + } +} + +// MARK: - Default implementations + +extension Test.Attachable where Self: ~Copyable { + public var estimatedAttachmentByteCount: Int? { + nil + } +} + +extension Test.Attachable where Self: Collection, Element == UInt8 { + public var estimatedAttachmentByteCount: Int? { + count + } + + // We do not provide an implementation of withUnsafeBufferPointer(for:_:) here + // because there is no way in the standard library to statically detect if a + // collection can provide contiguous storage (_HasContiguousBytes is not API.) + // If withContiguousBytesIfAvailable(_:) fails, we don't want to make a + // (potentially expensive!) copy of the collection. + // + // The planned Foundation cross-import overlay can provide a default + // implementation for collection types that conform to Foundation's + // ContiguousBytes protocol. +} + +extension Test.Attachable where Self: StringProtocol { + public var estimatedAttachmentByteCount: Int? { + // NOTE: utf8.count may be O(n) for foreign strings. + // SEE: https://github.com/swiftlang/swift/blob/main/stdlib/public/core/StringUTF8View.swift + utf8.count + } +} + +// MARK: - Default conformances + +// Implement the protocol requirements for byte arrays and buffers so that +// developers can attach raw data when needed. +@_spi(Experimental) +extension Array: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try withUnsafeBytes(body) + } +} + +@_spi(Experimental) +extension ContiguousArray: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try withUnsafeBytes(body) + } +} + +@_spi(Experimental) +extension ArraySlice: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try withUnsafeBytes(body) + } +} + +@_spi(Experimental) +extension UnsafeBufferPointer: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try body(.init(self)) + } +} + +@_spi(Experimental) +extension UnsafeMutableBufferPointer: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try body(.init(self)) + } +} + +@_spi(Experimental) +extension UnsafeRawBufferPointer: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try body(self) + } +} + +@_spi(Experimental) +extension UnsafeMutableRawBufferPointer: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try body(.init(self)) + } +} + +@_spi(Experimental) +extension String: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + var selfCopy = self + return try selfCopy.withUTF8 { utf8 in + try body(UnsafeRawBufferPointer(utf8)) + } + } +} + +@_spi(Experimental) +extension Substring: Test.Attachable { + public func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + var selfCopy = self + return try selfCopy.withUTF8 { utf8 in + try body(UnsafeRawBufferPointer(utf8)) + } + } +} diff --git a/Sources/Testing/Attachments/Test.Attachment.swift b/Sources/Testing/Attachments/Test.Attachment.swift new file mode 100644 index 000000000..68fc44ffc --- /dev/null +++ b/Sources/Testing/Attachments/Test.Attachment.swift @@ -0,0 +1,307 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +private import _TestingInternals + +@_spi(Experimental) +extension Test { + /// A type describing values that can be attached to the output of a test run + /// and inspected later by the user. + /// + /// Attachments are included in test reports in Xcode or written to disk when + /// tests are run at the command line. To create an attachment, you need a + /// value of some type that conforms to ``Test/Attachable``. Initialize an + /// instance of ``Test/Attachment`` with that value and, optionally, a + /// preferred filename to use when writing to disk. + public struct Attachment: Sendable { + /// The value of this attachment. + /// + /// The type of this property's value may not match the type of the value + /// originally used to create this attachment. + public var attachableValue: any Attachable & Sendable /* & Copyable rdar://137614425 */ + + /// The source location where the attachment was initialized. + /// + /// The value of this property is used when recording issues associated with + /// the attachment. + public var sourceLocation: SourceLocation + + /// The default preferred name to use if the developer does not supply one. + package static var defaultPreferredName: String { + "untitled" + } + + /// The path to which the this attachment was written, if any. + /// + /// If a developer sets the ``Configuration/attachmentsPath`` property of + /// the current configuration before running tests, or if a developer passes + /// `--experimental-attachments-path` on the command line, then attachments + /// will be automatically written to disk when they are attached and the + /// value of this property will describe the path where they were written. + /// + /// If no destination path is set, or if an error occurred while writing + /// this attachment to disk, the value of this property is `nil`. + @_spi(ForToolsIntegrationOnly) + public var fileSystemPath: String? + + /// Initialize an instance of this type that encloses the given attachable + /// value. + /// + /// - Parameters: + /// - attachableValue: The value that will be attached to the output of + /// the test run. + /// - preferredName: The preferred name of the attachment when writing it + /// to a test report or to disk. If `nil`, the testing library attempts + /// to derive a reasonable filename for the attached value. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + public init( + _ attachableValue: some Attachable & Sendable & Copyable, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) { + self.attachableValue = attachableValue + self.preferredName = preferredName ?? Self.defaultPreferredName + self.sourceLocation = sourceLocation + } + + /// A filename to use when writing this attachment to a test report or to a + /// file on disk. + /// + /// The value of this property is used as a hint to the testing library. The + /// testing library may substitute a different filename as needed. If the + /// value of this property has not been explicitly set, the testing library + /// will attempt to generate its own value. + public var preferredName: String + } +} + +// MARK: - + +extension Test.Attachment { + /// Attach this instance to the current test. + /// + /// An attachment can only be attached once. + public consuming func attach() { + Event.post(.valueAttached(self)) + } +} + +// MARK: - Non-sendable and move-only attachments + +/// A type that stands in for an attachable type that is not also sendable. +private struct _AttachableProxy: Test.Attachable, Sendable { + /// The result of `withUnsafeBufferPointer(for:_:)` from the original + /// attachable value. + var encodedValue = [UInt8]() + + var estimatedAttachmentByteCount: Int? + + func withUnsafeBufferPointer(for attachment: borrowing Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + try encodedValue.withUnsafeBufferPointer(for: attachment, body) + } +} + +extension Test.Attachment { + /// Initialize an instance of this type that encloses the given attachable + /// value. + /// + /// - Parameters: + /// - attachableValue: The value that will be attached to the output of + /// the test run. + /// - preferredName: The preferred name of the attachment when writing it + /// to a test report or to disk. If `nil`, the testing library attempts + /// to derive a reasonable filename for the attached value. + /// - sourceLocation: The source location of the call to this initializer. + /// This value is used when recording issues associated with the + /// attachment. + /// + /// When attaching a value of a type that does not conform to both `Sendable` + /// and `Copyable`, the testing library encodes it as data immediately. If the + /// value cannot be encoded and an error is thrown, that error is recorded as + /// an issue in the current test and the resulting instance of + /// ``Test/Attachment`` is empty. + @_disfavoredOverload + public init( + _ attachableValue: borrowing some Test.Attachable & ~Copyable, + named preferredName: String? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) { + var proxyAttachable = _AttachableProxy() + proxyAttachable.estimatedAttachmentByteCount = attachableValue.estimatedAttachmentByteCount + + // BUG: the borrow checker thinks that withErrorRecording() is consuming + // attachableValue, so get around it with an additional do/catch clause. + do { + let proxyAttachment = Self(proxyAttachable, named: preferredName, sourceLocation: sourceLocation) + proxyAttachable.encodedValue = try attachableValue.withUnsafeBufferPointer(for: proxyAttachment) { buffer in + [UInt8](buffer) + } + proxyAttachable.estimatedAttachmentByteCount = proxyAttachable.encodedValue.count + } catch { + Issue.withErrorRecording(at: sourceLocation) { + // TODO: define new issue kind .valueAttachmentFailed(any Error) + // (but only use it if the caught error isn't ExpectationFailedError, + // SystemError, or APIMisuseError. We need a protocol for these things.) + throw error + } + } + + self.init(proxyAttachable, named: preferredName, sourceLocation: sourceLocation) + } +} + +#if !SWT_NO_FILE_IO +// MARK: - Writing + +extension Test.Attachment { + /// Write the attachment's contents to a file in the specified directory. + /// + /// - Parameters: + /// - directoryPath: The directory that should contain the attachment when + /// written. + /// + /// - Throws: Any error preventing writing the attachment. + /// + /// - Returns: The path to the file that was written. + /// + /// The attachment is written to a file _within_ `directoryPath`, whose name + /// is derived from the value of the ``Test/Attachment/preferredName`` + /// property. + /// + /// If you pass `--experimental-attachments-path` to `swift test`, the testing + /// library automatically uses this function to persist attachments to the + /// directory you specify. + /// + /// This function does not get or set the value of the attachment's + /// ``fileSystemPath`` property. The caller is responsible for setting the + /// value of this property if needed. + /// + /// This function is provided as a convenience to allow tools authors to write + /// attachments to persistent storage the same way that Swift Package Manager + /// does. You are not required to use this function. + @_spi(ForToolsIntegrationOnly) + public func write(toFileInDirectoryAtPath directoryPath: String) throws -> String { + try write( + toFileInDirectoryAtPath: directoryPath, + appending: String(UInt64.random(in: 0 ..< .max), radix: 36) + ) + } + + /// Write the attachment's contents to a file in the specified directory. + /// + /// - Parameters: + /// - directoryPath: The directory to which the attachment should be + /// written. + /// - usingPreferredName: Whether or not to use the attachment's preferred + /// name. If `false`, ``defaultPreferredName`` is used instead. + /// - suffix: A suffix to attach to the file name (instead of randomly + /// generating one.) This value may be evaluated multiple times. + /// + /// - Throws: Any error preventing writing the attachment. + /// + /// - Returns: The path to the file that was written. + /// + /// The attachment is written to a file _within_ `directoryPath`, whose name + /// is derived from the value of the ``Test/Attachment/preferredName`` + /// property and the value of `suffix`. + /// + /// If the argument `suffix` always produces the same string, the result of + /// this function is undefined. + func write(toFileInDirectoryAtPath directoryPath: String, usingPreferredName: Bool = true, appending suffix: @autoclosure () -> String) throws -> String { + let result: String + + let preferredName = usingPreferredName ? preferredName : Self.defaultPreferredName + + var file: FileHandle? + do { + // First, attempt to create the file with the exact preferred name. If a + // file exists at this path (note "x" in the mode string), an error will + // be thrown and we'll try again by adding a suffix. + let preferredPath = appendPathComponent(preferredName, to: directoryPath) + file = try FileHandle(atPath: preferredPath, mode: "wxb") + result = preferredPath + } catch { + // Split the extension(s) off the preferred name. The first component in + // the resulting array is our base name. + var preferredNameComponents = preferredName.split(separator: ".") + let firstPreferredNameComponent = preferredNameComponents[0] + + while true { + preferredNameComponents[0] = "\(firstPreferredNameComponent)-\(suffix())" + let preferredName = preferredNameComponents.joined(separator: ".") + let preferredPath = appendPathComponent(preferredName, to: directoryPath) + + // Propagate any error *except* EEXIST, which would indicate that the + // name was already in use (so we should try again with a new suffix.) + do { + file = try FileHandle(atPath: preferredPath, mode: "wxb") + result = preferredPath + break + } catch let error as CError where error.rawValue == EEXIST { + // Try again with a new suffix. + continue + } catch where usingPreferredName { + // Try again with the default name before giving up. + return try write(toFileInDirectoryAtPath: directoryPath, usingPreferredName: false, appending: suffix()) + } + } + } + + try attachableValue.withUnsafeBufferPointer(for: self) { buffer in + try file!.write(buffer) + } + + return result + } +} + +extension Configuration { + /// Handle the given "value attached" event. + /// + /// - Parameters: + /// - event: The event to handle. This event must be of kind + /// ``Event/Kind/valueAttached(_:)``. If the associated attachment's + /// ``Test/Attachment/fileSystemPath`` property is not `nil`, this + /// function does nothing. + /// - context: The context associated with the event. + /// + /// This function is called automatically by ``handleEvent(_:in:)``. You do + /// not need to call it elsewhere. It automatically persists the attachment + /// associated with `event` and modifies `event` to include the path where the + /// attachment was stored. + func handleValueAttachedEvent(_ event: inout Event, in eventContext: borrowing Event.Context) { + guard let attachmentsPath else { + // If there is no path to which attachments should be written, there's + // nothing to do. + return + } + + guard case let .valueAttached(attachment) = event.kind else { + preconditionFailure("Passed the wrong kind of event to \(#function) (expected valueAttached, got \(event.kind)). Please file a bug report at https://github.com/swiftlang/swift-testing/issues/new") + } + if attachment.fileSystemPath != nil { + // Somebody already persisted this attachment. This isn't necessarily a + // logic error in the testing library, but it probably means we shouldn't + // persist it again. + return + } + + // Write the attachment. If an error occurs, record it as an issue in the + // current test. + Issue.withErrorRecording(at: attachment.sourceLocation, configuration: self) { + var attachment = attachment + attachment.fileSystemPath = try attachment.write(toFileInDirectoryAtPath: attachmentsPath) + event.kind = .valueAttached(attachment) + } + } +} +#endif diff --git a/Sources/Testing/CMakeLists.txt b/Sources/Testing/CMakeLists.txt index 7faa0186e..12efce2b2 100644 --- a/Sources/Testing/CMakeLists.txt +++ b/Sources/Testing/CMakeLists.txt @@ -13,6 +13,7 @@ add_library(Testing ABI/v0/ABIv0.Record.swift ABI/v0/ABIv0.Record+Streaming.swift ABI/v0/ABIv0.swift + ABI/v0/Encoded/ABIv0.EncodedAttachment.swift ABI/v0/Encoded/ABIv0.EncodedBacktrace.swift ABI/v0/Encoded/ABIv0.EncodedError.swift ABI/v0/Encoded/ABIv0.EncodedEvent.swift @@ -20,6 +21,8 @@ add_library(Testing ABI/v0/Encoded/ABIv0.EncodedIssue.swift ABI/v0/Encoded/ABIv0.EncodedMessage.swift ABI/v0/Encoded/ABIv0.EncodedTest.swift + Attachments/Test.Attachable.swift + Attachments/Test.Attachment.swift Events/Clock.swift Events/Event.swift Events/Recorder/Event.ConsoleOutputRecorder.swift diff --git a/Sources/Testing/Events/Event.swift b/Sources/Testing/Events/Event.swift index ffd55b5dd..54f4eea31 100644 --- a/Sources/Testing/Events/Event.swift +++ b/Sources/Testing/Events/Event.swift @@ -98,6 +98,13 @@ public struct Event: Sendable { /// - issue: The issue which was recorded. indirect case issueRecorded(_ issue: Issue) + /// An attachment was created. + /// + /// - Parameters: + /// - attachment: The attachment that was created. + @_spi(Experimental) + indirect case valueAttached(_ attachment: Test.Attachment) + /// A test ended. /// /// The test that ended is contained in the ``Event/Context`` instance that @@ -416,6 +423,9 @@ extension Event.Kind { /// - issue: The issue which was recorded. indirect case issueRecorded(_ issue: Issue.Snapshot) + /// An attachment was created. + case valueAttached + /// A test ended. case testEnded @@ -475,6 +485,8 @@ extension Event.Kind { self = Snapshot.expectationChecked(expectationSnapshot) case let .issueRecorded(issue): self = .issueRecorded(Issue.Snapshot(snapshotting: issue)) + case .valueAttached: + self = .valueAttached case .testEnded: self = .testEnded case let .testSkipped(skipInfo): diff --git a/Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift index 1f44de23a..a0a6a8ee3 100644 --- a/Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift +++ b/Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift @@ -166,6 +166,8 @@ extension Event.Symbol { return "\(_ansiEscapeCodePrefix)91m\(symbolCharacter)\(_resetANSIEscapeCode)" case .warning: return "\(_ansiEscapeCodePrefix)93m\(symbolCharacter)\(_resetANSIEscapeCode)" + case .attachment: + return "\(_ansiEscapeCodePrefix)94m\(symbolCharacter)\(_resetANSIEscapeCode)" case .details: return symbolCharacter } diff --git a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift index ad4b79b8e..ab1f56702 100644 --- a/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift +++ b/Sources/Testing/Events/Recorder/Event.HumanReadableOutputRecorder.swift @@ -459,6 +459,23 @@ extension Event.HumanReadableOutputRecorder { } return CollectionOfOne(primaryMessage) + additionalMessages + case let .valueAttached(attachment): + var result = [ + Message( + symbol: .attachment, + stringValue: "Attached '\(attachment.preferredName)' to \(testName)." + ) + ] + if let path = attachment.fileSystemPath { + result.append( + Message( + symbol: .details, + stringValue: "Written to '\(path)'." + ) + ) + } + return result + case .testCaseStarted: guard let testCase = eventContext.testCase, testCase.isParameterized else { break diff --git a/Sources/Testing/Events/Recorder/Event.Symbol.swift b/Sources/Testing/Events/Recorder/Event.Symbol.swift index 6bec7eb13..32acc8378 100644 --- a/Sources/Testing/Events/Recorder/Event.Symbol.swift +++ b/Sources/Testing/Events/Recorder/Event.Symbol.swift @@ -38,6 +38,10 @@ extension Event { /// The symbol to use when presenting details about an event to the user. case details + + /// The symbol to use when describing an instance of ``Test/Attachment``. + @_spi(Experimental) + case attachment } } @@ -66,6 +70,8 @@ extension Event.Symbol { ("\u{1001FF}", "exclamationmark.triangle.fill") case .details: ("\u{100135}", "arrow.turn.down.right") + case .attachment: + ("\u{100237}", "doc") } } @@ -128,6 +134,10 @@ extension Event.Symbol { case .details: // Unicode: DOWNWARDS ARROW WITH TIP RIGHTWARDS return "\u{21B3}" + case .attachment: + // TODO: decide on symbol + // Unicode: PRINT SCREEN SYMBOL + return "\u{2399}" } #elseif os(Windows) // The default Windows console font (Consolas) has limited Unicode support, @@ -159,6 +169,10 @@ extension Event.Symbol { case .details: // Unicode: RIGHTWARDS ARROW return "\u{2192}" + case .attachment: + // TODO: decide on symbol + // Unicode: PRINT SCREEN SYMBOL + return "\u{2399}" } #else #warning("Platform-specific implementation missing: Unicode characters unavailable") diff --git a/Sources/Testing/Running/Configuration+EventHandling.swift b/Sources/Testing/Running/Configuration+EventHandling.swift index 95febe085..03931b790 100644 --- a/Sources/Testing/Running/Configuration+EventHandling.swift +++ b/Sources/Testing/Running/Configuration+EventHandling.swift @@ -23,6 +23,15 @@ extension Configuration { var contextCopy = copy context contextCopy.configuration = self contextCopy.configuration?.eventHandler = { _, _ in } - eventHandler(event, contextCopy) + +#if !SWT_NO_FILE_IO + if case .valueAttached = event.kind { + var eventCopy = copy event + handleValueAttachedEvent(&eventCopy, in: context) + return eventHandler(eventCopy, contextCopy) + } +#endif + + return eventHandler(event, contextCopy) } } diff --git a/Sources/Testing/Running/Configuration.swift b/Sources/Testing/Running/Configuration.swift index 89cb93a07..786856e10 100644 --- a/Sources/Testing/Running/Configuration.swift +++ b/Sources/Testing/Running/Configuration.swift @@ -204,6 +204,33 @@ public struct Configuration: Sendable { } #endif +#if !SWT_NO_FILE_IO + /// Storage for ``attachmentsPath``. + private var _attachmentsPath: String? + + /// The path to which attachments should be written. + /// + /// By default, attachments are not written to disk when they are created. If + /// the value of this property is not `nil`, then when an attachment is + /// created and attached to a test, it will automatically be written to a file + /// in this directory. + /// + /// The value of this property must refer to a directory on the local file + /// system that already exists and which the current user can write to. If it + /// is a relative path, it is resolved to an absolute path automatically. + @_spi(Experimental) + public var attachmentsPath: String? { + get { + _attachmentsPath + } + set { + _attachmentsPath = newValue.map { newValue in + canonicalizePath(newValue) ?? newValue + } + } + } +#endif + /// How verbose human-readable output should be. /// /// When the value of this property is greater than `0`, additional output diff --git a/Sources/Testing/Support/FileHandle.swift b/Sources/Testing/Support/FileHandle.swift index e7b75e57a..5a58589db 100644 --- a/Sources/Testing/Support/FileHandle.swift +++ b/Sources/Testing/Support/FileHandle.swift @@ -580,4 +580,85 @@ func appendPathComponent(_ pathComponent: String, to path: String) -> String { "\(path)/\(pathComponent)" #endif } + +/// Check if a file exists at a given path. +/// +/// - Parameters: +/// - path: The path to check. +/// +/// - Returns: Whether or not the path `path` exists on disk. +func fileExists(atPath path: String) -> Bool { +#if os(Windows) + path.withCString(encodedAs: UTF16.self) { path in + PathFileExistsW(path) + } +#else + 0 == access(path, F_OK) +#endif +} + +/// Resolve a relative path or a path containing symbolic links to a canonical +/// absolute path. +/// +/// - Parameters: +/// - path: The path to resolve. +/// +/// - Returns: A fully resolved copy of `path`. If `path` is already fully +/// resolved, the resulting string may differ slightly but refers to the same +/// file system object. If the path could not be resolved, returns `nil`. +func canonicalizePath(_ path: String) -> String? { +#if SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(Android) || os(WASI) + path.withCString { path in + if let resolvedCPath = realpath(path, nil) { + defer { + free(resolvedCPath) + } + return String(validatingCString: resolvedCPath) + } + return nil + } +#elseif os(Windows) + path.withCString(encodedAs: UTF16.self) { path in + if let resolvedCPath = _wfullpath(nil, path, 0) { + defer { + free(resolvedCPath) + } + return String.decodeCString(resolvedCPath, as: UTF16.self)?.result + } + return nil + } +#else +#warning("Platform-specific implementation missing: cannot resolve paths") + return nil +#endif +} + +/// Get the path to the user's or system's temporary directory. +/// +/// - Returns: The path to a directory suitable for storing temporary files. +/// +/// - Throws: If the user's or system's temporary directory could not be +/// determined. +func temporaryDirectoryPath() throws -> String { +#if SWT_TARGET_OS_APPLE + try withUnsafeTemporaryAllocation(of: CChar.self, capacity: Int(PATH_MAX)) { buffer in + if 0 != confstr(_CS_DARWIN_USER_TEMP_DIR, buffer.baseAddress, buffer.count) { + return String(cString: buffer.baseAddress!) + } + return try #require(Environment.variable(named: "TMPDIR")) + } +#elseif os(Linux) || os(FreeBSD) + "/tmp" +#elseif os(Android) + Environment.variable(named: "TMPDIR") ?? "/data/local/tmp" +#elseif os(Windows) + try withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: Int(MAX_PATH + 1)) { buffer in + // NOTE: GetTempPath2W() was introduced in Windows 10 Build 20348. + if 0 == GetTempPathW(DWORD(buffer.count), buffer.baseAddress) { + throw Win32Error(rawValue: GetLastError()) + } + return try #require(String.decodeCString(buffer.baseAddress, as: UTF16.self)?.result) + } +#endif +} #endif diff --git a/Tests/TestingTests/AttachmentTests.swift b/Tests/TestingTests/AttachmentTests.swift new file mode 100644 index 000000000..a16c8c18f --- /dev/null +++ b/Tests/TestingTests/AttachmentTests.swift @@ -0,0 +1,319 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2023 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for Swift project authors +// + +@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing +private import _TestingInternals + +@Suite("Attachment Tests") +struct AttachmentTests { + @Test func saveValue() { + let attachableValue = MyAttachable(string: "") + let attachment = Test.Attachment(attachableValue, named: "AttachmentTests.saveValue.html") + attachment.attach() + } + +#if !SWT_NO_FILE_IO + func compare(_ attachableValue: borrowing MySendableAttachable, toContentsOfFileAtPath filePath: String) throws { + let file = try FileHandle(forReadingAtPath: filePath) + let bytes = try file.readToEnd() + + let decodedValue = if #available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) { + try #require(String(validating: bytes, as: UTF8.self)) + } else { + String(decoding: bytes, as: UTF8.self) + } + #expect(decodedValue == attachableValue.string) + } + + @Test func writeAttachment() throws { + let attachableValue = MySendableAttachable(string: "") + let attachment = Test.Attachment(attachableValue, named: "loremipsum.html") + + // Write the attachment to disk, then read it back. + let filePath = try attachment.write(toFileInDirectoryAtPath: temporaryDirectoryPath()) + defer { + remove(filePath) + } + try compare(attachableValue, toContentsOfFileAtPath: filePath) + } + + @Test func writeAttachmentWithNameConflict() throws { + // A sequence of suffixes that are guaranteed to cause conflict. + let randomBaseValue = UInt64.random(in: 0 ..< (.max - 10)) + var suffixes = (randomBaseValue ..< randomBaseValue + 10).lazy + .flatMap { [$0, $0, $0] } + .map { String($0, radix: 36) } + .makeIterator() + let baseFileName = "\(UInt64.random(in: 0 ..< .max))loremipsum.html" + var createdFilePaths = [String]() + defer { + for filePath in createdFilePaths { + remove(filePath) + } + } + + for i in 0 ..< 5 { + let attachableValue = MySendableAttachable(string: "\(i)") + let attachment = Test.Attachment(attachableValue, named: baseFileName) + + // Write the attachment to disk, then read it back. + let filePath = try attachment.write(toFileInDirectoryAtPath: temporaryDirectoryPath(), appending: suffixes.next()!) + createdFilePaths.append(filePath) + let fileName = try #require(filePath.split { $0 == "/" || $0 == #"\"# }.last) + if i == 0 { + #expect(fileName == baseFileName) + } else { + #expect(fileName != baseFileName) + } + try compare(attachableValue, toContentsOfFileAtPath: filePath) + } + } + + @Test func writeAttachmentWithMultiplePathExtensions() throws { + let attachableValue = MySendableAttachable(string: "") + let attachment = Test.Attachment(attachableValue, named: "loremipsum.tar.gz.gif.jpeg.html") + + // Write the attachment to disk once to ensure the original filename is not + // available and we add a suffix. + let originalFilePath = try attachment.write(toFileInDirectoryAtPath: temporaryDirectoryPath()) + defer { + remove(originalFilePath) + } + + // Write the attachment to disk, then read it back. + let suffix = String(UInt64.random(in: 0 ..< .max), radix: 36) + let filePath = try attachment.write(toFileInDirectoryAtPath: temporaryDirectoryPath(), appending: suffix) + defer { + remove(filePath) + } + let fileName = try #require(filePath.split { $0 == "/" || $0 == #"\"# }.last) + #expect(fileName == "loremipsum-\(suffix).tar.gz.gif.jpeg.html") + try compare(attachableValue, toContentsOfFileAtPath: filePath) + } + +#if os(Windows) + static let maximumNameCount = Int(_MAX_FNAME) + static let reservedNames = ["CON", "COM0", "LPT2"] +#else + static let maximumNameCount = Int(NAME_MAX) + static let reservedNames: [String] = [] +#endif + + @Test(arguments: [ + #"/\:"#, + String(repeating: "a", count: maximumNameCount), + String(repeating: "a", count: maximumNameCount + 1), + String(repeating: "a", count: maximumNameCount + 2), + ] + reservedNames) func writeAttachmentWithBadName(name: String) throws { + let attachableValue = MySendableAttachable(string: "") + let attachment = Test.Attachment(attachableValue, named: name) + + // Write the attachment to disk, then read it back. + let filePath = try attachment.write(toFileInDirectoryAtPath: temporaryDirectoryPath()) + defer { + remove(filePath) + } + try compare(attachableValue, toContentsOfFileAtPath: filePath) + } + + @Test func fileSystemPathIsSetAfterWritingViaEventHandler() async throws { + var configuration = Configuration() + configuration.attachmentsPath = try temporaryDirectoryPath() + + let attachableValue = MySendableAttachable(string: "") + + await confirmation("Attachment detected") { valueAttached in + await Test { + let attachment = Test.Attachment(attachableValue, named: "loremipsum.html") + attachment.attach() + }.run(configuration: configuration) { event, _ in + guard case let .valueAttached(attachment) = event.kind else { + return + } + valueAttached() + + // BUG: We could use #expect(throws: Never.self) here, but the Swift 6.1 + // compiler crashes trying to expand the macro (rdar://138997009) + do { + let filePath = try #require(attachment.fileSystemPath) + defer { + remove(filePath) + } + try compare(attachableValue, toContentsOfFileAtPath: filePath) + } catch { + Issue.record(error) + } + } + } + } +#endif + + @Test func attachValue() async { + await confirmation("Attachment detected") { valueAttached in + await Test { + let attachableValue = MyAttachable(string: "") + Test.Attachment(attachableValue, named: "loremipsum").attach() + }.run { event, _ in + guard case let .valueAttached(attachment) = event.kind else { + return + } + + #expect(attachment.preferredName == "loremipsum") + valueAttached() + } + } + } + + @Test func attachSendableValue() async { + await confirmation("Attachment detected") { valueAttached in + await Test { + let attachableValue = MySendableAttachable(string: "") + Test.Attachment(attachableValue, named: "loremipsum").attach() + }.run { event, _ in + guard case let .valueAttached(attachment) = event.kind else { + return + } + + #expect(attachment.preferredName == "loremipsum") + valueAttached() + } + } + } + + @Test func issueRecordedWhenAttachingNonSendableValueThatThrows() async { + await confirmation("Attachment detected") { valueAttached in + await confirmation("Issue recorded") { issueRecorded in + await Test { + var attachableValue = MyAttachable(string: "") + attachableValue.errorToThrow = MyError() + Test.Attachment(attachableValue, named: "loremipsum").attach() + }.run { event, _ in + if case .valueAttached = event.kind { + valueAttached() + } else if case let .issueRecorded(issue) = event.kind, + case let .errorCaught(error) = issue.kind, + error is MyError { + issueRecorded() + } + } + } + } + } +} + +extension AttachmentTests { + @Suite("Built-in conformances") + struct BuiltInConformances { + func test(_ value: borrowing some Test.Attachable & ~Copyable) throws { + #expect(value.estimatedAttachmentByteCount == 6) + let attachment = Test.Attachment(value) + try attachment.attachableValue.withUnsafeBufferPointer(for: attachment) { buffer in + #expect(buffer.elementsEqual("abc123".utf8)) + #expect(buffer.count == 6) + } + } + + @Test func uint8Array() throws { + let value: [UInt8] = Array("abc123".utf8) + try test(value) + } + + @Test func uint8ContiguousArray() throws { + let value: ContiguousArray = ContiguousArray("abc123".utf8) + try test(value) + } + + @Test func uint8ArraySlice() throws { + let value: ArraySlice = Array("abc123".utf8)[...] + try test(value) + } + + @Test func uint8UnsafeBufferPointer() throws { + let value: [UInt8] = Array("abc123".utf8) + try value.withUnsafeBufferPointer { value in + try test(value) + } + } + + @Test func uint8UnsafeMutableBufferPointer() throws { + var value: [UInt8] = Array("abc123".utf8) + try value.withUnsafeMutableBufferPointer { value in + try test(value) + } + } + + @Test func unsafeRawBufferPointer() throws { + let value: [UInt8] = Array("abc123".utf8) + try value.withUnsafeBytes { value in + try test(value) + } + } + + @Test func unsafeMutableRawBufferPointer() throws { + var value: [UInt8] = Array("abc123".utf8) + try value.withUnsafeMutableBytes { value in + try test(value) + } + } + + @Test func string() throws { + let value = "abc123" + try test(value) + } + + @Test func substring() throws { + let value: Substring = "abc123"[...] + try test(value) + } + } +} + +// MARK: - Fixtures + +struct MyAttachable: Test.Attachable, ~Copyable { + var string: String + var errorToThrow: (any Error)? + + func withUnsafeBufferPointer(for attachment: borrowing Testing.Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + if let errorToThrow { + throw errorToThrow + } + + var string = string + return try string.withUTF8 { buffer in + try body(.init(buffer)) + } + } +} + +@available(*, unavailable) +extension MyAttachable: Sendable {} + +struct MySendableAttachable: Test.Attachable, Sendable { + var string: String + + func withUnsafeBufferPointer(for attachment: borrowing Testing.Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + var string = string + return try string.withUTF8 { buffer in + try body(.init(buffer)) + } + } +} + +struct MySendableAttachableWithDefaultByteCount: Test.Attachable, Sendable { + var string: String + + func withUnsafeBufferPointer(for attachment: borrowing Testing.Test.Attachment, _ body: (UnsafeRawBufferPointer) throws -> R) throws -> R { + var string = string + return try string.withUTF8 { buffer in + try body(.init(buffer)) + } + } +} diff --git a/Tests/TestingTests/Support/FileHandleTests.swift b/Tests/TestingTests/Support/FileHandleTests.swift index c7f347357..7d8b3e817 100644 --- a/Tests/TestingTests/Support/FileHandleTests.swift +++ b/Tests/TestingTests/Support/FileHandleTests.swift @@ -215,7 +215,7 @@ func withTemporaryPath(_ body: (_ path: String) throws -> R) throws -> R { return strnlen(buffer.baseAddress!, buffer.count) } #else - let path = appendPathComponent("file_named_\(UInt64.random(in: 0 ..< .max))", to: try temporaryDirectory()) + let path = appendPathComponent("file_named_\(UInt64.random(in: 0 ..< .max))", to: try temporaryDirectoryPath()) #endif defer { _ = remove(path) @@ -247,29 +247,6 @@ extension FileHandle { } #endif -func temporaryDirectory() throws -> String { -#if SWT_TARGET_OS_APPLE - try withUnsafeTemporaryAllocation(of: CChar.self, capacity: Int(PATH_MAX)) { buffer in - if 0 != confstr(_CS_DARWIN_USER_TEMP_DIR, buffer.baseAddress, buffer.count) { - return String(cString: buffer.baseAddress!) - } - return try #require(Environment.variable(named: "TMPDIR")) - } -#elseif os(Linux) || os(FreeBSD) - "/tmp" -#elseif os(Android) - Environment.variable(named: "TMPDIR") ?? "/data/local/tmp" -#elseif os(Windows) - try withUnsafeTemporaryAllocation(of: wchar_t.self, capacity: Int(MAX_PATH + 1)) { buffer in - // NOTE: GetTempPath2W() was introduced in Windows 10 Build 20348. - if 0 == GetTempPathW(DWORD(buffer.count), buffer.baseAddress) { - throw Win32Error(rawValue: GetLastError()) - } - return try #require(String.decodeCString(buffer.baseAddress, as: UTF16.self)?.result) - } -#endif -} - #if SWT_TARGET_OS_APPLE func fileHandleForCloseMonitoring(with confirmation: Confirmation) throws -> FileHandle { let context = Unmanaged.passRetained(confirmation as AnyObject).toOpaque() diff --git a/Tests/TestingTests/SwiftPMTests.swift b/Tests/TestingTests/SwiftPMTests.swift index 6b114efe6..3f9275e3b 100644 --- a/Tests/TestingTests/SwiftPMTests.swift +++ b/Tests/TestingTests/SwiftPMTests.swift @@ -176,7 +176,7 @@ struct SwiftPMTests { func xunitOutputIsWrittenToFile() throws { // Test that a file is opened when requested. Testing of the actual output // occurs in ConsoleOutputRecorderTests. - let tempDirPath = try temporaryDirectory() + let tempDirPath = try temporaryDirectoryPath() let temporaryFilePath = appendPathComponent("\(UInt64.random(in: 0 ..< .max))", to: tempDirPath) defer { _ = remove(temporaryFilePath) @@ -200,7 +200,7 @@ struct SwiftPMTests { "--configuration-path", "--experimental-configuration-path", ]) func configurationPath(argumentName: String) async throws { - let tempDirPath = try temporaryDirectory() + let tempDirPath = try temporaryDirectoryPath() let temporaryFilePath = appendPathComponent("\(UInt64.random(in: 0 ..< .max))", to: tempDirPath) defer { _ = remove(temporaryFilePath) @@ -244,7 +244,7 @@ struct SwiftPMTests { func eventStreamOutput(outputArgumentName: String, versionArgumentName: String, version: String) async throws { // Test that JSON records are successfully streamed to a file and can be // read back into memory and decoded. - let tempDirPath = try temporaryDirectory() + let tempDirPath = try temporaryDirectoryPath() let temporaryFilePath = appendPathComponent("\(UInt64.random(in: 0 ..< .max))", to: tempDirPath) defer { _ = remove(temporaryFilePath) diff --git a/Tests/TestingTests/TestSupport/TestingAdditions.swift b/Tests/TestingTests/TestSupport/TestingAdditions.swift index 0f0d4641a..a253d6b55 100644 --- a/Tests/TestingTests/TestSupport/TestingAdditions.swift +++ b/Tests/TestingTests/TestSupport/TestingAdditions.swift @@ -237,7 +237,14 @@ extension Test { /// runs it. It is provided as a convenience for use in the testing library's /// own test suite; when writing tests for other test suites, it should not be /// necessary to call this function. - func run(configuration: Configuration = .init()) async { + func run(configuration: Configuration = .init(), eventHandler: Event.Handler? = nil) async { + var configuration = configuration + if let eventHandler { + configuration.eventHandler = { [oldEventHandler = configuration.eventHandler] event, eventContext in + eventHandler(event, eventContext) + oldEventHandler(event, eventContext) + } + } let runner = await Runner(testing: [self], configuration: configuration) await runner.run() } diff --git a/Tests/TestingTests/Traits/TagListTests.swift b/Tests/TestingTests/Traits/TagListTests.swift index 29b8e3909..f81564713 100644 --- a/Tests/TestingTests/Traits/TagListTests.swift +++ b/Tests/TestingTests/Traits/TagListTests.swift @@ -121,7 +121,7 @@ struct TagListTests { #if !SWT_NO_FILE_IO @Test("Colors are read from disk") func tagColorsReadFromDisk() throws { - let tempDirPath = try temporaryDirectory() + let tempDirPath = try temporaryDirectoryPath() let jsonPath = appendPathComponent("tag-colors.json", to: tempDirPath) var jsonContent = """ {