//===----------------------------------------------------------------------===// // 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 Foundation import Testing @testable import ContainerBuildReporting @Suite("JSONProgressConsumer Tests") struct JSONProgressConsumerTests { // MARK: - Test Utilities private func createTestPipe() -> (FileHandle, FileHandle) { let pipe = Pipe() return (pipe.fileHandleForWriting, pipe.fileHandleForReading) } private func readOutputAsString(from readHandle: FileHandle) -> String { let data = readHandle.readDataToEndOfFile() return String(data: data, encoding: .utf8) ?? "" } private func parseJSONLines(_ output: String) -> [[String: Any]] { let lines = output.split(separator: "\n").map(String.init) return lines.compactMap { line in guard let data = line.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return json } } // MARK: - Initialization Tests @Test("Default initialization sets correct configuration values") func testDefaultInitialization() { let consumer = JSONProgressConsumer(configuration: JSONProgressConsumer.Configuration()) #expect(!consumer.configuration.prettyPrint) #expect(consumer.configuration.output == FileHandle.standardOutput) } @Test("Initialization with custom configuration applies settings correctly") func testInitializationWithCustomConfiguration() { let (writeHandle, _) = createTestPipe() let config = JSONProgressConsumer.Configuration( output: writeHandle, prettyPrint: true ) let consumer = JSONProgressConsumer(configuration: config) #expect(consumer.configuration.prettyPrint) #expect(consumer.configuration.output == writeHandle) } // MARK: - Build Event JSON Output Tests @Test("Build started event produces correct JSON output") func testBuildStartedEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let event = BuildEvent.buildStarted(totalOperations: 5, stages: 2, timestamp: Date()) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] #expect(json["type"] as? String == "build_started") #expect(json["total_operations"] as? Int == 5) #expect(json["stages"] as? Int == 2) #expect(json["timestamp"] != nil) } @Test("Build completed event produces correct JSON output") func testBuildCompletedEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let event = BuildEvent.buildCompleted(success: true, timestamp: Date()) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] #expect(json["type"] as? String == "build_completed") #expect(json["success"] as? Bool == true) #expect(json["timestamp"] != nil) } @Test("Stage started event produces correct JSON output") func testStageStartedEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let event = BuildEvent.stageStarted(stageName: "stage1", timestamp: Date()) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] #expect(json["type"] as? String == "stage_started") #expect(json["stage"] as? String == "stage1") #expect(json["timestamp"] != nil) } @Test("Stage completed event produces correct JSON output") func testStageCompletedEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let event = BuildEvent.stageCompleted(stageName: "stage1", timestamp: Date()) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] #expect(json["type"] as? String == "stage_completed") #expect(json["stage"] as? String == "stage1") #expect(json["timestamp"] != nil) } @Test("Operation started event produces correct JSON output with operation number") func testOperationStartedEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let context = ReportContext(nodeId: nodeId, stageId: "stage1", description: "Test operation") let event = BuildEvent.operationStarted(context: context) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] #expect(json["type"] as? String == "operation_started") #expect(json["operation"] as? String == "#1") #expect(json["description"] as? String == "Test operation") #expect(json["stage"] as? String == "stage1") #expect(json["timestamp"] != nil) } @Test("Operation finished event produces correct JSON output with duration") func testOperationFinishedEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let context = ReportContext(nodeId: nodeId, description: "Test operation") // First start the operation to assign a number let startEvent = BuildEvent.operationStarted(context: context) try await consumer.handle(startEvent) // Then finish it let finishEvent = BuildEvent.operationFinished(context: context, duration: 1.5) try await consumer.handle(finishEvent) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 2) let finishJson = jsonLines[1] #expect(finishJson["type"] as? String == "operation_finished") #expect(finishJson["operation"] as? String == "#1") #expect(finishJson["duration"] as? Double == 1.5) #expect(finishJson["timestamp"] != nil) } @Test("Operation failed event produces correct JSON output with error details") func testOperationFailedEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let context = ReportContext(nodeId: nodeId, description: "Test operation") let error = BuildEventError(type: .executionFailed, description: "Test error") // First start the operation to assign a number let startEvent = BuildEvent.operationStarted(context: context) try await consumer.handle(startEvent) // Then fail it let failEvent = BuildEvent.operationFailed(context: context, error: error) try await consumer.handle(failEvent) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 2) let failJson = jsonLines[1] #expect(failJson["type"] as? String == "operation_failed") #expect(failJson["operation"] as? String == "#1") #expect(failJson["error"] as? String == "Test error") #expect(failJson["error_type"] as? String == "executionFailed") #expect(failJson["timestamp"] != nil) } @Test("Operation cache hit event produces correct JSON output") func testOperationCacheHitEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let context = ReportContext(nodeId: nodeId, description: "Test operation") let event = BuildEvent.operationCacheHit(context: context) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] #expect(json["type"] as? String == "operation_cache_hit") #expect(json["operation"] as? String == "#1") #expect(json["description"] as? String == "Test operation") #expect(json["timestamp"] != nil) } @Test("Operation progress event produces correct JSON output with progress fraction") func testOperationProgressEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let context = ReportContext(nodeId: nodeId, description: "Test operation") // First start the operation to assign a number let startEvent = BuildEvent.operationStarted(context: context) try await consumer.handle(startEvent) // Then progress it let progressEvent = BuildEvent.operationProgress(context: context, fraction: 0.75) try await consumer.handle(progressEvent) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 2) let progressJson = jsonLines[1] #expect(progressJson["type"] as? String == "operation_progress") #expect(progressJson["operation"] as? String == "#1") #expect(progressJson["progress"] as? Double == 0.75) #expect(progressJson["timestamp"] != nil) } @Test("Operation log event produces correct JSON output with message") func testOperationLogEvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let context = ReportContext(nodeId: nodeId, description: "Test operation") // First start the operation to assign a number let startEvent = BuildEvent.operationStarted(context: context) try await consumer.handle(startEvent) // Then log a message let logEvent = BuildEvent.operationLog(context: context, message: "Test log message") try await consumer.handle(logEvent) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 2) let logJson = jsonLines[1] #expect(logJson["type"] as? String == "operation_log") #expect(logJson["operation"] as? String == "#1") #expect(logJson["message"] as? String == "Test log message") #expect(logJson["timestamp"] != nil) } @Test("IR event produces correct JSON output with source map information") func testIREvent() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let sourceMap = SourceMap(file: "test.swift", line: 42, column: 10, snippet: "let x = 1") let context = ReportContext(nodeId: nodeId, stageId: "stage1", description: "IR test", sourceMap: sourceMap) let event = BuildEvent.irEvent(context: context, type: .graphStarted) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] #expect(json["type"] as? String == "ir_event") #expect(json["event_type"] as? String == "graph_started") #expect(json["description"] as? String == "IR test") #expect(json["node_id"] as? String == nodeId.uuidString) #expect(json["stage_id"] as? String == "stage1") #expect(json["timestamp"] != nil) // Check source map let sourceMapJson = json["source_map"] as? [String: Any] #expect(sourceMapJson != nil) #expect(sourceMapJson?["file"] as? String == "test.swift") #expect(sourceMapJson?["line"] as? Int == 42) #expect(sourceMapJson?["column"] as? Int == 10) #expect(sourceMapJson?["snippet"] as? String == "let x = 1") } // MARK: - Operation Number Assignment Tests @Test("Operation number assignment assigns sequential numbers to operations") func testOperationNumberAssignment() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId1 = UUID() let nodeId2 = UUID() let nodeId3 = UUID() let events = [ BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId1, description: "Op 1")), BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId2, description: "Op 2")), BuildEvent.operationStarted(context: ReportContext(nodeId: nodeId3, description: "Op 3")), BuildEvent.operationFinished(context: ReportContext(nodeId: nodeId1, description: "Op 1"), duration: 1.0), BuildEvent.operationFinished(context: ReportContext(nodeId: nodeId2, description: "Op 2"), duration: 2.0), BuildEvent.operationFinished(context: ReportContext(nodeId: nodeId3, description: "Op 3"), duration: 3.0), ] for event in events { try await consumer.handle(event) } writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 6) // Check operation numbers are assigned sequentially #expect(jsonLines[0]["operation"] as? String == "#1") #expect(jsonLines[1]["operation"] as? String == "#2") #expect(jsonLines[2]["operation"] as? String == "#3") #expect(jsonLines[3]["operation"] as? String == "#1") #expect(jsonLines[4]["operation"] as? String == "#2") #expect(jsonLines[5]["operation"] as? String == "#3") } @Test("Operation number consistency maintains same number across operation lifecycle") func testOperationNumberConsistency() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let context = ReportContext(nodeId: nodeId, description: "Test operation") let events = [ BuildEvent.operationStarted(context: context), BuildEvent.operationProgress(context: context, fraction: 0.5), BuildEvent.operationLog(context: context, message: "Progress update"), BuildEvent.operationFinished(context: context, duration: 1.0), ] for event in events { try await consumer.handle(event) } writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 4) // All events should have the same operation number for json in jsonLines { #expect(json["operation"] as? String == "#1") } } // MARK: - Pretty Print Tests @Test("Pretty print formatting produces indented JSON with newlines") func testPrettyPrintFormatting() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: true) let consumer = JSONProgressConsumer(configuration: config) let event = BuildEvent.buildStarted(totalOperations: 5, stages: 2, timestamp: Date()) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) // Pretty printed JSON should contain newlines and indentation #expect(output.contains("{\n")) #expect(output.contains(" ")) #expect(output.contains("\"stages\" : 2")) #expect(output.contains("\"total_operations\" : 5")) #expect(output.contains("\"type\" : \"build_started\"")) } @Test("Compact formatting produces single-line JSON without indentation") func testCompactFormatting() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let event = BuildEvent.buildStarted(totalOperations: 5, stages: 2, timestamp: Date()) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) // Compact JSON should be on a single line let lines = output.split(separator: "\n") #expect(lines.count == 1) #expect(!output.contains(" ")) } // MARK: - Edge Cases and Error Handling @Test("Operation event without node ID produces no output") func testOperationEventWithoutNodeId() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let context = ReportContext(nodeId: nil, description: "Test operation") let event = BuildEvent.operationStarted(context: context) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) // Should produce no output when nodeId is nil #expect(output.isEmpty) } @Test("Operation finished without prior start produces no output") func testOperationFinishedWithoutPriorStart() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let nodeId = UUID() let context = ReportContext(nodeId: nodeId, description: "Test operation") let event = BuildEvent.operationFinished(context: context, duration: 1.0) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) // Should produce no output when operation wasn't started #expect(output.isEmpty) } @Test("IR event with minimal context produces basic JSON output") func testIREventWithMinimalContext() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let context = ReportContext(nodeId: nil, description: "Minimal IR event") let event = BuildEvent.irEvent(context: context, type: .graphStarted) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] #expect(json["type"] as? String == "ir_event") #expect(json["event_type"] as? String == "graph_started") #expect(json["description"] as? String == "Minimal IR event") #expect(json["node_id"] == nil) #expect(json["stage_id"] == nil) #expect(json["source_map"] == nil) } @Test("IR event with partial source map includes only available fields") func testIREventWithPartialSourceMap() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let sourceMap = SourceMap(file: "test.swift", line: nil, column: nil, snippet: nil) let context = ReportContext(nodeId: nil, description: "Partial source map", sourceMap: sourceMap) let event = BuildEvent.irEvent(context: context, type: .graphStarted) try await consumer.handle(event) writeHandle.closeFile() let output = readOutputAsString(from: readHandle) let jsonLines = parseJSONLines(output) #expect(jsonLines.count == 1) let json = jsonLines[0] let sourceMapJson = json["source_map"] as? [String: Any] #expect(sourceMapJson != nil) #expect(sourceMapJson?["file"] as? String == "test.swift") #expect(sourceMapJson?["line"] == nil) #expect(sourceMapJson?["column"] == nil) #expect(sourceMapJson?["snippet"] == nil) } // MARK: - Thread Safety Tests @Test("Thread safety with concurrent operations maintains unique operation numbers") func testThreadSafetyWithConcurrentOperations() async throws { let (writeHandle, readHandle) = createTestPipe() let config = JSONProgressConsumer.Configuration(output: writeHandle, prettyPrint: false) let consumer = JSONProgressConsumer(configuration: config) let operationCount = 100 let nodeIds = (0..