diff --git a/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift b/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift index 8c496d8e6..74932085c 100644 --- a/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift +++ b/Sources/Testing/ABI/Encoded/ABI.EncodedEvent.swift @@ -180,3 +180,65 @@ extension ABI { extension ABI.EncodedEvent: Codable {} extension ABI.EncodedEvent.Kind: Codable {} + +// MARK: - Conversion to/from library types + +@_spi(ForToolsIntegrationOnly) +extension Event { + /// Initialize an instance of this type from the given value. + /// + /// - Parameters: + /// - event: The encoded event to initialize this instance from. + /// + /// ``testID`` and ``testCaseID`` are always `nil` because we need information + /// from the associated `ABI.EncodedTest` to properly decode those values. + public init?(decoding event: ABI.EncodedEvent) { + // SkipInfo will only be decoded for skip/cancel event kinds + lazy var skipInfo = SkipInfo(decoding: event) + + let kind: Kind + switch event.kind { + case .runStarted: + kind = .runStarted + case .testStarted: + kind = .testStarted + case .testCaseStarted: + kind = .testCaseStarted + case .issueRecorded: + guard let issue = Issue(decoding: event) else { + return nil + } + kind = .issueRecorded(issue) + case .valueAttached: + guard let attachment = Attachment(decoding: event) else { + return nil + } + kind = .valueAttached(attachment) + case .testCaseEnded: + kind = .testCaseEnded + case .testCaseCancelled: + guard let skipInfo else { + return nil + } + kind = .testCaseCancelled(skipInfo) + case .testEnded: + kind = .testEnded + case .testSkipped: + guard let skipInfo else { + return nil + } + kind = .testSkipped(skipInfo) + case .testCancelled: + guard let skipInfo else { + return nil + } + kind = .testCancelled(skipInfo) + case .runEnded: + kind = .runEnded + } + + guard let instant = Test.Clock.Instant(decoding: event.instant) else { return nil } + + self.init(kind, testID: nil, testCaseID: nil, instant: instant) + } +} diff --git a/Sources/Testing/ExitTests/ExitTest.swift b/Sources/Testing/ExitTests/ExitTest.swift index 6a9bb6fbb..f9664c044 100644 --- a/Sources/Testing/ExitTests/ExitTest.swift +++ b/Sources/Testing/ExitTests/ExitTest.swift @@ -1053,30 +1053,32 @@ extension ExitTest { /// - Throws: Any error encountered attempting to decode or process the JSON. private static func _processRecord(_ recordJSON: UnsafeRawBufferPointer, fromBackChannel backChannel: borrowing FileHandle) throws { let record = try JSON.decode(ABI.Record.self, from: recordJSON) - guard case let .event(event) = record.kind else { + guard case let .event(encodedEvent) = record.kind, + let event = Event(decoding: encodedEvent) + else { return } // Translate the event back into a "real" event (such as "issue recorded") // and post it in the parent process. This translation is, of course, lossy // due to the process boundary, but we make a best effort. - if var issue = Issue(decoding: event) { - // A backtrace from the child process will have the wrong address space, - // so remove the backtrace if present before recording it. + // + // Events containing a backtrace from the child process will have the wrong + // address space, so remove the backtrace if present before recording it. + + switch event.kind { + case .issueRecorded(var issue): issue.sourceContext.backtrace = nil issue.record() - } else if let attachment = Attachment(decoding: event) { + case .valueAttached(let attachment): Attachment.record(attachment, sourceLocation: attachment.sourceLocation) - } else if case .testCancelled = event.kind { - let comment = event._comments?.lazy - .map(Comment.init(rawValue:)) - .first - let sourceContext = SourceContext( - backtrace: nil, // A backtrace from the child process will have the wrong address space. - sourceLocation: event._sourceLocation.flatMap(SourceLocation.init) - ) - let skipInfo = SkipInfo(comment: comment, sourceContext: sourceContext) + case .testCancelled(var skipInfo), .testCaseCancelled(var skipInfo), .testSkipped(var skipInfo): + // In practice, an exit test won't receive .testSkipped events because + // they would happen before the exit test body starts running. + skipInfo.sourceContext.backtrace = nil _ = try? Test.cancel(with: skipInfo) + default: + break } } diff --git a/Tests/TestingTests/ABI.EncodedEventTests.swift b/Tests/TestingTests/ABI.EncodedEventTests.swift new file mode 100644 index 000000000..405675e4b --- /dev/null +++ b/Tests/TestingTests/ABI.EncodedEventTests.swift @@ -0,0 +1,122 @@ +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 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 + +@Suite struct `ABI.EncodedEventTests` { +#if canImport(Foundation) + /// Creates an EncodedEvent from a JSON string. + /// + /// - Throws: If the JSON doesn't represent a valid EncodedEvent. + private func encodedEvent( + _ json: String, + ) throws -> ABI.EncodedEvent { + var json = json + return try json.withUTF8 { json in + try JSON.decode(ABI.EncodedEvent.self, from: UnsafeRawBufferPointer(json)) + } + } + + @Test func `Decoded event always has nil testID and testCaseID`() throws { + let event = try encodedEvent( + """ + { + "kind": "testStarted", + "instant": {"absolute": 123, "since1970": 456}, + "messages": [], + "testID": "SomeValidTestID/testFunc()" + } + """) + let decoded = try #require(Event(decoding: event)) + + #expect(decoded.testID == nil) + #expect(decoded.testCaseID == nil) + } + + @Test(arguments: [ + "runStarted", + "runEnded", + "testStarted", + "testEnded", + "testCaseStarted", + "testCaseEnded", + // Following `kind`s need SkipInfo which nominally requires _sourceLocation. + // However, an empty placeholder SkipInfo can be provided when decoding. + // vvv + "testCaseCancelled", + "testSkipped", + "testCancelled", + ]) func `Successfully decode events which don't require associated info`(kind: String) throws { + let event = try encodedEvent( + """ + { + "kind": "\(kind)", + "instant": {"absolute": 123, "since1970": 456}, + "messages": [], + } + """) + + #expect(Event(decoding: event) != nil) + } + + @Test(arguments: [ + "issueRecorded", // Needs issue details + "valueAttached", // Needs attachment details + ]) func `Events without required associated info fail to decode`(kind: String) throws { + let event = try encodedEvent( + """ + { + "kind": "\(kind)", + "instant": {"absolute": 123, "since1970": 456}, + "messages": [], + } + """) + + #expect(Event(decoding: event) == nil) + } + + @Test func `Decode issueRecorded`() throws { + let event = try encodedEvent( + """ + { + "kind": "issueRecorded", + "instant": {"absolute": 0, "since1970": 0}, + "messages": [], + "issue": {"isKnown": true} + } + """) + let decoded = try #require(Event(decoding: event)) + + guard case .issueRecorded(let issue) = decoded.kind else { + Issue.record("Expected issueRecorded but got wrong kind \(decoded.kind)") + return + } + #expect(issue.isKnown) + } + + @Test func `Decode valueAttached`() throws { + let event = try encodedEvent( + """ + { + "kind": "valueAttached", + "instant": {"absolute": 0, "since1970": 0}, + "messages": [], + "attachment": {"path": "/tmp/important-cheese.txt"} + } + """) + let decoded = try #require(Event(decoding: event)) + + guard case .valueAttached = decoded.kind else { + Issue.record("Expected valueAttached but got wrong kind \(decoded.kind)") + return + } + } +#endif +} diff --git a/Tests/TestingTests/SkipInfoTests.swift b/Tests/TestingTests/SkipInfoTests.swift index dd313974e..9971ead02 100644 --- a/Tests/TestingTests/SkipInfoTests.swift +++ b/Tests/TestingTests/SkipInfoTests.swift @@ -9,9 +9,6 @@ // @testable @_spi(ForToolsIntegrationOnly) import Testing -#if canImport(Foundation) -import Foundation -#endif @Suite("SkipInfo Tests") struct SkipInfoTests {