import Foundation enum CoreClientError: LocalizedError { case projectNotFound case commandFailed(String) var errorDescription: String? { switch self { case .projectNotFound: return "未找到 bin/datatest,请从项目目录启动应用。" case .commandFailed(let message): return message } } } struct CoreClient: Sendable { private func searchProjectRoot(startingAt start: URL) -> URL? { var candidate = start for _ in 0..<6 { if FileManager.default.fileExists(atPath: candidate.appendingPathComponent("bin/datatest").path) { return candidate } candidate.deleteLastPathComponent() } return nil } private func projectRoot() throws -> URL { let workingDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) if let root = searchProjectRoot(startingAt: workingDirectory) { return root } let executable = URL(fileURLWithPath: CommandLine.arguments[0]).standardizedFileURL if let root = searchProjectRoot(startingAt: executable.deletingLastPathComponent()) { return root } throw CoreClientError.projectNotFound } private func run(_ arguments: [String]) async throws -> Data { try await Task.detached(priority: .userInitiated) { let root = try projectRoot() let process = Process() let output = Pipe() let errors = Pipe() process.executableURL = root.appendingPathComponent("bin/datatest") process.arguments = ["--home", root.appendingPathComponent(".datatest").path, "--compact"] + arguments process.currentDirectoryURL = root process.standardOutput = output process.standardError = errors try process.run() // Drain both pipes while the child is running. Waiting for the process // first can deadlock once dashboard JSON grows beyond the pipe buffer. let outputReader = Task.detached(priority: .utility) { output.fileHandleForReading.readDataToEndOfFile() } let errorReader = Task.detached(priority: .utility) { errors.fileHandleForReading.readDataToEndOfFile() } process.waitUntilExit() let data = await outputReader.value let errorData = await errorReader.value if process.terminationStatus != 0 { let standardOutput = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let standardError = String(data: errorData, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let message = !standardOutput.isEmpty ? standardOutput : (!standardError.isEmpty ? standardError : "DataTest 核心服务执行失败") throw CoreClientError.commandFailed(message) } return data }.value } func dashboard() async throws -> Dashboard { let data = try await run(["dashboard"]) return try JSONDecoder().decode(Dashboard.self, from: data) } func initializeDemo() async throws { _ = try await run(["demo"]) } func initializeComplexDemo() async throws { _ = try await run(["complex-demo"]) } func importRequirement(fileURL: URL, requirementID: String, requirementName: String) async throws { _ = try await run([ "requirement-import", fileURL.path, "--project-id", "PROJECT-LOCAL", "--project-name", "本地 ETL 测试项目", "--requirement-id", requirementID, "--requirement-name", requirementName, ]) } func parseRequirement(requirementID: String, context: String? = nil) async throws { var arguments = ["ai-parse", requirementID] if let context, !context.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { arguments += ["--context", context] } _ = try await run(arguments) } func confirmRequirement(requirementID: String) async throws { _ = try await run(["requirement-confirm", requirementID]) } func refreshMetadata(requirementID: String) async throws { _ = try await run(["metadata", requirementID, "--refresh"]) } func generateCases( requirementID: String ) -> AsyncThrowingStream { streamProgress( arguments: ["ai-generate-cases-stream", requirementID], fallbackError: "Codex 测试案例生成失败" ) } func approveCase(caseID: String) async throws { _ = try await run(["case-approve", caseID]) } func approveAllCases(requirementID: String) async throws { _ = try await run(["case-approve-all", requirementID]) } func rejectCase(caseID: String) async throws { _ = try await run(["case-reject", caseID]) } func chatAboutCases( requirementID: String, message: String ) -> AsyncThrowingStream { streamProgress( arguments: ["case-chat-stream", requirementID, message], fallbackError: "Codex 案例协作执行失败" ) } private func streamProgress( arguments: [String], fallbackError: String ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task.detached(priority: .userInitiated) { do { let root = try projectRoot() let process = Process() let output = Pipe() let errors = Pipe() process.executableURL = root.appendingPathComponent("bin/datatest") process.arguments = [ "--home", root.appendingPathComponent(".datatest").path, "--compact", ] + arguments process.currentDirectoryURL = root process.standardOutput = output process.standardError = errors try process.run() var buffer = "" var streamedError: String? func consumeLine(_ line: String) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8), let event = try? JSONDecoder().decode( CaseChatProgressEvent.self, from: data ) else { return } if event.type == "error" { streamedError = event.detail } continuation.yield(event) } while true { let data = output.fileHandleForReading.availableData if data.isEmpty { break } buffer.append(String(decoding: data, as: UTF8.self)) while let newline = buffer.firstIndex(of: "\n") { consumeLine(String(buffer[.. AsyncThrowingStream { runCasesStream(requirementID: requirementID, caseID: nil) } func runSingleCase( requirementID: String, caseID: String ) -> AsyncThrowingStream { runCasesStream(requirementID: requirementID, caseID: caseID) } private func runCasesStream( requirementID: String, caseID: String? ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in Task.detached(priority: .userInitiated) { do { let root = try projectRoot() let process = Process() let output = Pipe() let errors = Pipe() process.executableURL = root.appendingPathComponent("bin/datatest") var arguments = [ "--home", root.appendingPathComponent(".datatest").path, "--compact", "run-stream", requirementID, ] if let caseID { arguments += ["--case", caseID] } process.arguments = arguments process.currentDirectoryURL = root process.standardOutput = output process.standardError = errors try process.run() var buffer = "" var streamedError: String? func consumeLine(_ line: String) { let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let data = trimmed.data(using: .utf8), let event = try? JSONDecoder().decode( TestRunProgressEvent.self, from: data ) else { return } if event.type == "error" { streamedError = event.detail } continuation.yield(event) } while true { let data = output.fileHandleForReading.availableData if data.isEmpty { break } buffer.append(String(decoding: data, as: UTF8.self)) while let newline = buffer.firstIndex(of: "\n") { consumeLine(String(buffer[.. String ) { isLoading = true workflowAction = progress message = nil Task { do { let successMessage = try await operation() dashboard = try await client.dashboard() message = successMessage } catch { message = error.localizedDescription } workflowAction = nil isLoading = false } } func runTests(requirementID: String) { startTestRun(requirementID: requirementID, caseID: nil) } func runSingleCase(requirementID: String, caseID: String) { startTestRun(requirementID: requirementID, caseID: caseID) } private func startTestRun(requirementID: String, caseID: String?) { guard !isRunningTests else { return } isLoading = true isRunningTests = true runningCaseID = caseID testRunRequirementID = requirementID testRunProgress = [] testRunStartedAt = Date() testRunFinishedAt = nil testRunError = nil message = nil Task { do { let stream = if let caseID { client.runSingleCase(requirementID: requirementID, caseID: caseID) } else { client.runApprovedCases(requirementID: requirementID) } for try await event in stream { testRunProgress.append(event) if event.type == "error" { testRunError = event.detail } } dashboard = try await client.dashboard() message = caseID == nil ? "已完成全部已审核案例。" : "案例 \(caseID!) 已完成单独重跑。" } catch { testRunError = error.localizedDescription message = error.localizedDescription if let refreshed = try? await client.dashboard() { dashboard = refreshed } } testRunFinishedAt = Date() runningCaseID = nil isRunningTests = false isLoading = false } } func generateReport(runID: String) { isLoading = true generatingReportRunID = runID message = nil Task { do { try await client.generateReport(runID: runID) dashboard = try await client.dashboard() message = "运行 \(runID) 的测试报告已生成。" } catch { message = error.localizedDescription } generatingReportRunID = nil isLoading = false } } func analyzeFailure(runID: String, caseID: String) { isLoading = true analyzingCaseID = caseID message = nil Task { do { try await client.analyzeFailure(runID: runID, caseID: caseID) dashboard = try await client.dashboard() message = "Codex 已完成 \(caseID) 的失败根因调查。" } catch { message = error.localizedDescription } analyzingCaseID = nil isLoading = false } } func clearMessage() { message = nil } }