mirror of
https://github.com/apple/container.git
synced 2026-07-15 05:57:01 +00:00
536 lines
17 KiB
Swift
536 lines
17 KiB
Swift
//===----------------------------------------------------------------------===//
|
|
// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved.
|
|
//
|
|
// 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 ContainerBuildIR
|
|
import ContainerBuildSnapshotter
|
|
import ContainerizationOCI
|
|
import Crypto
|
|
import Foundation
|
|
import Testing
|
|
|
|
@testable import ContainerBuildCache
|
|
|
|
// MARK: - Missing Type Definitions for Testing
|
|
|
|
/// ContentWriter mock for testing
|
|
public struct ContentWriter {
|
|
public let ingestDir: URL
|
|
|
|
public init(for ingestDir: URL) throws {
|
|
self.ingestDir = ingestDir
|
|
}
|
|
|
|
public func write(_ data: Data) throws -> (Int64, SHA256.Digest) {
|
|
let digest = SHA256.hash(data: data)
|
|
// Use the digest string without the sha256: prefix for the filename
|
|
let digestString = digest.map { String(format: "%02x", $0) }.joined()
|
|
let filePath = ingestDir.appendingPathComponent(digestString)
|
|
try data.write(to: filePath)
|
|
return (Int64(data.count), digest)
|
|
}
|
|
|
|
public func create(from manifest: CacheManifest) throws -> (Int64, SHA256.Digest) {
|
|
let data = try JSONEncoder().encode(manifest)
|
|
return try write(data)
|
|
}
|
|
}
|
|
|
|
extension Data {
|
|
var sha256: String {
|
|
let digest = (try? ContainerBuildIR.Digest.compute(self, using: .sha256)) ?? (try! ContainerBuildIR.Digest(algorithm: .sha256, bytes: Data(count: 32)))
|
|
return digest.stringValue.replacingOccurrences(of: "sha256:", with: "")
|
|
}
|
|
}
|
|
|
|
// MARK: - Test Environment
|
|
|
|
/// Test environment with common setup and utilities for cache tests
|
|
public struct CacheTestEnvironment {
|
|
public let tempDir: URL
|
|
|
|
public init() throws {
|
|
self.tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
}
|
|
|
|
public func cleanup() {
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
}
|
|
|
|
/// Convenience function to create and manage test environment
|
|
public func withCacheTestEnvironment<T>(
|
|
_ operation: (CacheTestEnvironment) async throws -> T
|
|
) async throws -> T {
|
|
let environment = try CacheTestEnvironment()
|
|
defer { environment.cleanup() }
|
|
return try await operation(environment)
|
|
}
|
|
|
|
// MARK: - Compatibility Layer
|
|
|
|
/// Compatibility base class for existing XCTest-based tests during migration
|
|
/// This maintains the same interface as the original CacheTestCase for gradual migration
|
|
open class CacheTestCase {
|
|
public var tempDir: URL!
|
|
|
|
public init() {}
|
|
|
|
open func setUp() async throws {
|
|
tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
}
|
|
|
|
open func tearDown() async throws {
|
|
if let tempDir = tempDir {
|
|
try? FileManager.default.removeItem(at: tempDir)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - ContentStore Protocol
|
|
// Using the real ContentStore from ContainerizationOCI
|
|
|
|
// MARK: - Mock Content
|
|
|
|
/// Mock Content implementation for testing
|
|
public struct MockContent: Content {
|
|
public let path: URL
|
|
private let _data: Data
|
|
|
|
public init(data: Data) {
|
|
self._data = data
|
|
self.path = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
}
|
|
|
|
public func digest() throws -> SHA256.Digest {
|
|
SHA256.hash(data: _data)
|
|
}
|
|
|
|
public func size() throws -> UInt64 {
|
|
UInt64(_data.count)
|
|
}
|
|
|
|
public func data() throws -> Data {
|
|
_data
|
|
}
|
|
|
|
public func data(offset: UInt64, length: Int) throws -> Data? {
|
|
let start = Int(offset)
|
|
let end = min(start + length, _data.count)
|
|
guard start < _data.count else { return nil }
|
|
return _data.subdata(in: start..<end)
|
|
}
|
|
|
|
public func decode<T: Decodable>() throws -> T {
|
|
let decoder = JSONDecoder()
|
|
return try decoder.decode(T.self, from: _data)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mock ContentStore
|
|
|
|
/// Mock ContentStore for testing cache implementations
|
|
public actor MockContentStore: ContentStore {
|
|
private var storage: [String: Data] = [:]
|
|
private var manifests: [String: CacheManifest] = [:]
|
|
private var sessions: [String: URL] = [:]
|
|
private var nextSessionId = 0
|
|
private let baseDir: URL
|
|
|
|
public init(baseDir: URL? = nil) {
|
|
self.baseDir = baseDir ?? FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
|
try? FileManager.default.createDirectory(at: self.baseDir, withIntermediateDirectories: true)
|
|
}
|
|
|
|
// MARK: - ContentStore Interface
|
|
|
|
// MARK: - ContentStore Protocol Implementation
|
|
|
|
public func get(digest: String) async throws -> (any Content)? {
|
|
guard let data = storage[digest] else {
|
|
return nil
|
|
}
|
|
// Return a mock Content object
|
|
return MockContent(data: data)
|
|
}
|
|
|
|
public func get<T: Decodable & Sendable>(digest: String) async throws -> T? {
|
|
guard let data = storage[digest] else {
|
|
return nil
|
|
}
|
|
let decoder = JSONDecoder()
|
|
return try decoder.decode(T.self, from: data)
|
|
}
|
|
|
|
public func put<T: Codable & Sendable>(_ object: T, digest: String) async throws {
|
|
let encoder = JSONEncoder()
|
|
let data = try encoder.encode(object)
|
|
storage[digest] = data
|
|
}
|
|
|
|
@discardableResult
|
|
public func delete(digests: [String]) async throws -> ([String], UInt64) {
|
|
var deletedDigests: [String] = []
|
|
var totalSize: UInt64 = 0
|
|
|
|
for digest in digests {
|
|
if let data = storage.removeValue(forKey: digest) {
|
|
deletedDigests.append(digest)
|
|
totalSize += UInt64(data.count)
|
|
}
|
|
manifests.removeValue(forKey: digest)
|
|
}
|
|
|
|
return (deletedDigests, totalSize)
|
|
}
|
|
|
|
@discardableResult
|
|
public func delete(keeping: [String]) async throws -> ([String], UInt64) {
|
|
let keepSet = Set(keeping)
|
|
var deletedDigests: [String] = []
|
|
var totalSize: UInt64 = 0
|
|
|
|
for (digest, data) in storage {
|
|
if !keepSet.contains(digest) {
|
|
storage.removeValue(forKey: digest)
|
|
manifests.removeValue(forKey: digest)
|
|
deletedDigests.append(digest)
|
|
totalSize += UInt64(data.count)
|
|
}
|
|
}
|
|
|
|
return (deletedDigests, totalSize)
|
|
}
|
|
|
|
@discardableResult
|
|
public func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String] {
|
|
let tempDir = baseDir.appendingPathComponent("ingest-\(UUID().uuidString)")
|
|
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
|
defer { try? FileManager.default.removeItem(at: tempDir) }
|
|
|
|
try await body(tempDir)
|
|
|
|
// Mock implementation - return empty array
|
|
return []
|
|
}
|
|
|
|
public func newIngestSession() async throws -> (id: String, ingestDir: URL) {
|
|
nextSessionId += 1
|
|
let sessionId = "session-\(nextSessionId)"
|
|
let sessionDir = baseDir.appendingPathComponent(sessionId)
|
|
try FileManager.default.createDirectory(at: sessionDir, withIntermediateDirectories: true)
|
|
sessions[sessionId] = sessionDir
|
|
return (id: sessionId, ingestDir: sessionDir)
|
|
}
|
|
|
|
@discardableResult
|
|
public func completeIngestSession(_ sessionId: String) async throws -> [String] {
|
|
guard let sessionDir = sessions[sessionId] else {
|
|
throw MockContentStoreError.sessionNotFound(sessionId)
|
|
}
|
|
|
|
// Read all files from the session directory and store them
|
|
var digests: [String] = []
|
|
|
|
if FileManager.default.fileExists(atPath: sessionDir.path) {
|
|
let files = try FileManager.default.contentsOfDirectory(at: sessionDir, includingPropertiesForKeys: nil)
|
|
for file in files {
|
|
let data = try Data(contentsOf: file)
|
|
let digestHash = file.lastPathComponent
|
|
// Ensure the digest has the sha256: prefix for storage
|
|
let digest = digestHash.hasPrefix("sha256:") ? digestHash : "sha256:\(digestHash)"
|
|
storage[digest] = data
|
|
digests.append(digest)
|
|
}
|
|
}
|
|
|
|
// Clean up session
|
|
sessions.removeValue(forKey: sessionId)
|
|
try? FileManager.default.removeItem(at: sessionDir)
|
|
|
|
return digests
|
|
}
|
|
|
|
public func cancelIngestSession(_ sessionId: String) async throws {
|
|
guard let sessionDir = sessions[sessionId] else {
|
|
throw MockContentStoreError.sessionNotFound(sessionId)
|
|
}
|
|
|
|
sessions.removeValue(forKey: sessionId)
|
|
try? FileManager.default.removeItem(at: sessionDir)
|
|
}
|
|
|
|
// MARK: - Test Utilities
|
|
|
|
public func hasContent(digest: String) async -> Bool {
|
|
storage[digest] != nil
|
|
}
|
|
|
|
public func clear() async {
|
|
storage.removeAll()
|
|
manifests.removeAll()
|
|
for (_, sessionDir) in sessions {
|
|
try? FileManager.default.removeItem(at: sessionDir)
|
|
}
|
|
sessions.removeAll()
|
|
}
|
|
|
|
public func contentCount() async -> Int {
|
|
storage.count
|
|
}
|
|
}
|
|
|
|
// MARK: - Mock ContentStore Errors
|
|
|
|
public enum MockContentStoreError: LocalizedError {
|
|
case notFound(String)
|
|
case sessionNotFound(String)
|
|
case encodingFailed(Error)
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .notFound(let digest):
|
|
return "Content not found: \(digest)"
|
|
case .sessionNotFound(let sessionId):
|
|
return "Session not found: \(sessionId)"
|
|
case .encodingFailed(let error):
|
|
return "Encoding failed: \(error.localizedDescription)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Test Data Factory
|
|
|
|
/// Factory for creating test data objects
|
|
public enum TestDataFactory {
|
|
|
|
public static func createDigest(from string: String = "test-content") -> ContainerBuildIR.Digest {
|
|
let data = string.data(using: .utf8)!
|
|
return (try? ContainerBuildIR.Digest.compute(data, using: .sha256)) ?? (try! ContainerBuildIR.Digest(algorithm: .sha256, bytes: Data(count: 32)))
|
|
}
|
|
|
|
public static func createSnapshot(
|
|
id: UUID = UUID(),
|
|
content: String = "test-snapshot",
|
|
size: Int64 = 1024,
|
|
parent: Snapshot? = nil
|
|
) -> Snapshot {
|
|
let digest = createDigest(from: content)
|
|
return Snapshot(
|
|
id: id,
|
|
digest: digest,
|
|
size: size,
|
|
parent: parent,
|
|
state: .prepared(mountpoint: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString))
|
|
)
|
|
}
|
|
|
|
public static func createCacheKey(
|
|
operation: ContainerBuildIR.Operation? = nil,
|
|
operationContent: String = "test-operation",
|
|
inputContents: [String] = ["input1", "input2"],
|
|
platform: Platform = .linuxAMD64
|
|
) -> ContainerBuildCache.CacheKey {
|
|
let operationDigest: ContainerBuildIR.Digest
|
|
if let operation = operation {
|
|
operationDigest = (try? operation.contentDigest()) ?? createDigest(from: operationContent)
|
|
} else {
|
|
operationDigest = createDigest(from: operationContent)
|
|
}
|
|
let inputDigests = inputContents.map { createDigest(from: $0) }
|
|
|
|
return ContainerBuildCache.CacheKey(
|
|
operationDigest: operationDigest,
|
|
inputDigests: inputDigests,
|
|
platform: platform
|
|
)
|
|
}
|
|
|
|
public static func createCachedResult(
|
|
snapshotContent: String = "test-result",
|
|
environmentChanges: [String: EnvironmentValue] = ["PATH": .literal("/usr/bin")],
|
|
metadataChanges: [String: String] = ["build.time": "2024-01-01T12:00:00Z"]
|
|
) -> CachedResult {
|
|
let snapshot = createSnapshot(content: snapshotContent)
|
|
return CachedResult(
|
|
snapshot: snapshot,
|
|
environmentChanges: environmentChanges,
|
|
metadataChanges: metadataChanges
|
|
)
|
|
}
|
|
|
|
public static func createOperation(
|
|
kind: String = "test",
|
|
content: String = "test-operation"
|
|
) -> MockOperation {
|
|
MockOperation(kind: kind, content: content)
|
|
}
|
|
|
|
public static func createCacheMetadata(
|
|
operationHash: String = "test-hash",
|
|
platform: Platform = .linuxAMD64,
|
|
ttl: TimeInterval? = nil,
|
|
tags: [String: String] = [:]
|
|
) -> CacheMetadata {
|
|
CacheMetadata(
|
|
operationHash: operationHash,
|
|
platform: platform,
|
|
ttl: ttl,
|
|
tags: tags
|
|
)
|
|
}
|
|
|
|
public static func createDescriptor(
|
|
mediaType: String = "application/vnd.test+json",
|
|
digest: String = "sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
|
size: Int64 = 1024
|
|
) -> Descriptor {
|
|
Descriptor(
|
|
mediaType: mediaType,
|
|
digest: digest,
|
|
size: size,
|
|
urls: nil,
|
|
annotations: nil,
|
|
platform: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Mock Operation
|
|
|
|
public struct MockOperation: ContainerBuildIR.Operation {
|
|
public let kind: String
|
|
public let content: String
|
|
public let metadata: OperationMetadata
|
|
|
|
public static let operationKind = OperationKind(rawValue: "mock")
|
|
public var operationKind: OperationKind {
|
|
OperationKind(rawValue: kind)
|
|
}
|
|
|
|
public init(kind: String = "mock", content: String = "test", metadata: OperationMetadata = OperationMetadata()) {
|
|
self.kind = kind
|
|
self.content = content
|
|
self.metadata = metadata
|
|
}
|
|
|
|
public func accept<V: OperationVisitor>(_ visitor: V) throws -> V.Result {
|
|
try visitor.visitUnknown(self)
|
|
}
|
|
}
|
|
|
|
extension MockOperation: Hashable {
|
|
public func hash(into hasher: inout Hasher) {
|
|
hasher.combine(kind)
|
|
hasher.combine(content)
|
|
}
|
|
|
|
public static func == (lhs: MockOperation, rhs: MockOperation) -> Bool {
|
|
lhs.kind == rhs.kind && lhs.content == rhs.content
|
|
}
|
|
}
|
|
|
|
// MARK: - Performance Measurement
|
|
|
|
/// Utility for measuring test performance
|
|
public struct PerformanceMeasurement {
|
|
public static func measure<T>(
|
|
_ operation: () async throws -> T,
|
|
file: StaticString = #file,
|
|
line: UInt = #line
|
|
) async rethrows -> (result: T, duration: TimeInterval) {
|
|
let startTime = Date()
|
|
let result = try await operation()
|
|
let endTime = Date()
|
|
let duration = endTime.timeIntervalSince(startTime)
|
|
|
|
print("Performance measurement at \(file):\(line): \(duration)s")
|
|
return (result, duration)
|
|
}
|
|
|
|
public static func measureAndAssert<T>(
|
|
_ operation: () async throws -> T,
|
|
maxDuration: TimeInterval,
|
|
file: StaticString = #file,
|
|
line: UInt = #line
|
|
) async rethrows -> T {
|
|
let (result, duration) = try await measure(operation, file: file, line: line)
|
|
if duration >= maxDuration {
|
|
Issue.record(
|
|
"Operation took too long: \(duration)s >= \(maxDuration)s",
|
|
sourceLocation: SourceLocation(fileID: file.description, filePath: file.description, line: Int(line), column: 1))
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: - Async Test Utilities
|
|
|
|
/// Utilities for async testing
|
|
public enum AsyncTestUtilities {
|
|
|
|
/// Wait for a condition to become true with timeout
|
|
public static func waitFor(
|
|
condition: @escaping () async -> Bool,
|
|
timeout: TimeInterval = 5.0,
|
|
interval: TimeInterval = 0.1
|
|
) async throws {
|
|
let deadline = Date().addingTimeInterval(timeout)
|
|
|
|
while Date() < deadline {
|
|
if await condition() {
|
|
return
|
|
}
|
|
try await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
|
|
}
|
|
|
|
throw AsyncTestError.timeout
|
|
}
|
|
|
|
/// Run multiple async operations concurrently and collect results
|
|
public static func runConcurrently<T: Sendable>(
|
|
count: Int,
|
|
operation: @escaping @Sendable (Int) async throws -> T
|
|
) async throws -> [T] {
|
|
try await withThrowingTaskGroup(of: T.self) { group in
|
|
for i in 0..<count {
|
|
let index = i
|
|
group.addTask {
|
|
try await operation(index)
|
|
}
|
|
}
|
|
|
|
var results: [T] = []
|
|
for try await result in group {
|
|
results.append(result)
|
|
}
|
|
return results
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum AsyncTestError: LocalizedError {
|
|
case timeout
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .timeout:
|
|
return "Async test timed out"
|
|
}
|
|
}
|
|
}
|