624 lines
24 KiB
Swift
624 lines
24 KiB
Swift
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
|
||
}
|
||
}
|