Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ let package = Package(
path: "Tests/_MemorySafeTestingTests",
swiftSettings: .packageSettings + [.strictMemorySafety()]
),
.testTarget(
name: "SubexpressionShowcase",
dependencies: [
"Testing",
],
swiftSettings: .packageSettings
),

.macro(
name: "TestingMacros",
Expand Down Expand Up @@ -358,6 +365,7 @@ extension Array where Element == PackageDescription.SwiftSetting {

result += [
.enableUpcomingFeature("ExistentialAny"),
.enableExperimentalFeature("NonescapableTypes"),

.enableExperimentalFeature("AccessLevelOnImport"),
.enableUpcomingFeature("InternalImportsByDefault"),
Expand Down
34 changes: 34 additions & 0 deletions Sources/Testing/ABI/Encoded/ABI.EncodedExpectation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 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 ABI {
/// A type implementing the JSON encoding of ``Expectation`` 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: Expectations are not yet part of the JSON schema.
struct EncodedExpectation<V>: Sendable where V: ABI.Version {
/// The expression evaluated by this expectation.
///
/// - Warning: Expressions are not yet part of the JSON schema.
var _expression: EncodedExpression<V>

init(encoding expectation: borrowing Expectation, in eventContext: borrowing Event.Context) {
_expression = EncodedExpression<V>(encoding: expectation.evaluatedExpression, in: eventContext)
}
}
}

// MARK: - Codable

extension ABI.EncodedExpectation: Codable {}
52 changes: 52 additions & 0 deletions Sources/Testing/ABI/Encoded/ABI.EncodedExpression.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 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 ABI {
/// A type implementing the JSON encoding of ``Expression`` 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: Expressions are not yet part of the JSON schema.
struct EncodedExpression<V>: Sendable where V: ABI.Version {
/// The source code of the original captured expression.
var sourceCode: String

/// A string representation of the runtime value of this expression.
///
/// If the runtime value of this expression has not been evaluated, the
/// value of this property is `nil`.
var runtimeValue: String?

/// The fully-qualified name of the type of value represented by
/// `runtimeValue`, or `nil` if that value has not been captured.
var runtimeTypeName: String?

/// Any child expressions within this expression.
var children: [EncodedExpression]?

init(encoding expression: borrowing __Expression, in eventContext: borrowing Event.Context) {
sourceCode = expression.sourceCode
runtimeValue = expression.runtimeValue.map(String.init(describingForTest:))
runtimeTypeName = expression.runtimeValue.map(\.typeInfo.fullyQualifiedName)
if !expression.subexpressions.isEmpty {
children = expression.subexpressions.map { [eventContext = copy eventContext] subexpression in
Self(encoding: subexpression, in: eventContext)
}
}
}
}
}

// MARK: - Codable

extension ABI.EncodedExpression: Codable {}
8 changes: 8 additions & 0 deletions Sources/Testing/ABI/Encoded/ABI.EncodedIssue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ extension ABI {
/// - Warning: Errors are not yet part of the JSON schema.
var _error: EncodedError<V>?

/// The expectation associated with this issue, if applicable.
///
/// - Warning: Expectations are not yet part of the JSON schema.
var _expectation: EncodedExpectation<V>?

init(encoding issue: borrowing Issue, in eventContext: borrowing Event.Context) {
// >= v0
isKnown = issue.isKnown
Expand All @@ -81,6 +86,9 @@ extension ABI {
_error = EncodedError(encoding: error, in: eventContext)
}
}
if case let .expectationFailed(expectation) = issue.kind {
_expectation = EncodedExpectation(encoding: expectation, in: eventContext)
}
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions Sources/Testing/ABI/Encoded/ABI.EncodedMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,19 @@ extension ABI {
/// The symbol associated with this message.
var symbol: Symbol

/// How much to indent this message when presenting it.
///
/// - Warning: This property is not yet part of the JSON schema.
var _indentation: Int?

/// The human-readable, unformatted text associated with this message.
var text: String

init(encoding message: borrowing Event.HumanReadableOutputRecorder.Message) {
symbol = Symbol(encoding: message.symbol ?? .default)
if message.indentation > 0 {
_indentation = message.indentation
}
text = message.conciseStringValue ?? message.stringValue
}
}
Expand Down
6 changes: 5 additions & 1 deletion Sources/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ add_library(Testing
ABI/Encoded/ABI.EncodedBacktrace.swift
ABI/Encoded/ABI.EncodedError.swift
ABI/Encoded/ABI.EncodedEvent.swift
ABI/Encoded/ABI.EncodedExpectation.swift
ABI/Encoded/ABI.EncodedExpression.swift
ABI/Encoded/ABI.EncodedInstant.swift
ABI/Encoded/ABI.EncodedIssue.swift
ABI/Encoded/ABI.EncodedMessage.swift
Expand Down Expand Up @@ -47,6 +49,8 @@ add_library(Testing
Expectations/Expectation.swift
Expectations/Expectation+Macro.swift
Expectations/ExpectationChecking+Macro.swift
Expectations/ExpectationContext.swift
Expectations/ExpectationContext+Pointers.swift
Issues/Confirmation.swift
Issues/ErrorSnapshot.swift
Issues/Issue.swift
Expand All @@ -69,7 +73,7 @@ add_library(Testing
SourceAttribution/Backtrace+Symbolication.swift
SourceAttribution/CustomTestStringConvertible.swift
SourceAttribution/Expression.swift
SourceAttribution/Expression+Macro.swift
SourceAttribution/ExpressionID.swift
SourceAttribution/SourceContext.swift
SourceAttribution/SourceLocation.swift
SourceAttribution/SourceLocation+Macro.swift
Expand Down
40 changes: 25 additions & 15 deletions Sources/Testing/Events/Recorder/Event.ConsoleOutputRecorder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,22 @@ private let _ansiEscapeCodePrefix = "\u{001B}["
private let _resetANSIEscapeCode = "\(_ansiEscapeCodePrefix)0m"

extension Event.Symbol {
/// Get the string value to use for a message with no associated symbol.
///
/// - Parameters:
/// - options: Options to use when writing the symbol.
///
/// - Returns: A string representation of "no symbol" appropriate for writing
/// to a stream.
fileprivate static func placeholderStringValue(options: Event.ConsoleOutputRecorder.Options) -> String {
#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst))
if options.useSFSymbols {
return " "
}
#endif
return " "
}

/// Get the string value for this symbol with the given write options.
///
/// - Parameters:
Expand Down Expand Up @@ -171,7 +187,7 @@ extension Event.Symbol {
case .attachment:
return "\(_ansiEscapeCodePrefix)94m\(symbolCharacter)\(_resetANSIEscapeCode)"
case .details:
return symbolCharacter
return "\(symbolCharacter)"
}
}
return "\(symbolCharacter)"
Expand Down Expand Up @@ -305,18 +321,12 @@ extension Event.ConsoleOutputRecorder {
/// - Returns: Whether any output was produced and written to this instance's
/// destination.
@discardableResult public func record(_ event: borrowing Event, in context: borrowing Event.Context) -> Bool {
let messages = _humanReadableOutputRecorder.record(event, in: context)

// Padding to use in place of a symbol for messages that don't have one.
var padding = " "
#if os(macOS) || (os(iOS) && targetEnvironment(macCatalyst))
if options.useSFSymbols {
padding = " "
}
#endif
let symbolPlaceholder = Event.Symbol.placeholderStringValue(options: options)

let messages = _humanReadableOutputRecorder.record(event, in: context)
let lines = messages.lazy.map { [test = context.test] message in
let symbol = message.symbol?.stringValue(options: options) ?? padding
let symbol = message.symbol?.stringValue(options: options) ?? symbolPlaceholder
let indentation = String(repeating: " ", count: message.indentation)

if case .details = message.symbol {
// Special-case the detail symbol to apply grey to the entire line of
Expand All @@ -325,17 +335,17 @@ extension Event.ConsoleOutputRecorder {
// to the indentation provided by the symbol.
var lines = message.stringValue.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline)
lines = CollectionOfOne(lines[0]) + lines.dropFirst().map { line in
"\(padding) \(line)"
"\(indentation)\(symbolPlaceholder) \(line)"
}
let stringValue = lines.joined(separator: "\n")
if options.useANSIEscapeCodes, options.ansiColorBitDepth > 1 {
return "\(_ansiEscapeCodePrefix)90m\(symbol) \(stringValue)\(_resetANSIEscapeCode)\n"
return "\(_ansiEscapeCodePrefix)90m\(symbol) \(indentation)\(stringValue)\(_resetANSIEscapeCode)\n"
} else {
return "\(symbol) \(stringValue)\n"
return "\(symbol) \(indentation)\(stringValue)\n"
}
} else {
let colorDots = test.map { self.colorDots(for: $0.tags) } ?? ""
return "\(symbol) \(colorDots)\(message.stringValue)\n"
return "\(symbol) \(indentation)\(colorDots)\(message.stringValue)\n"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ extension Event {
/// The symbol associated with this message, if any.
var symbol: Symbol?

/// How much to indent this message when presenting it.
///
/// The way in which this additional indentation is rendered is
/// implementation-defined. Typically, the greater the value of this
/// property, the more whitespace characters are inserted.
///
/// Rendering of indentation is optional.
var indentation = 0

/// The human-readable message.
var stringValue: String

Expand Down Expand Up @@ -496,20 +505,18 @@ extension Event.HumanReadableOutputRecorder {
additionalMessages.append(_formattedComment(knownIssueComment))
}

if verbosity > 0, case let .expectationFailed(expectation) = issue.kind {
if verbosity >= 0, case let .expectationFailed(expectation) = issue.kind {
let expression = expectation.evaluatedExpression
func addMessage(about expression: __Expression) {
let description = expression.expandedDebugDescription()
additionalMessages.append(Message(symbol: .details, stringValue: description))
}
let subexpressions = expression.subexpressions
if subexpressions.isEmpty {
addMessage(about: expression)
} else {
for subexpression in subexpressions {
addMessage(about: subexpression)
func addMessage(about expression: __Expression, depth: Int) {
let description = expression.expandedDescription(verbose: verbosity > 0)
if description != expression.sourceCode {
additionalMessages.append(Message(symbol: .details, indentation: depth, stringValue: description))
}
for subexpression in expression.subexpressions {
addMessage(about: subexpression, depth: depth + 1)
}
}
addMessage(about: expression, depth: 0)
}

let atSourceLocation = issue.sourceLocation.map { " at \($0)" } ?? ""
Expand Down
13 changes: 8 additions & 5 deletions Sources/Testing/ExitTests/ExitTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ func callExitTest(
encodingCapturedValues capturedValues: [ExitTest.CapturedValue],
processExitsWith expectedExitCondition: ExitTest.Condition,
observing observedValues: [any PartialKeyPath<ExitTest.Result> & Sendable],
expression: __Expression,
sourceCode: @escaping @autoclosure @Sendable () -> [__ExpressionID: String],
comments: @autoclosure () -> [Comment],
isRequired: Bool,
isolation: isolated (any Actor)? = #isolation,
Expand Down Expand Up @@ -520,11 +520,14 @@ func callExitTest(
}

// Plumb the exit test's result through the general expectation machinery.
let expression = __Expression(String(describingForTest: expectedExitCondition))
return __checkValue(
let expectationContext = __ExpectationContext(
sourceCode: [.root: String(describingForTest: expectedExitCondition)],
runtimeValues: [.root: { Expression.Value(reflecting: result.exitStatus) }]
)
return check(
expectedExitCondition.isApproximatelyEqual(to: result.exitStatus),
expression: expression,
expressionWithCapturedRuntimeValues: expression.capturingRuntimeValues(result.exitStatus),
expectationContext: expectationContext,
mismatchedErrorDescription: nil,
comments: comments(),
isRequired: isRequired,
sourceLocation: sourceLocation
Expand Down
12 changes: 6 additions & 6 deletions Sources/Testing/Expectations/Expectation+Macro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,10 @@
/// running in the current task and an instance of ``ExpectationFailedError`` is
/// thrown.
@freestanding(expression) public macro require<T>(
_ optionalValue: T?,
_ optionalValue: consuming T?,
_ comment: @autoclosure () -> Comment? = nil,
sourceLocation: SourceLocation = #_sourceLocation
) -> T = #externalMacro(module: "TestingMacros", type: "RequireMacro")
) -> T = #externalMacro(module: "TestingMacros", type: "UnwrapMacro") where T: ~Copyable

/// Unwrap an optional boolean value or, if it is `nil`, fail and throw an
/// error.
Expand All @@ -89,7 +89,7 @@
/// running in the current task and an instance of ``ExpectationFailedError`` is
/// thrown.
///
/// This overload of ``require(_:_:sourceLocation:)-6w9oo`` checks if
/// This overload of ``require(_:_:sourceLocation:)-5l63q`` checks if
/// `optionalValue` may be ambiguous (i.e. it is unclear if the developer
/// intended to check for a boolean value or unwrap an optional boolean value)
/// and provides additional compile-time diagnostics when it is.
Expand Down Expand Up @@ -118,16 +118,16 @@ public macro require(
/// running in the current task and an instance of ``ExpectationFailedError`` is
/// thrown.
///
/// This overload of ``require(_:_:sourceLocation:)-6w9oo`` is used when a
/// This overload of ``require(_:_:sourceLocation:)-5l63q`` is used when a
/// non-optional, non-`Bool` value is passed to `#require()`. It emits a warning
/// diagnostic indicating that the expectation is redundant.
@freestanding(expression)
@_documentation(visibility: private)
public macro require<T>(
_ optionalValue: T,
_ optionalValue: consuming T,
_ comment: @autoclosure () -> Comment? = nil,
sourceLocation: SourceLocation = #_sourceLocation
) -> T = #externalMacro(module: "TestingMacros", type: "NonOptionalRequireMacro")
) -> T = #externalMacro(module: "TestingMacros", type: "NonOptionalRequireMacro") where T: ~Copyable

// MARK: - Matching errors by type

Expand Down
Loading