From 8edbf3b4753b157de774cd6fd1758b03cb07a0a8 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:14:05 +0900 Subject: [PATCH 1/2] fix: make CLI completion reflect simulator cleanup --- Docs/generation.md | 10 +- .../PrivateHeaderKitCommand.swift | 95 ++++++-- .../PrivateHeaderKitInteractive.swift | 11 +- .../PrivateHeaderKitRendering.swift | 67 +++++- Sources/PrivateHeaderKitTooling/Simctl.swift | 22 +- .../PrivateHeaderKitCLITests.swift | 204 ++++++++++++++++-- ...ivateHeaderKitProgressRenderingTests.swift | 22 ++ .../ToolingDeterministicTests.swift | 120 +++++++++++ 8 files changed, 490 insertions(+), 61 deletions(-) diff --git a/Docs/generation.md b/Docs/generation.md index da56bae..8ff59c4 100644 --- a/Docs/generation.md +++ b/Docs/generation.md @@ -24,7 +24,10 @@ creates and boots one dedicated simulator device for the run, then deletes that exact device after generation, failure, or interruption. It does not use a connected iPhone or Apple Watch as a generation source. An explicit `--device` selects an existing borrowed simulator instead; PrivateHeaderKit never deletes -that device. +that device. When generation has produced a typed terminal outcome, its final +`Finished` block is rendered after successful cleanup of a dedicated device. If +cleanup fails, the command instead reports the exact device name and UDID and +exits with an error. ## Automation @@ -92,6 +95,11 @@ Consumers should use only the concrete directory printed as `Headers`: /generated-headers/// ``` +The completion summary keeps this consumer path under `Output`. The internal +state database and the full, unabridged run identifier are listed separately +under `Diagnostics`; they are troubleshooting references, not generated-header +locations. + Platform directories use the displayed Apple platform name: `iOS`, `watchOS`, or `macOS`. Release directories include the exact build when it is available: diff --git a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitCommand.swift b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitCommand.swift index eb97411..dc77cce 100644 --- a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitCommand.swift +++ b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitCommand.swift @@ -145,6 +145,11 @@ typealias PrivateHeaderKitReleaseMetadataResolver = @Sendable ( ) throws -> Bool typealias PrivateHeaderKitOutputLogger = @Sendable (String) -> Void +struct PrivateHeaderKitCommandOutcome: Equatable, Sendable { + let exitCode: Int32 + let runStatus: PrivateHeaderGeneration.RunStatus? +} + func resolvePrivateHeaderKitReleaseMetadata( systemRoot: URL, layout: RuntimeRootLayout @@ -303,7 +308,7 @@ func runPrivateHeaderKitGenerateCommand( outputLogger: @escaping PrivateHeaderKitOutputLogger, errorLogger: @escaping PrivateHeaderKitOutputLogger ) async throws -> Int32 { - try await withPrivateHeaderKitSimulatorSession( + let outcome = try await withPrivateHeaderKitSimulatorSession( command, resolver: simulatorResolver, cleaner: simulatorCleaner, @@ -330,25 +335,37 @@ func runPrivateHeaderKitGenerateCommand( errorLogger: errorLogger ) } catch let error as PrivateHeaderGeneration.GenerationError { - if Task.isCancelled { - throw CancellationError() + let cancellationRequested = Task.isCancelled + if cancellationRequested { + guard case .runInterrupted = error else { + throw CancellationError() + } } - renderPrivateHeaderKitGenerationError( + let runStatus = renderPrivateHeaderKitGenerationError( error, sourceDisplayName: request.source.label.displayName, targetQuery: command.targetQuery, screenClearer: resultScreenClearer, outputLogger: errorLogger ) - return 2 + return PrivateHeaderKitCommandOutcome( + exitCode: cancellationRequested ? 130 : 2, + runStatus: runStatus + ) } } catch is CancellationError { throw CancellationError() } catch { errorLogger("error: \(error)") - return 2 + return PrivateHeaderKitCommandOutcome(exitCode: 2, runStatus: nil) } } + renderPrivateHeaderKitCommandOutcome( + outcome, + outputLogger: outputLogger, + errorLogger: errorLogger + ) + return outcome.exitCode } func preparePrivateHeaderKitGenerationRequest( @@ -391,7 +408,7 @@ func runPrivateHeaderKitPreparedGeneration( resultScreenClearer: PrivateHeaderKitInteractiveScreenClearer?, outputLogger: @escaping PrivateHeaderKitOutputLogger, errorLogger: @escaping PrivateHeaderKitOutputLogger -) async throws -> Int32 { +) async throws -> PrivateHeaderKitCommandOutcome { do { let result = try await preparedGeneration.run( resumeBehavior, @@ -409,24 +426,30 @@ func runPrivateHeaderKitPreparedGeneration( title: "Generation completed", outputLogger: outputLogger ) - return 0 + return PrivateHeaderKitCommandOutcome(exitCode: 0, runStatus: result.summary.status) } catch let error as PrivateHeaderGeneration.GenerationError { - if Task.isCancelled { - throw CancellationError() + let cancellationRequested = Task.isCancelled + if cancellationRequested { + guard case .runInterrupted = error else { + throw CancellationError() + } } - renderPrivateHeaderKitGenerationError( + let runStatus = renderPrivateHeaderKitGenerationError( error, sourceDisplayName: request.source.label.displayName, targetQuery: targetQuery, screenClearer: resultScreenClearer, outputLogger: errorLogger ) - return 2 + return PrivateHeaderKitCommandOutcome( + exitCode: cancellationRequested ? 130 : 2, + runStatus: runStatus + ) } catch is CancellationError { throw CancellationError() } catch { errorLogger("error: \(error)") - return 2 + return PrivateHeaderKitCommandOutcome(exitCode: 2, runStatus: nil) } } @@ -1085,8 +1108,13 @@ func withPrivateHeaderKitSimulatorSession( if command.platform.simulatorPlatform == nil { resolution = nil } else { + renderPrivateHeaderKitSimulatorPreparation(command, outputLogger: outputLogger) let resolved = try await resolver(command) - outputLogger("selected simulator: \(resolved.deviceName) (\(resolved.deviceUDID))") + renderPrivateHeaderKitSimulatorReady( + resolved, + command: command, + outputLogger: outputLogger + ) resolution = resolved } @@ -1097,9 +1125,15 @@ func withPrivateHeaderKitSimulatorSession( let operationError = error if let resolution { do { - try await finishPrivateHeaderKitSimulatorSession(resolution, cleaner: cleaner) + try await finishPrivateHeaderKitSimulatorSession( + resolution, + cleaner: cleaner, + outputLogger: outputLogger + ) } catch { throw PrivateHeaderKitSimulatorSessionCleanupError( + deviceName: resolution.deviceName, + deviceUDID: resolution.deviceUDID, operationError: String(describing: operationError), cleanupError: String(describing: error) ) @@ -1108,28 +1142,49 @@ func withPrivateHeaderKitSimulatorSession( throw operationError } if let resolution { - try await finishPrivateHeaderKitSimulatorSession(resolution, cleaner: cleaner) + do { + try await finishPrivateHeaderKitSimulatorSession( + resolution, + cleaner: cleaner, + outputLogger: outputLogger + ) + } catch { + throw PrivateHeaderKitSimulatorSessionCleanupError( + deviceName: resolution.deviceName, + deviceUDID: resolution.deviceUDID, + operationError: nil, + cleanupError: String(describing: error) + ) + } } return result } -private struct PrivateHeaderKitSimulatorSessionCleanupError: Error, CustomStringConvertible { - let operationError: String +struct PrivateHeaderKitSimulatorSessionCleanupError: Error, CustomStringConvertible { + let deviceName: String + let deviceUDID: String + let operationError: String? let cleanupError: String var description: String { - "simulator cleanup failed after \(operationError): \(cleanupError)" + let device = "\(deviceName) (UDID: \(deviceUDID))" + guard let operationError else { + return "simulator cleanup failed for \(device): \(cleanupError)" + } + return "simulator cleanup failed for \(device) after \(operationError): \(cleanupError)" } } private func finishPrivateHeaderKitSimulatorSession( _ resolution: PrivateHeaderKitSimulatorResolution, - cleaner: @escaping PrivateHeaderKitSimulatorCleaner + cleaner: @escaping PrivateHeaderKitSimulatorCleaner, + outputLogger: @escaping PrivateHeaderKitOutputLogger ) async throws { guard resolution.deviceOwnership == .runOwned else { return } try await Task.detached { try await cleaner(resolution) }.value + renderPrivateHeaderKitSimulatorCleanup(outputLogger: outputLogger) } func cleanupPrivateHeaderKitSimulator( diff --git a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitInteractive.swift b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitInteractive.swift index aecb427..7b5bdd6 100644 --- a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitInteractive.swift +++ b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitInteractive.swift @@ -163,7 +163,7 @@ func runPrivateHeaderKitInteractiveGenerate( simulatorHelperPath: nil ) do { - return try await withPrivateHeaderKitSimulatorSession( + let outcome = try await withPrivateHeaderKitSimulatorSession( command, resolver: simulatorResolver, cleaner: simulatorCleaner, @@ -197,6 +197,12 @@ func runPrivateHeaderKitInteractiveGenerate( errorLogger: errorLogger ) } + renderPrivateHeaderKitCommandOutcome( + outcome, + outputLogger: outputLogger, + errorLogger: errorLogger + ) + return outcome.exitCode } catch PrivateHeaderKitInteractiveNavigation.back { continue targetSelection } @@ -204,6 +210,9 @@ func runPrivateHeaderKitInteractiveGenerate( } } catch is CancellationError { throw CancellationError() + } catch let error as PrivateHeaderKitSimulatorSessionCleanupError { + errorLogger("error: \(error)") + return 2 } catch let error as PrivateHeaderKitCLIError { errorLogger("error: \(error.description)") return 1 diff --git a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitRendering.swift b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitRendering.swift index 8809748..6ef615b 100644 --- a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitRendering.swift +++ b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitRendering.swift @@ -1,6 +1,7 @@ import Dispatch import Foundation import PrivateHeaderKitCore +import PrivateHeaderKitTooling #if canImport(Darwin) import Darwin @@ -72,7 +73,7 @@ final class PrivateHeaderKitProgressOutputLogger: @unchecked Sendable { switch event { case .runStarted(let runID, let totalTargetCount): outputLogger( - "Generation \(shortenedRunID(runID.rawValue)): \(totalTargetCount) targets → " + "Generation \(runID.rawValue): \(totalTargetCount) targets → " + artifactDirectory.path ) case .targetStarted(let index, let total, let displayName): @@ -315,13 +316,14 @@ func concisePrivateHeaderKitDiagnostic(_ message: String) -> String { return String(sanitized.prefix(maximumLength - 1)) + "…" } +@discardableResult func renderPrivateHeaderKitGenerationError( _ error: PrivateHeaderGeneration.GenerationError, sourceDisplayName: String, targetQuery: String, screenClearer: PrivateHeaderKitInteractiveScreenClearer?, outputLogger: PrivateHeaderKitOutputLogger -) { +) -> PrivateHeaderGeneration.RunStatus? { switch error { case .runFailed(let failure): screenClearer?() @@ -333,6 +335,7 @@ func renderPrivateHeaderKitGenerationError( failedTargetIDs: failure.failedTargetIDs, outputLogger: outputLogger ) + return failure.summary.status case .runInterrupted(let interruption): renderPrivateHeaderKitRunSummary( interruption.summary, @@ -341,6 +344,7 @@ func renderPrivateHeaderKitGenerationError( title: "Generation interrupted", outputLogger: outputLogger ) + return interruption.summary.status case .infrastructureFailed(let failure): screenClearer?() renderPrivateHeaderKitRunSummary( @@ -351,11 +355,14 @@ func renderPrivateHeaderKitGenerationError( infrastructureMessage: failure.message, outputLogger: outputLogger ) + return failure.summary.status case .resumeRequired: outputLogger("error: \(error.description)") outputLogger("rerun with `--resume` to continue or `--fresh` to restart") + return nil default: outputLogger("error: \(error.description)") + return nil } } @@ -430,8 +437,54 @@ func renderPrivateHeaderKitRunSummary( outputLogger("") outputLogger("Output") outputLogger(formatResultField("Headers", summary.artifactDirectory.path)) + + outputLogger("") + outputLogger("Diagnostics") outputLogger(formatResultField("State", summary.stateDatabaseURL.path)) - outputLogger(formatResultField("Run", shortenedRunID(summary.runID.rawValue))) + outputLogger(formatResultField("Run", summary.runID.rawValue)) +} + +func renderPrivateHeaderKitSimulatorPreparation( + _ command: PrivateHeaderKitGenerateCommand, + outputLogger: PrivateHeaderKitOutputLogger +) { + outputLogger("Preparing simulator for \(command.platform.rawValue) \(command.version)...") +} + +func renderPrivateHeaderKitSimulatorReady( + _ resolution: PrivateHeaderKitSimulatorResolution, + command: PrivateHeaderKitGenerateCommand, + outputLogger: PrivateHeaderKitOutputLogger +) { + let description: String + switch resolution.deviceOwnership { + case .borrowed: + description = "\(resolution.deviceName) (UDID: \(resolution.deviceUDID))" + case .runOwned: + description = + "temporary \(command.platform.rawValue) \(resolution.runtimeVersion) device " + + "(UDID: \(resolution.deviceUDID))" + } + outputLogger("Simulator ready: \(description)") +} + +func renderPrivateHeaderKitSimulatorCleanup( + outputLogger: PrivateHeaderKitOutputLogger +) { + outputLogger("") + outputLogger("Cleanup") + outputLogger(formatResultField("Simulator", "Temporary device deleted")) +} + +func renderPrivateHeaderKitCommandOutcome( + _ outcome: PrivateHeaderKitCommandOutcome, + outputLogger: PrivateHeaderKitOutputLogger, + errorLogger: PrivateHeaderKitOutputLogger +) { + let logger = outcome.exitCode == 0 ? outputLogger : errorLogger + logger("") + logger("Finished") + logger(formatResultField("Status", outcome.runStatus?.rawValue ?? "failed")) } private func formatResultMetric(_ label: String, _ value: Int) -> String { @@ -457,14 +510,6 @@ private func formattedTargetQuery(_ query: String) -> String { .joined(separator: ", ") } -private func shortenedRunID(_ runID: String) -> String { - let maximumLength = 36 - guard runID.count > maximumLength else { - return runID - } - return String(runID.prefix(maximumLength - 1)) + "…" -} - func logCLIError(_ message: String) { FileHandle.standardError.write(Data((message + "\n").utf8)) } diff --git a/Sources/PrivateHeaderKitTooling/Simctl.swift b/Sources/PrivateHeaderKitTooling/Simctl.swift index 6b6a445..99f79eb 100644 --- a/Sources/PrivateHeaderKitTooling/Simctl.swift +++ b/Sources/PrivateHeaderKitTooling/Simctl.swift @@ -287,7 +287,9 @@ public enum Simctl { }.result if case .failure(let cleanupError) = cleanupResult { throw ToolingError.message( - "simulator acquisition failed: \(error); cleanup also failed: \(cleanupError)" + "simulator acquisition failed for run-owned simulator \(created.name) " + + "(UDID: \(created.udid)): \(error); cleanup also failed: " + + "\(cleanupError)" ) } throw error @@ -300,7 +302,6 @@ public enum Simctl { force: Bool ) async throws -> DeviceInfo { if stateEquals(device.state, "Booted"), !force { return device } - print("Booting simulator: \(device.name) (\(device.udid))") try await runner.runSimple(["xcrun", "simctl", "boot", device.udid], env: nil, cwd: nil) try await runner.runSimple(["xcrun", "simctl", "bootstatus", device.udid, "-b"], env: nil, cwd: nil) var booted = device @@ -350,7 +351,6 @@ public enum Simctl { throw ToolingError.message("no device types available") } - print("Creating device: \(name)") let output: String do { output = try await runner.runCapture( @@ -369,7 +369,8 @@ public enum Simctl { }.result if case .failure(let cleanupError) = cleanupResult { throw ToolingError.message( - "simulator creation failed: \(createError); cleanup also failed: \(cleanupError)" + "simulator creation failed for run-owned simulator \(name): \(createError); " + + "cleanup also failed: \(cleanupError)" ) } throw createError @@ -392,7 +393,8 @@ public enum Simctl { }.result if case .failure(let cleanupError) = cleanupResult { throw ToolingError.message( - "\(contractError); cleanup also failed: \(cleanupError)" + "\(contractError) for run-owned simulator \(name); cleanup also failed: " + + "\(cleanupError)" ) } throw contractError @@ -404,7 +406,6 @@ public enum Simctl { _ device: DeviceInfo, runner: CommandRunning ) async throws { - print("Deleting simulator: \(device.name) (\(device.udid))") try await runner.runSimple( ["xcrun", "simctl", "delete", device.udid], env: nil, @@ -425,7 +426,14 @@ public enum Simctl { ) } if let device = matches.first { - try await deleteDevice(device, runner: runner) + do { + try await deleteDevice(device, runner: runner) + } catch { + throw ToolingError.message( + "failed to delete run-owned simulator \(device.name) " + + "(UDID: \(device.udid)): \(error)" + ) + } } } diff --git a/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift b/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift index 56e06e5..ce24dac 100644 --- a/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift +++ b/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift @@ -217,6 +217,7 @@ struct PrivateHeaderKitCLIExecutionTests { let cleanupCount = ThreadSafeCounter() let operationStarted = EventCounter() let cleanupWasCancelled = ThreadSafeBool() + let cancellationOutput = ThreadSafeStrings() let owned = PrivateHeaderKitSimulatorResolution( runtimeVersion: testPrivateHeaderKitSimulatorResolution.runtimeVersion, runtimeBuild: testPrivateHeaderKitSimulatorResolution.runtimeBuild, @@ -238,7 +239,7 @@ struct PrivateHeaderKitCLIExecutionTests { } cleanupCount.increment() }, - outputLogger: { _ in }, + outputLogger: cancellationOutput.append, operation: { _ in operationStarted.signal() while true { @@ -255,18 +256,30 @@ struct PrivateHeaderKitCLIExecutionTests { } #expect(cleanupCount.value == 1) #expect(!cleanupWasCancelled.value) + #expect(!cancellationOutput.text.contains(owned.deviceName)) + #expect(cancellationOutput.text.contains("UDID: \(owned.deviceUDID)")) + #expect(cancellationOutput.text.contains("Temporary device deleted")) + let borrowedOutput = ThreadSafeStrings() let value: Int = try await withPrivateHeaderKitSimulatorSession( iosGenerateCommand(build: nil, systemRoot: nil), resolver: { _ in testPrivateHeaderKitSimulatorResolution }, cleaner: { _ in cleanupCount.increment() }, - outputLogger: { _ in }, + outputLogger: borrowedOutput.append, operation: { _ in 7 } ) #expect(value == 7) #expect(cleanupCount.value == 1) + #expect( + borrowedOutput.text.contains( + "Simulator ready: \(testPrivateHeaderKitSimulatorResolution.deviceName) " + + "(UDID: \(testPrivateHeaderKitSimulatorResolution.deviceUDID))" + ) + ) + #expect(!borrowedOutput.text.contains("Cleanup")) - await #expect(throws: CLIFixtureError.self) { + let failedCleanupOutput = ThreadSafeStrings() + do { let _: Int = try await withPrivateHeaderKitSimulatorSession( iosGenerateCommand(build: nil, systemRoot: nil), resolver: { _ in owned }, @@ -274,11 +287,19 @@ struct PrivateHeaderKitCLIExecutionTests { cleanupCount.increment() throw CLIFixtureError.cleanupFailed }, - outputLogger: { _ in }, + outputLogger: failedCleanupOutput.append, operation: { _ in 9 } ) + Issue.record("successful operation unexpectedly hid simulator cleanup failure") + } catch { + let message = String(describing: error) + #expect(message.contains("simulator cleanup failed for")) + #expect(message.contains(owned.deviceName)) + #expect(message.contains("UDID: \(owned.deviceUDID)")) + #expect(message.contains("cleanupFailed")) } #expect(cleanupCount.value == 2) + #expect(!failedCleanupOutput.text.contains("Temporary device deleted")) } @Test func simulatorCleanupFailureOverridesInteractiveBack() async { @@ -305,8 +326,12 @@ struct PrivateHeaderKitCLIExecutionTests { } catch is PrivateHeaderKitInteractiveNavigation { Issue.record("interactive Back unexpectedly won over simulator cleanup failure") } catch { - #expect(String(describing: error).contains("simulator cleanup failed after")) - #expect(String(describing: error).contains("cleanupFailed")) + let message = String(describing: error) + #expect(message.contains("simulator cleanup failed for")) + #expect(message.contains("after")) + #expect(message.contains(owned.deviceName)) + #expect(message.contains("UDID: \(owned.deviceUDID)")) + #expect(message.contains("cleanupFailed")) } } @@ -669,6 +694,9 @@ struct PrivateHeaderKitCLIExecutionTests { let preparationCount = ThreadSafeCounter() let summaryInspectionCount = ThreadSafeCounter() let output = ThreadSafeStrings() + let runID = PrivateHeaderGeneration.RunID( + rawValue: "run-ad657944-4255-4460-8571-9f5892b75637" + ) let status = await runPrivateHeaderKitCommand( [ "privateheaderkit", @@ -689,7 +717,7 @@ struct PrivateHeaderKitCLIExecutionTests { run: { request, _, progress in requestBox.set(request) progress(.runStarted( - runID: PrivateHeaderGeneration.RunID(rawValue: "run-typed"), + runID: runID, totalTargetCount: 3 )) return resultFixture( @@ -705,7 +733,8 @@ struct PrivateHeaderKitCLIExecutionTests { relativePath: "Frameworks/AppKit/Headers/Generated.h", message: "preserved unowned artifact" ), - ] + ], + runID: runID ) } ), @@ -733,6 +762,12 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(output.text.contains("generation.sqlite")) let headersPath = "/tmp/PrivateHeaderKit/generated-headers/macOS/16.0" #expect(output.text.components(separatedBy: headersPath).count == 3) + #expect(output.text.contains("Generation \(runID.rawValue): 3 targets")) + #expect(output.text.contains("Output\n Headers")) + #expect(output.text.contains("Diagnostics\n State")) + #expect(output.text.contains(" Run \(runID.rawValue)")) + #expect(!output.text.contains("run-ad657944-4255-4460-8571-9f5892b…")) + #expect(output.text.hasSuffix("Finished\n Status completed")) #expect(!output.text.contains("manifest.json")) #expect(!output.text.contains("run.json")) } @@ -777,14 +812,53 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(request.options.producerVersion == PrivateHeaderKitBuildInfo.version) } - @Test func runFailureUsesTypedSummaryWithoutReadingStateFiles() async { + @Test func runOwnedSuccessCleansBeforeCompletedFinalStatus() async throws { let output = ThreadSafeStrings() + let errors = ThreadSafeStrings() let status = await runPrivateHeaderKitCommand( [ "privateheaderkit", - "--platform", "macOS", - "--version", "16.0", - "--system-root", "/SystemRoot", + "--platform", "iOS", + "--version", "27.0", + "--out", "/tmp/PrivateHeaderKit", + "--target", "AppKit", + ], + currentExecutableURL: URL(fileURLWithPath: "/cohort/privateheaderkit"), + generationClient: testPrivateHeaderKitGenerationClient { request, _, _ in + resultFixture( + for: request, + counts: .init(total: 1, completed: 1) + ) + }, + simulatorResolver: { _ in testPrivateHeaderKitRunOwnedSimulatorResolution }, + simulatorCleaner: { _ in output.append("cleanup command completed") }, + helperResolver: testPrivateHeaderKitHelperResolver, + releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver, + outputLogger: output.append, + errorLogger: errors.append + ) + + #expect(status == 0) + #expect(errors.text.isEmpty) + #expect(!output.text.contains(testPrivateHeaderKitRunOwnedSimulatorResolution.deviceName)) + let rendered = output.text + let summary = try #require(rendered.range(of: "Generation completed")) + let cleanupCommand = try #require(rendered.range(of: "cleanup command completed")) + let cleanupResult = try #require(rendered.range(of: "Cleanup")) + let finished = try #require(rendered.range(of: "Finished")) + #expect(summary.lowerBound < cleanupCommand.lowerBound) + #expect(cleanupCommand.lowerBound < cleanupResult.lowerBound) + #expect(cleanupResult.lowerBound < finished.lowerBound) + #expect(rendered.hasSuffix("Finished\n Status completed")) + } + + @Test func runFailureUsesTypedSummaryWithoutReadingStateFiles() async throws { + let output = ThreadSafeStrings() + let status = await runPrivateHeaderKitCommand( + [ + "privateheaderkit", + "--platform", "iOS", + "--version", "27.0", "--out", "/does/not/exist", "--target", "AppKit", ], @@ -793,7 +867,7 @@ struct PrivateHeaderKitCLIExecutionTests { run: { request, _, _ in let summary = summaryFixture( for: request, - status: .failed, + status: .partial, counts: PrivateHeaderGeneration.TargetCounts( total: 2, completed: 1, @@ -808,6 +882,8 @@ struct PrivateHeaderKitCLIExecutionTests { ) } ), + simulatorResolver: { _ in testPrivateHeaderKitRunOwnedSimulatorResolution }, + simulatorCleaner: { _ in output.append("cleanup command completed") }, helperResolver: testPrivateHeaderKitHelperResolver, releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver, outputLogger: output.append, @@ -818,9 +894,26 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(output.text.contains("framework:AppKit.framework")) #expect( output.text.contains( - "/does/not/exist/.state/macos-v1-16.0-b0/generation.sqlite" + "/does/not/exist/.state/ios-v1-27.0-b1-24~41123/generation.sqlite" ) ) + #expect(!output.text.contains(testPrivateHeaderKitRunOwnedSimulatorResolution.deviceName)) + #expect( + output.text.contains( + "UDID: \(testPrivateHeaderKitRunOwnedSimulatorResolution.deviceUDID)" + ) + ) + let rendered = output.text + let summaryRange = try #require( + rendered.range(of: "Generation completed with failures") + ) + let cleanupCommand = try #require(rendered.range(of: "cleanup command completed")) + let cleanupResult = try #require(rendered.range(of: "Cleanup")) + let finished = try #require(rendered.range(of: "Finished")) + #expect(summaryRange.lowerBound < cleanupCommand.lowerBound) + #expect(cleanupCommand.lowerBound < cleanupResult.lowerBound) + #expect(cleanupResult.lowerBound < finished.lowerBound) + #expect(rendered.hasSuffix("Finished\n Status partial")) } @Test func interruptionAndInfrastructureErrorsRenderTheirTypedSummaries() async { @@ -874,7 +967,7 @@ struct PrivateHeaderKitCLIExecutionTests { } } - @Test func cancelledCoreInterruptionReturns130InsteadOfGenericFailure() async { + @Test func cancelledCoreInterruptionReturns130AfterRunOwnedCleanup() async throws { let generationStarted = EventCounter() let cancellationObserved = EventCounter() let output = ThreadSafeStrings() @@ -882,9 +975,8 @@ struct PrivateHeaderKitCLIExecutionTests { await runPrivateHeaderKitCommand( [ "privateheaderkit", - "--platform", "macOS", - "--version", "16.0", - "--system-root", "/SystemRoot", + "--platform", "iOS", + "--version", "27.0", "--out", "/tmp/cancelled-core", "--target", "all", ], @@ -906,6 +998,8 @@ struct PrivateHeaderKitCLIExecutionTests { PrivateHeaderGeneration.RunInterruption(summary: summary) ) }, + simulatorResolver: { _ in testPrivateHeaderKitRunOwnedSimulatorResolution }, + simulatorCleaner: { _ in output.append("cleanup command completed") }, helperResolver: testPrivateHeaderKitHelperResolver, releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver, outputLogger: output.append, @@ -916,7 +1010,16 @@ struct PrivateHeaderKitCLIExecutionTests { await generationStarted.wait(until: 1) task.cancel() #expect(await task.value == 130) - #expect(!output.text.contains("Generation interrupted")) + #expect(output.text.contains("Generation interrupted")) + let rendered = output.text + let summary = try #require(rendered.range(of: "Generation interrupted")) + let cleanupCommand = try #require(rendered.range(of: "cleanup command completed")) + let cleanupResult = try #require(rendered.range(of: "Cleanup")) + let finished = try #require(rendered.range(of: "Finished")) + #expect(summary.lowerBound < cleanupCommand.lowerBound) + #expect(cleanupCommand.lowerBound < cleanupResult.lowerBound) + #expect(cleanupResult.lowerBound < finished.lowerBound) + #expect(rendered.hasSuffix("Finished\n Status interrupted")) #expect(!output.text.contains("error:")) } @@ -1862,6 +1965,52 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(preparationCount.value == 1) } + @Test func interactiveSimulatorCleanupFailureReturnsOperationalFailure() async { + let input = ScriptedInput(["1", "1"]) + let output = ThreadSafeStrings() + let status = await runPrivateHeaderKitCommand( + ["privateheaderkit"], + currentExecutableURL: URL(fileURLWithPath: "/cohort/privateheaderkit"), + generationClient: testPrivateHeaderKitGenerationClient { request, _, _ in + resultFixture( + for: request, + counts: .init(total: 1, completed: 1) + ) + }, + simulatorResolver: { _ in testPrivateHeaderKitRunOwnedSimulatorResolution }, + simulatorCleaner: { _ in throw CLIFixtureError.cleanupFailed }, + helperResolver: testPrivateHeaderKitHelperResolver, + releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver, + interactiveSourceProvider: { + [ + PrivateHeaderKitInteractiveSource( + platform: .iOS, + version: "27.0", + build: "24A123", + systemRoot: nil + ), + ] + }, + interactiveOutputBaseDirectoryProvider: { "/tmp/PrivateHeaderKit" }, + interactiveScreenClearer: {}, + inputReader: { try await input.readLine() }, + outputLogger: output.append, + errorLogger: output.append + ) + + #expect(status == 2) + #expect(output.text.contains("Generation completed")) + #expect(output.text.contains("simulator cleanup failed for")) + #expect(output.text.contains(testPrivateHeaderKitRunOwnedSimulatorResolution.deviceName)) + #expect( + output.text.contains( + "UDID: \(testPrivateHeaderKitRunOwnedSimulatorResolution.deviceUDID)" + ) + ) + #expect(!output.text.contains("Temporary device deleted")) + #expect(!output.text.contains("Finished")) + } + @Test func interactiveIncompatibleResumeRestartsTheSamePreparedGeneration() async throws { let root = try temporaryDirectory() defer { try? FileManager.default.removeItem(at: root) } @@ -2020,6 +2169,18 @@ private let testPrivateHeaderKitSimulatorResolution = PrivateHeaderKitSimulatorR deviceUDID: "SIM-001" ) +private let testPrivateHeaderKitRunOwnedSimulatorResolution = + PrivateHeaderKitSimulatorResolution( + runtimeVersion: "27.0", + runtimeBuild: "24A123", + runtimeIdentifier: "com.apple.CoreSimulator.SimRuntime.iOS-27-0", + resolvedRuntimeRoot: "/ResolvedRuntime", + metadataIsSeed: false, + deviceName: "PrivateHeaderKit Dump (iOS 27.0) session-001", + deviceUDID: "11111111-2222-3333-4444-555555555555", + deviceOwnership: .runOwned + ) + private let testPrivateHeaderKitWatchSimulatorResolution = PrivateHeaderKitSimulatorResolution( runtimeVersion: "27.0", runtimeBuild: "24R5325f", @@ -3523,7 +3684,8 @@ private func assertInteractiveLegacyMigration(kind: LegacyInputKind) async throw private func resultFixture( for request: PrivateHeaderKitGenerationRequest, counts: PrivateHeaderGeneration.TargetCounts, - warnings: [PrivateHeaderGeneration.GenerationWarning] = [] + warnings: [PrivateHeaderGeneration.GenerationWarning] = [], + runID: PrivateHeaderGeneration.RunID = .init(rawValue: "run-typed") ) -> PrivateHeaderGeneration.Result { let plan = PrivateHeaderGeneration.makePlan( source: request.source, @@ -3536,7 +3698,7 @@ private func resultFixture( generatedTargets: (0.. Date: Thu, 27 Aug 2026 20:19:58 +0900 Subject: [PATCH 2/2] fix: only finish typed generation outcomes --- .../PrivateHeaderKitRendering.swift | 3 +- .../PrivateHeaderKitCLITests.swift | 29 +++++++++++++++++++ ...ivateHeaderKitProgressRenderingTests.swift | 11 +++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitRendering.swift b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitRendering.swift index 6ef615b..8620625 100644 --- a/Sources/PrivateHeaderKitCLI/PrivateHeaderKitRendering.swift +++ b/Sources/PrivateHeaderKitCLI/PrivateHeaderKitRendering.swift @@ -481,10 +481,11 @@ func renderPrivateHeaderKitCommandOutcome( outputLogger: PrivateHeaderKitOutputLogger, errorLogger: PrivateHeaderKitOutputLogger ) { + guard let runStatus = outcome.runStatus else { return } let logger = outcome.exitCode == 0 ? outputLogger : errorLogger logger("") logger("Finished") - logger(formatResultField("Status", outcome.runStatus?.rawValue ?? "failed")) + logger(formatResultField("Status", runStatus.rawValue)) } private func formatResultMetric(_ label: String, _ value: Int) -> String { diff --git a/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift b/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift index ce24dac..f389c5e 100644 --- a/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift +++ b/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitCLITests.swift @@ -916,6 +916,35 @@ struct PrivateHeaderKitCLIExecutionTests { #expect(rendered.hasSuffix("Finished\n Status partial")) } + @Test func resumeRequiredDoesNotInventATerminalRunStatus() async throws { + let resumeSummary = try await unfinishedResumeSummaryFixture() + let output = ThreadSafeStrings() + let status = await runPrivateHeaderKitCommand( + [ + "privateheaderkit", + "--platform", "macOS", + "--version", "16.0", + "--system-root", "/SystemRoot", + "--out", "/tmp/resume-required", + "--target", "all", + ], + currentExecutableURL: URL(fileURLWithPath: "/cohort/privateheaderkit"), + generationClient: testPrivateHeaderKitGenerationClient { _, _, _ in + throw PrivateHeaderGeneration.GenerationError.resumeRequired(resumeSummary) + }, + helperResolver: testPrivateHeaderKitHelperResolver, + releaseMetadataResolver: testPrivateHeaderKitReleaseMetadataResolver, + outputLogger: output.append, + errorLogger: output.append + ) + + #expect(status == 2) + #expect(output.text.contains("explicit resume is required")) + #expect(output.text.contains("rerun with `--resume`")) + #expect(!output.text.contains("Finished")) + #expect(!output.text.contains("Status failed")) + } + @Test func interruptionAndInfrastructureErrorsRenderTheirTypedSummaries() async { for kind in [FailureKind.interrupted, .infrastructure] { let output = ThreadSafeStrings() diff --git a/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitProgressRenderingTests.swift b/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitProgressRenderingTests.swift index 5b3a4f5..2fb2b3e 100644 --- a/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitProgressRenderingTests.swift +++ b/Tests/PrivateHeaderKitCLITests/PrivateHeaderKitProgressRenderingTests.swift @@ -294,6 +294,17 @@ struct PrivateHeaderKitProgressRenderingTests { ) #expect(errors.values == ["", "Finished", " Status partial"]) + + let noRunOutput = ProgressTextRecorder() + let noRunErrors = ProgressTextRecorder() + renderPrivateHeaderKitCommandOutcome( + .init(exitCode: 2, runStatus: nil), + outputLogger: { noRunOutput.append($0) }, + errorLogger: { noRunErrors.append($0) } + ) + + #expect(noRunOutput.values.isEmpty) + #expect(noRunErrors.values.isEmpty) } }