datatestool/macos/Sources/DataTestApp/CoreClient.swift

624 lines
24 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<CaseChatProgressEvent, Error> {
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<CaseChatProgressEvent, Error> {
streamProgress(
arguments: ["case-chat-stream", requirementID, message],
fallbackError: "Codex 案例协作执行失败"
)
}
private func streamProgress(
arguments: [String],
fallbackError: String
) -> AsyncThrowingStream<CaseChatProgressEvent, Error> {
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[..<newline]))
buffer.removeSubrange(...newline)
}
}
if !buffer.isEmpty { consumeLine(buffer) }
process.waitUntilExit()
if process.terminationStatus != 0 {
let errorData = errors.fileHandleForReading.readDataToEndOfFile()
let standardError = String(data: errorData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
throw CoreClientError.commandFailed(
streamedError ?? (!standardError.isEmpty
? standardError : fallbackError)
)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
func runApprovedCases(
requirementID: String
) -> AsyncThrowingStream<TestRunProgressEvent, Error> {
runCasesStream(requirementID: requirementID, caseID: nil)
}
func runSingleCase(
requirementID: String,
caseID: String
) -> AsyncThrowingStream<TestRunProgressEvent, Error> {
runCasesStream(requirementID: requirementID, caseID: caseID)
}
private func runCasesStream(
requirementID: String,
caseID: String?
) -> AsyncThrowingStream<TestRunProgressEvent, Error> {
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[..<newline]))
buffer.removeSubrange(...newline)
}
}
if !buffer.isEmpty { consumeLine(buffer) }
process.waitUntilExit()
if process.terminationStatus != 0 {
let errorData = errors.fileHandleForReading.readDataToEndOfFile()
let standardError = String(data: errorData, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
throw CoreClientError.commandFailed(
streamedError ?? (!standardError.isEmpty
? standardError : "测试案例执行失败")
)
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
func generateReport(runID: String) async throws {
_ = try await run(["report", runID])
}
func analyzeFailure(runID: String, caseID: String) async throws {
_ = try await run(["analyze-failure", runID, caseID])
}
}
@MainActor
final class AppModel: ObservableObject {
@Published var dashboard = Dashboard.empty
@Published var isLoading = false
@Published var message: String?
@Published var runningCaseID: String?
@Published var lastImportedRequirementID: String?
@Published var generatingReportRunID: String?
@Published var analyzingCaseID: String?
@Published var isInitializingComplexDemo = false
@Published var workflowAction: String?
@Published var reviewingCaseID: String?
@Published var isGeneratingCases = false
@Published var caseGenerationRequirementID: String?
@Published var caseGenerationProgress: [CaseChatProgressEvent] = []
@Published var caseGenerationStartedAt: Date?
@Published var caseGenerationFinishedAt: Date?
@Published var caseGenerationError: String?
@Published var isChattingAboutCases = false
@Published var caseChatProgress: [CaseChatProgressEvent] = []
@Published var caseChatStartedAt: Date?
@Published var caseChatFinishedAt: Date?
@Published var caseChatError: String?
@Published var pendingCaseChatMessage: String?
@Published var isRunningTests = false
@Published var testRunRequirementID: String?
@Published var testRunProgress: [TestRunProgressEvent] = []
@Published var testRunStartedAt: Date?
@Published var testRunFinishedAt: Date?
@Published var testRunError: String?
private let client = CoreClient()
func refresh() {
isLoading = true
message = nil
Task {
do {
dashboard = try await client.dashboard()
} catch {
message = error.localizedDescription
}
isLoading = false
}
}
func initializeDemo() {
isLoading = true
message = nil
Task {
do {
try await client.initializeDemo()
dashboard = try await client.dashboard()
message = "演示需求、Metadata 和测试案例已初始化。"
} catch {
message = error.localizedDescription
}
isLoading = false
}
}
func initializeComplexDemo() {
isLoading = true
isInitializingComplexDemo = true
message = nil
Task {
do {
try await client.initializeComplexDemo()
dashboard = try await client.dashboard()
lastImportedRequirementID = "REQ-RISK-002"
message = "复杂大数据需求已准备10 万客户、20 万账户、100 万笔交易,并保留 1 条故意错误供失败诊断。"
} catch {
message = error.localizedDescription
}
isInitializingComplexDemo = false
isLoading = false
}
}
func importRequirement(from fileURL: URL) {
isLoading = true
message = nil
Task {
let hasAccess = fileURL.startAccessingSecurityScopedResource()
defer {
if hasAccess { fileURL.stopAccessingSecurityScopedResource() }
}
do {
let baseName = fileURL.deletingPathExtension().lastPathComponent
let timestamp = Int(Date().timeIntervalSince1970)
let requirementID = "REQ-LOCAL-\(timestamp)"
try await client.importRequirement(
fileURL: fileURL,
requirementID: requirementID,
requirementName: baseName
)
dashboard = try await client.dashboard()
lastImportedRequirementID = requirementID
message = "已导入需求“\(baseName)”,下一步请解析需求并获取 Metadata。"
} catch {
message = error.localizedDescription
}
isLoading = false
}
}
func parseRequirement(requirementID: String, context: String? = nil) {
performWorkflowAction("Agent 正在只读探索数据库并解析测试范围…") {
try await self.client.parseRequirement(requirementID: requirementID, context: context)
return "数据库探索和需求解析完成,候选 Metadata 已形成,请人工确认库、表、字段和规则。"
}
}
func confirmRequirement(requirementID: String) {
performWorkflowAction("正在确认范围并锁定正式 Metadata…") {
try await self.client.confirmRequirement(requirementID: requirementID)
return "需求范围已确认,正式 Metadata 已重新采集并锁定。"
}
}
func refreshMetadata(requirementID: String) {
performWorkflowAction("正在刷新需求 Metadata…") {
try await self.client.refreshMetadata(requirementID: requirementID)
return "Metadata 已刷新。"
}
}
func generateCases(requirementID: String) {
guard !isGeneratingCases else { return }
isLoading = true
isGeneratingCases = true
caseGenerationRequirementID = requirementID
caseGenerationProgress = []
caseGenerationStartedAt = Date()
caseGenerationFinishedAt = nil
caseGenerationError = nil
workflowAction = "Codex 正在根据需求和 Metadata 生成案例草稿…"
message = nil
Task {
do {
for try await event in client.generateCases(requirementID: requirementID) {
caseGenerationProgress.append(event)
if event.type == "error" { caseGenerationError = event.detail }
}
dashboard = try await client.dashboard()
message = "案例草稿已生成,请逐条审核;审核通过后才能执行。"
} catch {
caseGenerationError = error.localizedDescription
message = error.localizedDescription
if let refreshed = try? await client.dashboard() {
dashboard = refreshed
}
}
caseGenerationFinishedAt = Date()
workflowAction = nil
isGeneratingCases = false
isLoading = false
}
}
func approveCase(caseID: String) {
reviewingCaseID = caseID
performWorkflowAction("正在校验并批准 \(caseID)") {
defer { self.reviewingCaseID = nil }
try await self.client.approveCase(caseID: caseID)
return "案例 \(caseID) 已审核通过。"
}
}
func rejectCase(caseID: String) {
reviewingCaseID = caseID
performWorkflowAction("正在驳回 \(caseID)") {
defer { self.reviewingCaseID = nil }
try await self.client.rejectCase(caseID: caseID)
return "案例 \(caseID) 已驳回,可通过 Codex 沟通页面继续调整。"
}
}
func approveAllCases(requirementID: String) {
performWorkflowAction("正在校验并批量审核案例…") {
try await self.client.approveAllCases(requirementID: requirementID)
return "所有有效草稿案例已审核通过。"
}
}
func chatAboutCases(requirementID: String, message: String) {
guard !isChattingAboutCases else { return }
isChattingAboutCases = true
caseChatProgress = []
caseChatStartedAt = Date()
caseChatFinishedAt = nil
caseChatError = nil
pendingCaseChatMessage = message
self.message = nil
Task {
do {
for try await event in client.chatAboutCases(
requirementID: requirementID,
message: message
) {
caseChatProgress.append(event)
if event.type == "error" { caseChatError = event.detail }
}
dashboard = try await client.dashboard()
pendingCaseChatMessage = nil
self.message = "Codex 已回复;新增或修改案例已转为草稿,等待人工审核。"
} catch {
caseChatError = error.localizedDescription
self.message = error.localizedDescription
if let refreshed = try? await client.dashboard() {
dashboard = refreshed
pendingCaseChatMessage = nil
}
}
caseChatFinishedAt = Date()
isChattingAboutCases = false
}
}
private func performWorkflowAction(
_ progress: String,
operation: @escaping @MainActor () async throws -> 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
}
}