Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 6 additions & 3 deletions Sources/ContainerTestSupport/BuildFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import ContainerizationExtras
import Darwin
import Foundation
import SystemPackage
import Testing

// MARK: - Build context types

Expand Down Expand Up @@ -309,12 +308,16 @@ extension ContainerFixture {
/// Asserts that `path` exists as a regular file inside `container`.
public func assertContainerHasFile(_ container: String, at path: String, _ comment: String? = nil) throws {
let exists = try containerHasFile(container, at: path)
#expect(exists, "\(comment ?? path) should exist in container")
guard exists else {
throw CommandError.executionFailed("\(comment ?? path) should exist in container")
}
}

/// Asserts that `path` does NOT exist inside `container`.
public func assertContainerMissingFile(_ container: String, at path: String, _ comment: String? = nil) throws {
let exists = try containerHasFile(container, at: path)
#expect(!exists, "\(comment ?? path) should NOT exist in container")
guard !exists else {
throw CommandError.executionFailed("\(comment ?? path) should NOT exist in container")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@

import Foundation
import SystemPackage
import Testing

// MARK: - Image inspect types

Expand Down Expand Up @@ -109,6 +108,8 @@ extension ContainerFixture {
/// Asserts that the image was successfully built and is present in the image store.
public func assertImageBuilt(_ image: String) throws {
let name = try inspectImage(image)
#expect(name == image, "expected image \(image) to be present")
guard name == image else {
throw CommandError.executionFailed("expected image \(image) to be present")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
//===----------------------------------------------------------------------===//

import Foundation
import Testing

// MARK: - Machine output types

Expand Down
48 changes: 31 additions & 17 deletions Sources/ContainerTestSupport/ContainerFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import Foundation
import Logging
import Synchronization
import SystemPackage
import Testing

/// Per-test fixture for CLI integration tests.
///
Expand Down Expand Up @@ -73,29 +72,44 @@ public final class ContainerFixture: Sendable {

// MARK: - Unstructured API

/// Identity of the running test, supplied by the test target.
///
/// `ContainerTestSupport` does not import the Testing module, so callers that
/// can `import Testing` should pass `Test.current` / `Test.Case.current` here.
public struct TestIdentity: Sendable {
public var name: String?
public var identifier: String?
public var isParameterized: Bool

public init(name: String? = nil, identifier: String? = nil, isParameterized: Bool = false) {
self.name = name
self.identifier = identifier
self.isParameterized = isParameterized
}
}

/// Runs `body` with a fresh fixture, then tears down all registered resources.
///
/// Cleanup runs in LIFO order regardless of whether `body` throws.
/// Pass `identity` from the test target so log files and scratch directories
/// keep the current test name without this module importing Testing.
@discardableResult
public static func with<T>(_ body: (ContainerFixture) async throws -> T) async throws -> T {
public static func with<T>(identity: TestIdentity, _ body: (ContainerFixture) async throws -> T) async throws -> T {
let testID = String(UUID().uuidString.prefix(8)).lowercased()

let testName =
Test.current.map { $0.name.hasSuffix("()") ? String($0.name.dropLast(2)) : $0.name }
?? testID
// Test.current is a value describing the running test, not an instance of the suite
// type, so `type(of:)` always yields `Test` itself. Derive the suite from the test's
// fully-qualified ID instead (e.g. "IntegrationTests.TestCLIStatus/explicitTableFormat()/...")
// the same identifier format used in the swift-testing event-stream JSON.
let testIdentifier = Test.current.map { "\($0.id)" }
let testName: String = {
guard let name = identity.name else { return testID }
return name.hasSuffix("()") ? String(name.dropLast(2)) : name
}()
// Derive the suite from the test's fully-qualified ID
// (e.g. "IntegrationTests.TestCLIStatus/explicitTableFormat()/..."),
// the same identifier format used in the swift-testing event-stream JSON.
let testIdentifier = identity.identifier
let suiteName = testIdentifier?.split(separator: "/", maxSplits: 1).first.map(String.init) ?? "unknown"

// Swift Testing doesn't expose a stable per-case identifier or the case's arguments
// publicly, only `isParameterized`. Parameterized tests share one `testName` across all
// their concurrently-running cases, so fall back to the per-invocation `testID` to keep
// each case's log file distinct.
let isParameterized = Test.Case.current?.isParameterized ?? false
let logFileName = isParameterized ? "\(testName)-\(testID).log" : "\(testName).log"
// Parameterized tests share one `testName` across concurrently-running cases,
// so fall back to the per-invocation `testID` to keep each case's log file distinct.
let logFileName = identity.isParameterized ? "\(testName)-\(testID).log" : "\(testName).log"

// Set up logging before any fixture work (scratch dir creation, etc.) so a "test start"
// message is the first thing recorded — bookended by "test end" once `body` returns.
Expand Down Expand Up @@ -124,7 +138,7 @@ public final class ContainerFixture: Sendable {
// Name the scratch directory so it's immediately identifiable when browsing:
// {sanitizedTestName}-{testID}
let safeName = testName.replacingOccurrences(
of: "[^a-zA-Z0-9]", with: "-", options: .regularExpression)
of: "[^a-zA-Z0-9]", with: "-", options: String.CompareOptions.regularExpression)
let testDir = scratchRoot.appending("\(safeName)-\(testID)")
try FileManager.default.createDirectory(
atPath: testDir.string, withIntermediateDirectories: true, attributes: nil)
Expand Down
36 changes: 36 additions & 0 deletions Tests/IntegrationTests/ContainerFixture+Testing.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//

import ContainerTestSupport
import Testing

extension ContainerFixture {
/// Opens a fixture scope using Swift Testing's current test identity.
///
/// `ContainerTestSupport` cannot import Testing, so this test-target wrapper
/// reads `Test.current` / `Test.Case.current` and forwards them.
@discardableResult
static func with<T>(_ body: (ContainerFixture) async throws -> T) async throws -> T {
try await with(
identity: TestIdentity(
name: Test.current?.name,
identifier: Test.current.map { "\($0.id)" },
isParameterized: Test.Case.current?.isParameterized ?? false
),
body
)
}
}