|
| 1 | +# Conditionally saving attachments |
| 2 | + |
| 3 | +* Proposal: [ST-NNNN](NNNN-conditionally-saving-attachments.md) |
| 4 | +* Authors: [Jonathan Grynspan](https://github.com/grynspan) |
| 5 | +* Review Manager: TBD |
| 6 | +* Status: **Awaiting review** |
| 7 | +* Bug: [rdar://138921461](rdar://138921461) |
| 8 | +* Implementation: [swiftlang/swift-testing#1319](https://github.com/swiftlang/swift-testing/pull/1319) |
| 9 | +* Review: ([pitch](https://forums.swift.org/t/pitch-conditionally-saving-attachments-aka-attachment-lifetimes/82541)) |
| 10 | + |
| 11 | +## Introduction |
| 12 | + |
| 13 | +In [ST-0009](0009-attachments.md), we introduced [attachments](https://github.com/swiftlang/swift-evolution/blob/main/proposals/testing/0009-attachments.md) |
| 14 | +to Swift Testing in Swift 6.2. This feature allows you to create a file |
| 15 | +containing data relevant to a test. This proposal covers introducing new API to |
| 16 | +Swift Testing to allow test authors to control whether or not a test's |
| 17 | +attachments should be saved or discarded. |
| 18 | + |
| 19 | +> [!NOTE] |
| 20 | +> In this proposal, **recording** an attachment means calling |
| 21 | +> [`Attachment.record()`](https://developer.apple.com/documentation/testing/attachment/record(_:named:sourcelocation:)). |
| 22 | +> When you record an attachment, it is stored in an implementation-defined |
| 23 | +> temporary location (such as memory or `/tmp/`) until the current test function |
| 24 | +> returns. Swift Testing then determines if the attachment should be **saved**, |
| 25 | +> i.e. written to persistent storage such as a file on disk or an Xcode test |
| 26 | +> report. |
| 27 | +
|
| 28 | +## Motivation |
| 29 | + |
| 30 | +In **XCTest** on Apple platforms, you can specify the *lifetime* of an |
| 31 | +attachment by setting the [`lifetime`](https://developer.apple.com/documentation/xctest/xctattachment/lifetime-swift.property) |
| 32 | +property of an `XCTAttachment` object. This property lets you avoid serializing |
| 33 | +and saving an attachment if its data won't be necessary (e.g. "if the test |
| 34 | +passes, don't bother saving this attachment.") |
| 35 | + |
| 36 | +It is especially useful for test authors working in CI environments to be able |
| 37 | +to control whether or not their attachments are saved, especially when those |
| 38 | +attachments are large (on the order of hundreds of megabytes or more). |
| 39 | +Persistent storage may come with a real-world monetary cost, or may be limited |
| 40 | +such that their CI jobs run out of space for new attachments too quickly. |
| 41 | + |
| 42 | +The initial implementation of the attachments feature did not include this |
| 43 | +functionality, but we listed it as a future direction in [ST-0009](0009-attachments.md#future-directions). |
| 44 | +We understand the utility of [`XCTAttachment.lifetime`](https://developer.apple.com/documentation/xctest/xctattachment/lifetime-swift.property) |
| 45 | +and want to bring an analogous interface to Swift Testing. |
| 46 | + |
| 47 | +## Proposed solution |
| 48 | + |
| 49 | +I propose introducing a new test trait that can be applied to a suite or test |
| 50 | +function. This trait can be configured with a condition that determines whether |
| 51 | +or not attachments should be saved. |
| 52 | + |
| 53 | +## Detailed design |
| 54 | + |
| 55 | +A new trait type is added: |
| 56 | + |
| 57 | +```swift |
| 58 | +/// A type that defines a condition which must be satisfied for the testing |
| 59 | +/// library to save attachments recorded by a test. |
| 60 | +/// |
| 61 | +/// To add this trait to a test, use one of the following functions: |
| 62 | +/// |
| 63 | +/// - ``Trait/savingAttachments(if:)`` |
| 64 | +/// |
| 65 | +/// By default, the testing library saves your attachments as soon as you call |
| 66 | +/// ``Attachment/record(_:named:sourceLocation:)``. You can access saved |
| 67 | +/// attachments after your tests finish running: |
| 68 | +/// |
| 69 | +/// - When using Xcode, you can access attachments from the test report. |
| 70 | +/// - When using Visual Studio Code, the testing library saves attachments to |
| 71 | +/// `.build/attachments` by default. Visual Studio Code reports the paths to |
| 72 | +/// individual attachments in its Tests Results panel. |
| 73 | +/// - When using Swift Package Manager's `swift test` command, you can pass the |
| 74 | +/// `--attachments-path` option. The testing library saves attachments to the |
| 75 | +/// specified directory. |
| 76 | +/// |
| 77 | +/// If you add an instance of this trait type to a test, any attachments that |
| 78 | +/// test records are stored in memory until the test finishes running. The |
| 79 | +/// testing library then evaluates the instance's condition and, if the |
| 80 | +/// condition is met, saves the attachments. |
| 81 | +public struct AttachmentSavingTrait: TestTrait, SuiteTrait, TestScoping { |
| 82 | + /// A type that describes the conditions under which the testing library |
| 83 | + /// will save attachments. |
| 84 | + /// |
| 85 | + /// You can pass instances of this type to ``Trait/savingAttachments(if:)``. |
| 86 | + public struct Condition: Sendable { |
| 87 | + /// The testing library saves attachments if the test passes. |
| 88 | + public static var testPasses: Self { get } |
| 89 | + |
| 90 | + /// The testing library saves attachments if the test fails. |
| 91 | + public static var testFails: Self { get } |
| 92 | + |
| 93 | + /// The testing library saves attachments if the test records a matching |
| 94 | + /// issue. |
| 95 | + /// |
| 96 | + /// - Parameters: |
| 97 | + /// - issueMatcher: A function to invoke when an issue occurs that is used |
| 98 | + /// to determine if the testing library should save attachments for the |
| 99 | + /// current test. |
| 100 | + /// |
| 101 | + /// - Returns: An instance of ``AttachmentSavingTrait/Condition`` that |
| 102 | + /// evaluates `issueMatcher`. |
| 103 | + public static func testRecordsIssue( |
| 104 | + matching issueMatcher: @escaping @Sendable (_ issue: Issue) async throws -> Bool |
| 105 | + ) -> Self |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +extension Trait where Self == AttachmentSavingTrait { |
| 110 | + /// Constructs a trait that tells the testing library to only save attachments |
| 111 | + /// if a given condition is met. |
| 112 | + /// |
| 113 | + /// - Parameters: |
| 114 | + /// - condition: A condition which, when met, means that the testing library |
| 115 | + /// should save attachments that the current test has recorded. If the |
| 116 | + /// condition is not met, the testing library discards the test's |
| 117 | + /// attachments when the test ends. |
| 118 | + /// - sourceLocation: The source location of the trait. |
| 119 | + /// |
| 120 | + /// - Returns: An instance of ``AttachmentSavingTrait`` that evaluates the |
| 121 | + /// closure you provide. |
| 122 | + /// |
| 123 | + /// By default, the testing library saves your attachments as soon as you call |
| 124 | + /// ``Attachment/record(_:named:sourceLocation:)``. You can access saved |
| 125 | + /// attachments after your tests finish running: |
| 126 | + /// |
| 127 | + /// - When using Xcode, you can access attachments from the test report. |
| 128 | + /// - When using Visual Studio Code, the testing library saves attachments to |
| 129 | + /// `.build/attachments` by default. Visual Studio Code reports the paths to |
| 130 | + /// individual attachments in its Tests Results panel. |
| 131 | + /// - When using Swift Package Manager's `swift test` command, you can pass |
| 132 | + /// the `--attachments-path` option. The testing library saves attachments |
| 133 | + /// to the specified directory. |
| 134 | + /// |
| 135 | + /// If you add this trait to a test, any attachments that test records are |
| 136 | + /// stored in memory until the test finishes running. The testing library then |
| 137 | + /// evaluates `condition` and, if the condition is met, saves the attachments. |
| 138 | + public static func savingAttachments( |
| 139 | + if condition: Self.Condition, |
| 140 | + sourceLocation: SourceLocation = #_sourceLocation |
| 141 | + ) -> Self |
| 142 | + |
| 143 | + /// Constructs a trait that tells the testing library to only save attachments |
| 144 | + /// if a given condition is met. |
| 145 | + /// |
| 146 | + /// - Parameters: |
| 147 | + /// - condition: A closure that contains the trait's custom condition logic. |
| 148 | + /// If this closure returns `true`, the trait tells the testing library to |
| 149 | + /// save attachments that the current test has recorded. If this closure |
| 150 | + /// returns `false`, the testing library discards the test's attachments |
| 151 | + /// when the test ends. If this closure throws an error, the testing |
| 152 | + /// library records that error as an issue and discards the test's |
| 153 | + /// attachments. |
| 154 | + /// - sourceLocation: The source location of the trait. |
| 155 | + /// |
| 156 | + /// - Returns: An instance of ``AttachmentSavingTrait`` that evaluates the |
| 157 | + /// closure you provide. |
| 158 | + /// |
| 159 | + /// By default, the testing library saves your attachments as soon as you call |
| 160 | + /// ``Attachment/record(_:named:sourceLocation:)``. You can access saved |
| 161 | + /// attachments after your tests finish running: |
| 162 | + /// |
| 163 | + /// - When using Xcode, you can access attachments from the test report. |
| 164 | + /// - When using Visual Studio Code, the testing library saves attachments |
| 165 | + /// to `.build/attachments` by default. Visual Studio Code reports the paths |
| 166 | + /// to individual attachments in its Tests Results panel. |
| 167 | + /// - When using Swift Package Manager's `swift test` command, you can pass |
| 168 | + /// the `--attachments-path` option. The testing library saves attachments |
| 169 | + /// to the specified directory. |
| 170 | + /// |
| 171 | + /// If you add this trait to a test, any attachments that test records are |
| 172 | + /// stored in memory until the test finishes running. The testing library then |
| 173 | + /// evaluates `condition` and, if the condition is met, saves the attachments. |
| 174 | + public static func savingAttachments( |
| 175 | + if condition: @autoclosure @escaping @Sendable () throws -> Bool, |
| 176 | + sourceLocation: SourceLocation = #_sourceLocation |
| 177 | + ) -> Self |
| 178 | + |
| 179 | + /// Constructs a trait that tells the testing library to only save attachments |
| 180 | + /// if a given condition is met. |
| 181 | + /// |
| 182 | + /// - Parameters: |
| 183 | + /// - condition: A closure that contains the trait's custom condition logic. |
| 184 | + /// If this closure returns `true`, the trait tells the testing library to |
| 185 | + /// save attachments that the current test has recorded. If this closure |
| 186 | + /// returns `false`, the testing library discards the test's attachments |
| 187 | + /// when the test ends. If this closure throws an error, the testing |
| 188 | + /// library records that error as an issue and discards the test's |
| 189 | + /// attachments. |
| 190 | + /// - sourceLocation: The source location of the trait. |
| 191 | + /// |
| 192 | + /// - Returns: An instance of ``AttachmentSavingTrait`` that evaluates the |
| 193 | + /// closure you provide. |
| 194 | + /// |
| 195 | + /// By default, the testing library saves your attachments as soon as you call |
| 196 | + /// ``Attachment/record(_:named:sourceLocation:)``. You can access saved |
| 197 | + /// attachments after your tests finish running: |
| 198 | + /// |
| 199 | + /// - When using Xcode, you can access attachments from the test report. |
| 200 | + /// - When using Visual Studio Code, the testing library saves attachments |
| 201 | + /// to `.build/attachments` by default. Visual Studio Code reports the paths |
| 202 | + /// to individual attachments in its Tests Results panel. |
| 203 | + /// - When using Swift Package Manager's `swift test` command, you can pass |
| 204 | + /// the `--attachments-path` option. The testing library saves attachments |
| 205 | + /// to the specified directory. |
| 206 | + /// |
| 207 | + /// If you add this trait to a test, any attachments that test records are |
| 208 | + /// stored in memory until the test finishes running. The testing library then |
| 209 | + /// evaluates `condition` and, if the condition is met, saves the attachments. |
| 210 | + public static func savingAttachments( |
| 211 | + if condition: @escaping @Sendable () async throws -> Bool, |
| 212 | + sourceLocation: SourceLocation = #_sourceLocation |
| 213 | + ) -> Self |
| 214 | +} |
| 215 | +``` |
| 216 | + |
| 217 | +This trait can then be added to a test function using one of the listed factory |
| 218 | +functions. If added to a test suite, it is recursively applied to the test |
| 219 | +functions in that suite. |
| 220 | + |
| 221 | +If multiple traits of this type are added to a test (directly or indirectly), |
| 222 | +then _all_ their conditions must be met for the test's attachments to be saved. |
| 223 | +This behavior is consistent with existing API such as [`ConditionTrait`](https://developer.apple.com/documentation/testing/conditiontrait). |
| 224 | + |
| 225 | +### Default behavior |
| 226 | + |
| 227 | +If a test has no instance of `AttachmentSavingTrait` applied to it, then the |
| 228 | +test host environment determines whether or not attachments from that test are |
| 229 | +saved: |
| 230 | + |
| 231 | +- When using Xcode, your test plan determines if attachments are saved by |
| 232 | + default. |
| 233 | +- When using Visual Studio Code, attachments are saved to a temporary directory |
| 234 | + inside `./.build` by default. |
| 235 | +- When using `swift test`, you must pass `--attachments-path` to enable saving |
| 236 | + attachments. |
| 237 | + |
| 238 | +For example, a test author may wish to only save attachments from a particular |
| 239 | +test if the test fails: |
| 240 | + |
| 241 | +```swift |
| 242 | +@Test(.savingAttachments(if: .testFails)) |
| 243 | +func `All vowels are green`() { |
| 244 | + var vowels = "AEIOU" |
| 245 | + if Bool.random() { |
| 246 | + vowels += "Y" |
| 247 | + } |
| 248 | + Attachment.record(vowels) |
| 249 | + #expect(vowels.allSatisfy { $0.color == .green }) |
| 250 | +} |
| 251 | +``` |
| 252 | + |
| 253 | +Or a test author may wish to save attachments if an issue is recorded in a |
| 254 | +specific file: |
| 255 | + |
| 256 | +```swift |
| 257 | +extension Issue { |
| 258 | + var inCriticalFile: Bool { |
| 259 | + guard let sourceLocation else { return false } |
| 260 | + return sourceLocation.fileID.hasSuffix("Critical.swift") |
| 261 | + } |
| 262 | +} |
| 263 | + |
| 264 | +@Test(.savingAttachments(if: .testRecordsIssue { $0.inCriticalFile }) |
| 265 | +func `Ideas taste tremendous`() { ... } |
| 266 | +``` |
| 267 | + |
| 268 | +If a test author wants to conditionally save attachments based on some outside |
| 269 | +state, there are overloads of `savingAttachments(if:)` that take an autoclosure |
| 270 | +or explicit closure: |
| 271 | + |
| 272 | +```swift |
| 273 | +@Test( |
| 274 | + .savingAttachments(if: CommandLine.arguments.contains("--save--attachments")), |
| 275 | + .savingAttachments { try await CI.current.storage.available >= 500.MB } |
| 276 | +) |
| 277 | +func `The fandango is especially grim today`() { ... } |
| 278 | +``` |
| 279 | + |
| 280 | +## Source compatibility |
| 281 | + |
| 282 | +This change is additive. |
| 283 | + |
| 284 | +## Integration with supporting tools |
| 285 | + |
| 286 | +Tools that consume the JSON event stream Swift Testing produces and which |
| 287 | +already observe the `.valueAttached` event will generally not need to change. |
| 288 | +When this trait is applied to a test, those events will be delivered later than |
| 289 | +they would be without it, but still before `.testCaseEnded` for the current test |
| 290 | +case. |
| 291 | + |
| 292 | +Two new properties, `preferredName` and `bytes`, are added to the JSON structure |
| 293 | +describing an attachment when the stream's schema version is `"6.3"` or higher: |
| 294 | + |
| 295 | +- `preferredName` is a string and contains (perhaps unsurprisingly) the test |
| 296 | + author's preferred filename for the attachment. For more information about |
| 297 | + this property, see [`Attachment.preferredName`](https://developer.apple.com/documentation/testing/attachment/preferredname). |
| 298 | +- `bytes`, if present, contains the serialized representation of the attachment |
| 299 | + as either a Base64-encoded string or an array of integers (one per byte). If |
| 300 | + the existing `path` property is set, this property is optional and may be |
| 301 | + excluded. (While this property may seem redundant, it is possible for a tool |
| 302 | + to consume the JSON event stream without also setting the attachments |
| 303 | + directory path, in which case the `bytes` property is necessary to recover the |
| 304 | + attachment's serialized representation.) |
| 305 | + |
| 306 | +<!-- TODO: BNF for these properties --> |
| 307 | + |
| 308 | +## Future directions |
| 309 | + |
| 310 | +- We may wish to augment `Issue` or other types/concepts in Swift Testing to |
| 311 | + allow associating attachments with them rather than with the current test. |
| 312 | + This would likely take the form of an additional argument to |
| 313 | + [`Issue.record()`](https://developer.apple.com/documentation/testing/issue/record(_:sourcelocation:)). |
| 314 | + |
| 315 | +- Test authors may still need more fine-grained control over whether individual |
| 316 | + attachments in a test should be saved. Our expectation (no pun intended) here |
| 317 | + is that per-test granularity will be sufficient for the majority of test |
| 318 | + authors. For those test authors who need more fine-grained control, we may |
| 319 | + want to add an argument of type `AttachmentSavingTrait.Condition` to |
| 320 | + [`Attachment.record()`](https://developer.apple.com/documentation/testing/attachment/record(_:named:sourcelocation:)) |
| 321 | + or, alternatively, allow for applying the `AttachmentSavingTrait` trait to a |
| 322 | + local scope. (Locally-scoped traits are another area we're looking at for a |
| 323 | + future proposal.) |
| 324 | + |
| 325 | +- There is interest in augmenting [`ConditionTrait`](https://developer.apple.com/documentation/testing/conditiontrait) |
| 326 | + to allow for boolean operations on them. It would make sense to add such |
| 327 | + functionality to `AttachmentSavingTrait` too. |
| 328 | + |
| 329 | +## Alternatives considered |
| 330 | + |
| 331 | +- Directly mapping XCTest's [`lifetime`](https://developer.apple.com/documentation/xctest/xctattachment/lifetime-swift.property) |
| 332 | + property to Swift Testing. Swift Testing presents the opportunity to improve |
| 333 | + upon this interface in ways that don't map cleanly to Objective-C. |
| 334 | + |
| 335 | +- Adding a `shouldBeSaved: Bool` property to [`Attachment`](https://developer.apple.com/documentation/testing/attachment). |
| 336 | + While developers can create an instance of [`Attachment`](https://developer.apple.com/documentation/testing/attachment) |
| 337 | + before calling [`Attachment.record()`](https://developer.apple.com/documentation/testing/attachment/record(_:sourcelocation:)), |
| 338 | + it is typically more ergonomic to pass the attachable value directly. Thus a |
| 339 | + property on [`Attachment`](https://developer.apple.com/documentation/testing/attachment) |
| 340 | + is less accessible than other alternatives. |
| 341 | + |
| 342 | +- Adding a `save: Bool` parameter to [`Attachment.record()`](https://developer.apple.com/documentation/testing/attachment/record(_:named:sourcelocation:)). |
| 343 | + In our experience, it's frequently the case that a test author wants to |
| 344 | + conditionally save an attachment based on whether a test fails (or some other |
| 345 | + external factor) and won't know if an attachment should be saved until after |
| 346 | + they've created it. |
| 347 | + |
| 348 | +## Acknowledgments |
| 349 | + |
| 350 | +Thanks to the team for their feedback on this proposal and to the Swift |
| 351 | +community for their continued interest in Swift Testing! |
0 commit comments