From 43c975b4c158286db6be3a8d7b0b10c1548c96b6 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 4 Nov 2026 22:33:54 -0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(core):=20#1662=20=E8=BD=A6=E9=81=93=20?= =?UTF-8?q?C=20=E8=BF=90=E8=A1=8C/=E8=B0=83=E8=AF=95=E4=B8=8B=E6=B2=89?= =?UTF-8?q?=EF=BC=88RUN-03/06/07/14=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RUN-03:Core 新增 build.resolveTestRunOutcome 通用归约(超时/取消不声明测试结果,失败优先于退出码, 退出码 0 但没有执行选中测试判为失败);macOS LanguageTestService 与 Windows maven.store 改为调用,删除旧的按退出码判定 - RUN-06:Core 新增 debug.planVariablePage / reduceVariablePage;macOS 删除本地分页状态机; Windows 保留 named/indexed 数量,只自动请求非 expensive Scope(expensive 改为手动加载),请求携带 filter/start/count 并提供加载更多 - RUN-07:Windows 栈帧完整消费 sourceReference / instructionPointerReference / isFiltered,sourceReference 帧不再当磁盘文件打开 - RUN-14:Core 新增 debug.planDisassemblyWindow / reduceDisassemblyPage;macOS 删除本地窗口二分、负偏移回退与耗尽判定;Windows host 未开放 disassemble/readMemory,功能矩阵标明 - 共享 fixture、i18n 词条、rust-core-api 说明、功能矩阵与 v1 baseline 条目一并更新 Co-Authored-By: Claude Opus 5.5 --- Package.swift | 4 +- macos/Resources/en.lproj/Localizable.strings | 4 + .../zh-Hans.lproj/Localizable.strings | 4 + .../Composition/DebugFeatureGraph.swift | 2 + .../Core/Rust/RustRunDebugReducers.swift | 19 + .../Platform/MacOS/MacServiceContainer.swift | 2 + .../Debug/DebugPagingContracts.swift | 258 +++ .../Execution/TestRunOutcomeContracts.swift | 95 + .../GenericDebugDisassemblyModel.swift | 184 +- .../GenericDebugFeatureModel.swift | 195 +- .../Services/LanguageTestService.swift | 69 +- .../DebugModuleTests.swift | 66 +- .../DebugVariableJSONSnapshotTests.swift | 2 +- .../LinkedCoreDebugPaging.swift | 37 + .../ExecutionModuleTests.swift | 23 +- .../LinkedCoreTestRunOutcome.swift | 37 + .../LitheTests/ConsoleInlineInputTests.swift | 2 +- .../DebugDisassemblyLayoutTests.swift | 6 +- .../DebugModuleCoordinatorTests.swift | 4 +- .../LitheTests/IdeCapabilitiesTests.swift | 2 +- .../RealJavaDebugIntegrationTests.swift | 4 +- .../RunConfigurationIntegrationTests.swift | 4 +- .../LitheTests/TCPDebugAdapterTests.swift | 2 +- .../TerminalPlacementFeatureModelTests.swift | 2 +- rust/lithe-core/src/build/mod.rs | 2 + rust/lithe-core/src/build/test_outcome.rs | 191 ++ .../src/debug/disassembly_paging.rs | 458 +++++ rust/lithe-core/src/debug/mod.rs | 4 + rust/lithe-core/src/debug/variable_paging.rs | 377 ++++ rust/lithe-core/src/protocol/command.rs | 19 + rust/lithe-core/src/runtime/dispatcher.rs | 53 + shared/contracts/rust-core-api.md | 56 + .../fixtures/build/test-run-outcome-v1.json | 137 ++ .../fixtures/debug/disassembly-paging-v1.json | 868 +++++++++ shared/fixtures/debug/v1-baseline.json | 28 + .../fixtures/debug/variable-page-plan-v1.json | 1573 +++++++++++++++++ shared/i18n/locales/en-US.json | 4 + shared/i18n/locales/zh-CN.json | 4 + shared/i18n/messages.json | 38 + .../features/debug-disassembly.json | 13 +- .../features/debug-state.json | 15 +- .../features/run-java-test.json | 13 +- .../debugger/api/variable-paging-api.ts | 53 + .../debugger/components/debugger-panels.tsx | 1 + .../components/debugger-variables-panel.tsx | 47 +- .../debugger/components/debugger-view.tsx | 2 + .../services/debug-adapter-events.test.ts | 108 ++ .../debugger/services/debug-adapter-events.ts | 40 +- .../services/debug-variable-paging.ts | 102 ++ .../debugger/stores/debugger.store.ts | 29 + .../features/debugger/types/debugger.types.ts | 22 +- .../features/maven/stores/maven.store.test.ts | 31 +- .../src/features/maven/stores/maven.store.ts | 42 +- .../features/run/api/test-run-outcome-api.ts | 46 + windows/tauri/src/i18n/shared.generated.ts | 8 + 55 files changed, 5095 insertions(+), 316 deletions(-) create mode 100644 macos/Sources/Lithe/Core/Rust/RustRunDebugReducers.swift create mode 100644 macos/Sources/LitheCoreContracts/Debug/DebugPagingContracts.swift create mode 100644 macos/Sources/LitheCoreContracts/Execution/TestRunOutcomeContracts.swift create mode 100644 macos/Tests/LitheDebugModuleTests/LinkedCoreDebugPaging.swift create mode 100644 macos/Tests/LitheExecutionModuleTests/LinkedCoreTestRunOutcome.swift create mode 100644 rust/lithe-core/src/build/test_outcome.rs create mode 100644 rust/lithe-core/src/debug/disassembly_paging.rs create mode 100644 rust/lithe-core/src/debug/variable_paging.rs create mode 100644 shared/fixtures/build/test-run-outcome-v1.json create mode 100644 shared/fixtures/debug/disassembly-paging-v1.json create mode 100644 shared/fixtures/debug/variable-page-plan-v1.json create mode 100644 windows/tauri/src/features/debugger/api/variable-paging-api.ts create mode 100644 windows/tauri/src/features/debugger/services/debug-variable-paging.ts create mode 100644 windows/tauri/src/features/run/api/test-run-outcome-api.ts diff --git a/Package.swift b/Package.swift index b579b7a7d5..2e8d847332 100644 --- a/Package.swift +++ b/Package.swift @@ -227,13 +227,13 @@ let package = Package( ), .testTarget( name: "LitheDebugModuleTests", - dependencies: ["LitheDebugModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], + dependencies: ["LitheDebugModule", "LitheApplicationKernel", "LitheRustCore", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheDebugModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheExecutionModuleTests", - dependencies: ["LitheExecutionModule", "LitheApplicationKernel", "LitheCoreContracts", .product(name: "Testing", package: "swift-testing")], + dependencies: ["LitheExecutionModule", "LitheApplicationKernel", "LitheCoreContracts", "LitheRustCore", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheExecutionModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 3ca3d383bc..9208c56723 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -2718,6 +2718,8 @@ "No tasks yet" = "No tasks yet"; "No test runner is configured for %@." = "No test runner is configured for %@."; "No tests discovered." = "No tests discovered."; +"No tests matched the selection." = "No tests matched the selection."; +"No tests were found." = "No tests were found."; "No textual diff available" = "No textual diff available"; "No textual difference" = "No textual difference"; "No tools running" = "No tools running"; @@ -10340,6 +10342,8 @@ "system Maven" = "system Maven"; "test Java" = "test Java"; "test resources" = "test resources"; +"tests.outcome.noTestsFound" = "No tests were found."; +"tests.outcome.noTestsMatched" = "No tests matched the selection."; "the project root" = "the project root"; "ui.browse" = "Browse"; "ui.cancel" = "Cancel"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index 7ab0f6f72c..201cb94fa9 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2718,6 +2718,8 @@ "No tasks yet" = "暂无任务"; "No test runner is configured for %@." = "没有为 %@ 配置测试运行器。"; "No tests discovered." = "未发现测试。"; +"No tests matched the selection." = "没有测试匹配所选范围。"; +"No tests were found." = "没有找到任何测试。"; "No textual diff available" = "没有可用的文本 Diff"; "No textual difference" = "没有文本差异"; "No tools running" = "暂无工具运行"; @@ -10340,6 +10342,8 @@ "system Maven" = "系统 Maven"; "test Java" = "测试代码"; "test resources" = "测试资源"; +"tests.outcome.noTestsFound" = "没有找到任何测试。"; +"tests.outcome.noTestsMatched" = "没有测试匹配所选范围。"; "the project root" = "项目根目录"; "ui.browse" = "浏览"; "ui.cancel" = "取消"; diff --git a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift index 92fa5e4b2c..09d76157da 100644 --- a/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift +++ b/macos/Sources/Lithe/Application/Composition/DebugFeatureGraph.swift @@ -13,6 +13,7 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { init( adapterSessions: DebugAdapterSessionManager, + paging: any DebugPagingPlanning, logSink: (any LogSink)? = nil, breakpointPersistence: (any DebugBreakpointPersisting)? = nil, breakpointRelocator: (any DebugBreakpointRelocating)? = nil, @@ -21,6 +22,7 @@ final class DebugFeatureGraph: NSObject, DebugServiceGraph { ) { self.adapterSessions = adapterSessions genericFeature = GenericDebugFeatureModel( + paging: paging, logSink: logSink, sessions: adapterSessions, breakpointPersistence: breakpointPersistence, diff --git a/macos/Sources/Lithe/Core/Rust/RustRunDebugReducers.swift b/macos/Sources/Lithe/Core/Rust/RustRunDebugReducers.swift new file mode 100644 index 0000000000..68a8c4567d --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustRunDebugReducers.swift @@ -0,0 +1,19 @@ +import Foundation +import LitheCoreContracts + +/// Pure Core reducers for test verdicts and debugger paging; no native I/O. +extension RustCoreBridge: DebugPagingCoreCommandExecuting, TestRunOutcomeCoreCommandExecuting { + func executeDebugPagingCommand( + _ command: String, + payload: Payload + ) throws -> Response { + try executeResult(command: command, payload: payload).get() + } + + func executeTestRunOutcomeCommand( + _ command: String, + payload: Payload + ) throws -> Response { + try executeResult(command: command, payload: payload).get() + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index 0a08f7393c..7cd7cff55b 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -761,6 +761,7 @@ final class MacServiceContainer { let graph = ExecutionFeatureGraph( run: runService, tests: LanguageTestService( + outcomeResolver: rustCore, catalog: languagePackRegistry.catalog, registry: languagePackRegistry.testProviders, executableResolver: executableResolver, @@ -924,6 +925,7 @@ final class MacServiceContainer { debugAdapterSessionsBox.manager = adapterSessions let graph = DebugFeatureGraph( adapterSessions: adapterSessions, + paging: rustCore, logSink: logSink, breakpointPersistence: debugBreakpointStore, breakpointRelocator: rustCore, diff --git a/macos/Sources/LitheCoreContracts/Debug/DebugPagingContracts.swift b/macos/Sources/LitheCoreContracts/Debug/DebugPagingContracts.swift new file mode 100644 index 0000000000..cc4f07a31f --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Debug/DebugPagingContracts.swift @@ -0,0 +1,258 @@ +import Foundation + +/// Core-owned paging cursor of one variable container (`debug.planVariablePage`). +/// +/// The host keeps the cursor for each loaded container and echoes it back with +/// every page; only `next` is meant to be read. The other fields round-trip +/// unchanged so Core, not the host, decides when a container is exhausted. +public struct DebugVariablePageCursor: Codable, Equatable, Sendable { + /// The `variables` request to send next. + public struct Request: Codable, Equatable, Sendable { + public let filter: DebugVariableFilter? + public let start: Int + public let count: Int + /// Children still to load across every remaining kind, when the adapter reported counts. + public let remainingCount: Int? + } + + public struct Segment: Codable, Equatable, Sendable { + public let filter: DebugVariableFilter? + public let nextStart: Int + public let totalCount: Int? + } + + public let variablesReference: Int + public let pageSize: Int + public let segments: [Segment] + public let loadedPages: [String] + /// Absent once the container is exhausted. + public let next: Request? +} + +/// Identity fields of one returned child; Core fingerprints them to detect repeated pages. +public struct DebugVariablePageItem: Codable, Equatable, Sendable { + public let name: String + public let value: String + public let type: String? + public let evaluateName: String? + public let variablesReference: Int + + public init(_ variable: DebugVariable) { + name = variable.name + value = variable.value + type = variable.type + evaluateName = variable.evaluateName + variablesReference = variable.variablesReference + } +} + +/// Result of merging one page: whether to show it, and the cursor that follows. +public struct DebugVariablePageReduction: Codable, Equatable, Sendable { + public let accept: Bool + public let state: DebugVariablePageCursor +} + +/// One direction a disassembly fetch extends the listing. +public enum DebugDisassemblyDirection: String, Codable, Equatable, Sendable { + case around, before, after +} + +/// Core-owned cursor of one bounded disassembly request sequence +/// (`debug.planDisassemblyWindow`). Hosts send `request` and echo the fetch with the reply. +public struct DebugDisassemblyFetch: Codable, Equatable, Sendable { + public struct Request: Codable, Equatable, Sendable { + public let memoryReference: String + public let instructionOffset: Int + public let instructionCount: Int + } + + public let direction: DebugDisassemblyDirection + public let reference: String + public let phase: String + public let count: Int + public let instructionsBefore: Int + public let windowFailure: String? + public let request: Request +} + +/// What the adapter answered to a fetch's request. +public enum DebugDisassemblyReply: Equatable, Sendable { + case instructions([DebugDisassembledInstruction]) + /// The adapter answered `disassemble` with `success: false`. + case rejected(String) + /// Transport failure, timeout or cancellation. + case failed(String) +} + +/// The next step of a disassembly fetch. +public enum DebugDisassemblyStep: Equatable, Sendable { + case request(DebugDisassemblyFetch) + /// `keep` indexes the reply's instructions to list, in order. + case page(keep: [Int], exhausted: Bool) + case failed(String) +} + +/// Shared paging rules for the variables tree and the disassembly listing. +/// +/// Core owns when to ask for more, how to recover from refused windows and when +/// a direction or container is exhausted; hosts keep page sizes, rows, +/// expansion, viewport and scroll anchors. +public protocol DebugPagingPlanning: Sendable { + func planVariablePage( + variablesReference: Int, + namedVariables: Int, + indexedVariables: Int, + pageSize: Int + ) throws -> DebugVariablePageCursor + func reduceVariablePage( + _ state: DebugVariablePageCursor, + requested: DebugVariablePageCursor.Request, + page: [DebugVariablePageItem], + newItemCount: Int + ) throws -> DebugVariablePageReduction + func planDisassemblyWindow( + direction: DebugDisassemblyDirection, + reference: String, + count: Int, + instructionsBefore: Int + ) throws -> DebugDisassemblyFetch + func reduceDisassemblyPage( + _ fetch: DebugDisassemblyFetch, + reply: DebugDisassemblyReply, + knownAddresses: [String] + ) throws -> DebugDisassemblyStep +} + +/// A JSON command transport to Rust Core, shared by the app bridge and tests +/// that link the same Core library. +public protocol DebugPagingCoreCommandExecuting: DebugPagingPlanning { + func executeDebugPagingCommand( + _ command: String, + payload: Payload + ) throws -> Response +} + +public extension DebugPagingCoreCommandExecuting { + func planVariablePage( + variablesReference: Int, + namedVariables: Int, + indexedVariables: Int, + pageSize: Int + ) throws -> DebugVariablePageCursor { + return try executeDebugPagingCommand("debug.planVariablePage", payload: PlanVariablePagePayload( + variablesReference: variablesReference, + namedVariables: namedVariables, + indexedVariables: indexedVariables, + pageSize: pageSize + )) + } + + func reduceVariablePage( + _ state: DebugVariablePageCursor, + requested: DebugVariablePageCursor.Request, + page: [DebugVariablePageItem], + newItemCount: Int + ) throws -> DebugVariablePageReduction { + return try executeDebugPagingCommand("debug.reduceVariablePage", payload: ReduceVariablePagePayload( + state: state, + requested: requested, + page: page, + newItemCount: newItemCount + )) + } + + func planDisassemblyWindow( + direction: DebugDisassemblyDirection, + reference: String, + count: Int, + instructionsBefore: Int + ) throws -> DebugDisassemblyFetch { + return try executeDebugPagingCommand("debug.planDisassemblyWindow", payload: PlanDisassemblyWindowPayload( + direction: direction, + reference: reference, + count: count, + instructionsBefore: instructionsBefore + )) + } + + func reduceDisassemblyPage( + _ fetch: DebugDisassemblyFetch, + reply: DebugDisassemblyReply, + knownAddresses: [String] + ) throws -> DebugDisassemblyStep { + let encodedReply: ReduceDisassemblyPageReply + switch reply { + case .instructions(let instructions): + encodedReply = ReduceDisassemblyPageReply(kind: "instructions", instructions: instructions.map { + ReduceDisassemblyPageInstruction(address: $0.address, instruction: $0.instruction, instructionBytes: $0.instructionBytes) + }, message: nil) + case .rejected(let message): + encodedReply = ReduceDisassemblyPageReply(kind: "rejected", instructions: nil, message: message) + case .failed(let message): + encodedReply = ReduceDisassemblyPageReply(kind: "failed", instructions: nil, message: message) + } + let step: ReduceDisassemblyPageStep = try executeDebugPagingCommand("debug.reduceDisassemblyPage", payload: ReduceDisassemblyPagePayload( + fetch: fetch, + reply: encodedReply, + knownAddresses: knownAddresses + )) + switch (step.kind, step.fetch, step.keep, step.exhausted, step.message) { + case ("request", let fetch?, _, _, _): return .request(fetch) + case ("page", _, let keep?, let exhausted?, _): return .page(keep: keep, exhausted: exhausted) + case ("failed", _, _, _, let message?): return .failed(message) + default: throw DebugPagingCoreError.invalidStep(step.kind) + } + } +} + +/// A Core reply that does not match the paging contract. +public enum DebugPagingCoreError: Error, Equatable, Sendable { + case invalidStep(String) +} + +private struct PlanVariablePagePayload: Encodable { + let variablesReference: Int + let namedVariables: Int + let indexedVariables: Int + let pageSize: Int +} + +private struct ReduceVariablePagePayload: Encodable { + let state: DebugVariablePageCursor + let requested: DebugVariablePageCursor.Request + let page: [DebugVariablePageItem] + let newItemCount: Int +} + +private struct PlanDisassemblyWindowPayload: Encodable { + let direction: DebugDisassemblyDirection + let reference: String + let count: Int + let instructionsBefore: Int +} + +private struct ReduceDisassemblyPageInstruction: Encodable { + let address: String + let instruction: String + let instructionBytes: String? +} + +private struct ReduceDisassemblyPageReply: Encodable { + let kind: String + let instructions: [ReduceDisassemblyPageInstruction]? + let message: String? +} + +private struct ReduceDisassemblyPagePayload: Encodable { + let fetch: DebugDisassemblyFetch + let reply: ReduceDisassemblyPageReply + let knownAddresses: [String] +} + +private struct ReduceDisassemblyPageStep: Decodable { + let kind: String + let fetch: DebugDisassemblyFetch? + let keep: [Int]? + let exhausted: Bool? + let message: String? +} diff --git a/macos/Sources/LitheCoreContracts/Execution/TestRunOutcomeContracts.swift b/macos/Sources/LitheCoreContracts/Execution/TestRunOutcomeContracts.swift new file mode 100644 index 0000000000..e710167159 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Execution/TestRunOutcomeContracts.swift @@ -0,0 +1,95 @@ +import Foundation + +/// How a test process ended, as the host observed it. +public enum TestRunTermination: Equatable, Sendable { + case exited(Int32) + case timedOut + case cancelled +} + +/// The breadth the host asked to run; Core never reads selector syntax. +public enum TestRunScopeKind: String, Codable, Equatable, Sendable { + case workspace, project, suite, `case` +} + +/// Counts from the provider's decoded results. +public struct TestRunSummaryCounts: Codable, Equatable, Sendable { + public let total: Int + public let passed: Int + public let failed: Int + public let errors: Int + public let skipped: Int + + public init(total: Int, passed: Int, failed: Int, errors: Int, skipped: Int) { + self.total = total + self.passed = passed + self.failed = failed + self.errors = errors + self.skipped = skipped + } +} + +/// `build.resolveTestRunOutcome`'s tool-neutral verdict. +public enum TestRunOutcome: String, Codable, Equatable, Sendable { + case passed + case testsFailed + case exitedWithError + case noTestsMatched + case noTestsFound + case timedOut + case cancelled +} + +public struct TestRunVerdict: Codable, Equatable, Sendable { + public let outcome: TestRunOutcome + public let success: Bool + public let exitCode: Int32? +} + +/// Reduces a finished test run to the verdict both platforms show. +public protocol TestRunOutcomeResolving: Sendable { + func resolveTestRunOutcome( + termination: TestRunTermination, + scope: TestRunScopeKind, + summary: TestRunSummaryCounts? + ) throws -> TestRunVerdict +} + +/// A JSON command transport to Rust Core, shared by the app bridge and tests +/// that link the same Core library. +public protocol TestRunOutcomeCoreCommandExecuting: TestRunOutcomeResolving { + func executeTestRunOutcomeCommand( + _ command: String, + payload: Payload + ) throws -> Response +} + +public extension TestRunOutcomeCoreCommandExecuting { + func resolveTestRunOutcome( + termination: TestRunTermination, + scope: TestRunScopeKind, + summary: TestRunSummaryCounts? + ) throws -> TestRunVerdict { + let encoded: ResolveTestRunOutcomeTermination + switch termination { + case .exited(let code): encoded = ResolveTestRunOutcomeTermination(kind: "exited", exitCode: code) + case .timedOut: encoded = ResolveTestRunOutcomeTermination(kind: "timedOut", exitCode: nil) + case .cancelled: encoded = ResolveTestRunOutcomeTermination(kind: "cancelled", exitCode: nil) + } + return try executeTestRunOutcomeCommand( + "build.resolveTestRunOutcome", + payload: ResolveTestRunOutcomePayload(termination: encoded, scope: scope, summary: summary) + ) + } +} + +private struct ResolveTestRunOutcomeTermination: Encodable { + let kind: String + let exitCode: Int32? +} + +private struct ResolveTestRunOutcomePayload: Encodable { + let termination: ResolveTestRunOutcomeTermination + let scope: TestRunScopeKind + let summary: TestRunSummaryCounts? +} diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift index 1ab078c639..b0396a0076 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugDisassemblyModel.swift @@ -92,17 +92,21 @@ public struct GenericDebugMemoryDump: Equatable, Sendable { public final class GenericDebugDisassemblyModel: ObservableObject { /// What the model needs from the feature model that owns it. public struct Environment { + /// Core's bounded request sequence for each listing edge. + public var paging: any DebugPagingPlanning public var session: () -> (any DebugAdapterControllingSession)? public var capabilities: () -> DebugAdapterCapabilities public var selectedFrameID: () -> Int? public var breakpointsChanged: (DebugSessionID, [DebugInstructionBreakpoint]) -> Void public init( + paging: any DebugPagingPlanning, session: @escaping () -> (any DebugAdapterControllingSession)?, capabilities: @escaping () -> DebugAdapterCapabilities, selectedFrameID: @escaping () -> Int?, breakpointsChanged: @escaping (DebugSessionID, [DebugInstructionBreakpoint]) -> Void ) { + self.paging = paging self.session = session self.capabilities = capabilities self.selectedFrameID = selectedFrameID @@ -292,7 +296,8 @@ public final class GenericDebugDisassemblyModel: ObservableObject { guard isPresented, let anchor = currentAddress, canDisassemble, - let session = environment?.session() else { + let environment, + let session = environment.session() else { rows = [] isLoading = false return @@ -304,21 +309,23 @@ public final class GenericDebugDisassemblyModel: ObservableObject { failedDirection = nil canLoadEarlier = true canLoadLater = true - request( - session: session, - generation: token, + fetch( + .around, reference: anchor, - offset: -Self.instructionsBefore, count: Self.initialInstructionCount, - retriesForward: true - ) { [weak self] instructions in + instructionsBefore: Self.instructionsBefore, + knownAddresses: [], + session: session, + paging: environment.paging, + generation: token + ) { [weak self] instructions, _ in guard let self, self.disassemblyGeneration == token else { return } self.rows = instructions self.isLoading = false - } onFailure: { [weak self] error in + } onFailure: { [weak self] message in guard let self, self.disassemblyGeneration == token else { return } self.rows = [] - self.loadFailure = error.localizedDescription + self.loadFailure = message self.isLoading = false } } @@ -328,122 +335,117 @@ public final class GenericDebugDisassemblyModel: ObservableObject { guard isPresented, currentAddress != nil, canDisassemble, !isLoading, direction == .before ? canLoadEarlier : canLoadLater, !automatically || loadFailure == nil, - let session = environment?.session(), + let environment, + let session = environment.session(), let edge = direction == .before ? rows.first : rows.last else { return } disassemblyGeneration &+= 1 let token = disassemblyGeneration isLoading = true loadFailure = nil failedDirection = nil - requestPage( - session: session, - generation: token, + // Only rows within reach of the requested page can repeat in it. + let reach = Self.pageInstructionCount * 2 + let known = (direction == .before ? Array(rows.prefix(reach)) : Array(rows.suffix(reach))).map(\.address) + fetch( + direction == .before ? .before : .after, reference: edge.address, - direction: direction, - count: Self.pageInstructionCount - ) { [weak self] instructions in + count: Self.pageInstructionCount, + instructionsBefore: 0, + knownAddresses: known, + session: session, + paging: environment.paging, + generation: token + ) { [weak self] fresh, exhausted in guard let self, self.disassemblyGeneration == token else { return } - var known = Set(self.rows.map { DebugAddressText.canonical($0.address) }) - let fresh = instructions.filter { known.insert(DebugAddressText.canonical($0.address)).inserted } - // Some adapters repeat the edge forever at an unmapped boundary. - if fresh.isEmpty { + if exhausted { if direction == .before { self.canLoadEarlier = false } else { self.canLoadLater = false } } self.rows = direction == .before ? fresh + self.rows : self.rows + fresh self.isLoading = false - } onFailure: { [weak self] error in + } onFailure: { [weak self] message in guard let self, self.disassemblyGeneration == token else { return } - self.loadFailure = error.localizedDescription + self.loadFailure = message self.failedDirection = direction self.isLoading = false } } - /// A page can cross an unmapped boundary even though nearer instructions remain readable. - /// Ask the adapter for smaller windows; never infer instruction sizes or compute addresses. - private func requestPage( - session: any DebugAdapterControllingSession, - generation: Int, + /// Runs Core's bounded request sequence for one fetch. Core decides when to retry a + /// refused window, how far to shrink it and when an edge is exhausted; the host only + /// sends the requests and never computes an address or an instruction length. + private func fetch( + _ direction: DebugDisassemblyDirection, reference: String, - direction: Direction, count: Int, - onSuccess: @escaping ([DebugDisassembledInstruction]) -> Void, - onFailure: @escaping (Error) -> Void + instructionsBefore: Int, + knownAddresses: [String], + session: any DebugAdapterControllingSession, + paging: any DebugPagingPlanning, + generation: Int, + onPage: @escaping ([DebugDisassembledInstruction], Bool) -> Void, + onFailure: @escaping (String) -> Void ) { - request( - session: session, generation: generation, reference: reference, - offset: direction == .before ? -count : 1, count: count, retriesForward: false, - onSuccess: onSuccess - ) { [weak self] error in - guard let self, self.disassemblyGeneration == generation else { return } - guard case DebugAdapterProtocolError.requestFailed(command: "disassemble", message: _) = error else { - onFailure(error) - return - } - if count > 1 { - self.requestPage( - session: session, generation: generation, reference: reference, - direction: direction, count: count / 2, onSuccess: onSuccess, onFailure: onFailure - ) - return - } - // LLDB reports an unmapped neighbour as a generic request failure. Only - // treat it as exhaustion if the known edge still disassembles in this stop. - self.request( - session: session, generation: generation, reference: reference, - offset: 0, count: 1, retriesForward: false - ) { instructions in - if instructions.contains(where: { DebugAddressText.areEqual($0.address, reference) }) { - onSuccess([]) - } else { - onFailure(error) - } - } onFailure: { onFailure($0) } + do { + let plan = try paging.planDisassemblyWindow( + direction: direction, + reference: reference, + count: count, + instructionsBefore: instructionsBefore + ) + send(plan, knownAddresses: knownAddresses, session: session, paging: paging, + generation: generation, onPage: onPage, onFailure: onFailure) + } catch { + onFailure(error.localizedDescription) } } - /// An adapter may refuse a negative `instructionOffset`; the first window then starts - /// at the instruction pointer instead of failing the whole view. - private func request( + private func send( + _ plan: DebugDisassemblyFetch, + knownAddresses: [String], session: any DebugAdapterControllingSession, + paging: any DebugPagingPlanning, generation: Int, - reference: String, - offset: Int, - count: Int, - retriesForward: Bool, - onSuccess: @escaping ([DebugDisassembledInstruction]) -> Void, - onFailure: @escaping (Error) -> Void + onPage: @escaping ([DebugDisassembledInstruction], Bool) -> Void, + onFailure: @escaping (String) -> Void ) { session.requestDisassembly( - memoryReference: reference, - instructionOffset: offset, - instructionCount: count + memoryReference: plan.request.memoryReference, + instructionOffset: plan.request.instructionOffset, + instructionCount: plan.request.instructionCount ) { [weak self] result in guard let self, self.disassemblyGeneration == generation else { return } + let reply: DebugDisassemblyReply + var instructions: [DebugDisassembledInstruction] = [] switch result { - case .success(let instructions): - // Adapters may pad a short window with empty invalid instructions. They - // are not addressable rows and must not become the next paging anchor. - onSuccess(instructions.filter { - !$0.instruction.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || !($0.instructionBytes ?? "").isEmpty - }) - case .failure(let error): - if retriesForward, offset < 0 { - self.request( - session: session, - generation: generation, - reference: reference, - offset: 0, - count: count, - retriesForward: false, - onSuccess: onSuccess, - onFailure: onFailure - ) + case .success(let values): + instructions = values + reply = .instructions(values) + case .failure(let error as DebugAdapterProtocolError): + if case .requestFailed(command: "disassemble", message: _) = error { + reply = .rejected(error.localizedDescription) } else { - onFailure(error) + reply = .failed(error.localizedDescription) } + case .failure(let error): + // Transport failures, timeouts and cancellation keep their own message. + reply = .failed(error.localizedDescription) + } + let step: DebugDisassemblyStep + do { + step = try paging.reduceDisassemblyPage(plan, reply: reply, knownAddresses: knownAddresses) + } catch { + onFailure(error.localizedDescription) + return + } + switch step { + case .request(let next): + self.send(next, knownAddresses: knownAddresses, session: session, paging: paging, + generation: generation, onPage: onPage, onFailure: onFailure) + case .page(let keep, let exhausted): + onPage(keep.compactMap { instructions.indices.contains($0) ? instructions[$0] : nil }, exhausted) + case .failed(let message): + onFailure(message) } } } diff --git a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift index c83d6451dc..6fb1e788ff 100644 --- a/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift +++ b/macos/Sources/LitheDebugModule/Application/GenericDebugFeatureModel.swift @@ -93,35 +93,6 @@ public struct GenericDebugStackFrameRow: Identifiable, Equatable, Sendable { public var isHiddenGroup: Bool { frame == nil } } -private struct GenericDebugVariablePageSegment: Equatable, Sendable { - let filter: DebugVariableFilter? - var nextStart: Int - let totalCount: Int? -} - -private struct GenericDebugVariablePageState: Equatable, Sendable { - let reference: Int - var segments: [GenericDebugVariablePageSegment] - var loadedPageFingerprints: Set<[GenericDebugVariablePageItemFingerprint]> - - var remainingCount: Int? { - var remaining = 0 - for segment in segments { - guard let totalCount = segment.totalCount else { return nil } - remaining += max(0, totalCount - segment.nextStart) - } - return remaining - } -} - -private struct GenericDebugVariablePageItemFingerprint: Hashable, Sendable { - let name: String - let value: String - let type: String? - let evaluateName: String? - let variablesReference: Int -} - /// The variable tree of the previous stop, kept on screen while the next stop's tree is read. /// /// A step must not blank the Variables panel, so the tree being replaced is frozen here and the @@ -132,7 +103,7 @@ private struct GenericDebugVariableSnapshot { let variables: [DebugVariable] let children: [String: [DebugVariable]] let expanded: Set - let pageStates: [String: GenericDebugVariablePageState] + let pageStates: [String: DebugVariablePageCursor] let expandedPaths: Set<[String]> } @@ -409,7 +380,8 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu @Published public private(set) var variableChildren: [String: [DebugVariable]] = [:] @Published public private(set) var expandedVariableIDs: Set = [] @Published public private(set) var loadingVariableIDs: Set = [] - @Published private var variablePageStates: [String: GenericDebugVariablePageState] = [:] + /// Core-owned paging cursors; a container without one has nothing more to load. + @Published private var variablePageStates: [String: DebugVariablePageCursor] = [:] @Published private var loadingVariablePageIDs: Set = [] @Published public private(set) var watches: [GenericDebugWatch] = [] @Published public private(set) var selectedThreadID: Int? @@ -483,6 +455,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu public var onSessionStateChanged: ((DebugSessionID, DebugAdapterState) -> Void)? private let sessions: DebugAdapterSessionManager + private let paging: any DebugPagingPlanning private let breakpointPersistence: (any DebugBreakpointPersisting)? private let breakpointRelocator: (any DebugBreakpointRelocating)? private let steppingFilterResolver: (any DebugSteppingFilterResolving)? @@ -515,6 +488,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu private let logSink: (any LogSink)? public init( + paging: any DebugPagingPlanning, logSink: (any LogSink)? = nil, sessions: DebugAdapterSessionManager, breakpointPersistence: (any DebugBreakpointPersisting)? = nil, @@ -522,6 +496,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu steppingFilterResolver: (any DebugSteppingFilterResolving)? = nil, steppingFilterPersistence: (any DebugSteppingFilterPersisting)? = nil ) { + self.paging = paging self.logSink = logSink self.sessions = sessions self.breakpointPersistence = breakpointPersistence @@ -532,6 +507,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu self?.requestConsoleCompletions(text: text, caret: caret, completion: completion) } disassembly.environment = GenericDebugDisassemblyModel.Environment( + paging: paging, session: { [weak self] in self?.activeSession }, capabilities: { [weak self] in self?.capabilities ?? .unknown }, selectedFrameID: { [weak self] in self?.selectedFrameID }, @@ -1749,7 +1725,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu let sessionID = activeSessionID let frameID = selectedFrameID let scopeID = selectedScopeID - let partial = !(variablePageStates[Self.rootVariablePageID]?.segments.isEmpty ?? true) + let partial = variablePageStates[Self.rootVariablePageID]?.next != nil let loader = DebugVariableJSONSnapshot(logSink: logSink) { reference, count in try await DebugVariableJSONRequest.read(session: session, reference: reference, count: count) } @@ -1770,13 +1746,14 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu expandedVariableIDs.insert(variable.id) return } - guard !loadingVariableIDs.contains(variable.id), activeSession != nil else { return } + guard !loadingVariableIDs.contains(variable.id), activeSession != nil, + let cursor = makeVariablePageState( + reference: variable.variablesReference, + namedVariables: variable.namedVariables, + indexedVariables: variable.indexedVariables + ) else { return } loadingVariableIDs.insert(variable.id) - variablePageStates[variable.id] = makeVariablePageState( - reference: variable.variablesReference, - namedVariables: variable.namedVariables, - indexedVariables: variable.indexedVariables - ) + variablePageStates[variable.id] = cursor requestVariablePage( parentVariableID: variable.id, frameID: selectedFrameID, @@ -2734,38 +2711,24 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu dataBreakpoints.sort { ($0.label, $0.id) < ($1.label, $1.id) } } + /// Asks Core for the first page of a container. A failure is shown and the + /// container stays unloaded rather than falling back to an unbounded request. private func makeVariablePageState( reference: Int, namedVariables: Int, indexedVariables: Int - ) -> GenericDebugVariablePageState { - var segments: [GenericDebugVariablePageSegment] = [] - if namedVariables > 0 { - segments.append(GenericDebugVariablePageSegment( - filter: .named, - nextStart: 0, - totalCount: namedVariables - )) - } - if indexedVariables > 0 { - segments.append(GenericDebugVariablePageSegment( - filter: .indexed, - nextStart: 0, - totalCount: indexedVariables - )) - } - if segments.isEmpty { - segments.append(GenericDebugVariablePageSegment( - filter: nil, - nextStart: 0, - totalCount: nil - )) + ) -> DebugVariablePageCursor? { + do { + return try paging.planVariablePage( + variablesReference: reference, + namedVariables: namedVariables, + indexedVariables: indexedVariables, + pageSize: variablePageSize + ) + } catch { + record(error) + return nil } - return GenericDebugVariablePageState( - reference: reference, - segments: segments, - loadedPageFingerprints: [] - ) } private func requestVariablePage( @@ -2777,17 +2740,14 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu let pageID = variablePageID(parentVariableID) guard !loadingVariablePageIDs.contains(pageID), let state = variablePageStates[pageID], - let segment = state.segments.first, + let next = state.next, let session = activeSession else { return } - let remaining = segment.totalCount.map { max(0, $0 - segment.nextStart) } - let requestedCount = min(variablePageSize, remaining ?? variablePageSize) - guard requestedCount > 0 else { return } loadingVariablePageIDs.insert(pageID) session.requestVariables( - reference: state.reference, - filter: segment.filter, - start: segment.nextStart, - count: requestedCount + reference: state.variablesReference, + filter: next.filter, + start: next.start, + count: next.count ) { [weak self] result in guard let self, self.inspectionGeneration == generation, @@ -2801,9 +2761,7 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu let additions = self.mergeVariablePage( values, parentVariableID: parentVariableID, - requestedFilter: segment.filter, - requestedStart: segment.nextStart, - requestedCount: requestedCount + requested: next ) if expandsParent, let parentVariableID { self.expandedVariableIDs.insert(parentVariableID) @@ -2827,13 +2785,14 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu guard stale.expandedPaths.contains(path), variable.isExpandable, variableChildren[variable.id] == nil, - !loadingVariableIDs.contains(variable.id) else { continue } + !loadingVariableIDs.contains(variable.id), + let cursor = makeVariablePageState( + reference: variable.variablesReference, + namedVariables: variable.namedVariables, + indexedVariables: variable.indexedVariables + ) else { continue } loadingVariableIDs.insert(variable.id) - variablePageStates[variable.id] = makeVariablePageState( - reference: variable.variablesReference, - namedVariables: variable.namedVariables, - indexedVariables: variable.indexedVariables - ) + variablePageStates[variable.id] = cursor requestVariablePage( parentVariableID: variable.id, frameID: selectedFrameID, @@ -2885,68 +2844,47 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu return expandedVariableIDs.contains(variableID) } - private func displayedPageState(_ pageID: String) -> GenericDebugVariablePageState? { + private func displayedPageState(_ pageID: String) -> DebugVariablePageCursor? { if let staleVariables { return staleVariables.pageStates[pageID] } return variablePageStates[pageID] } + /// Shows the children Core accepts and advances the container's cursor. @discardableResult private func mergeVariablePage( _ page: [DebugVariable], parentVariableID: String?, - requestedFilter: DebugVariableFilter?, - requestedStart: Int, - requestedCount: Int + requested: DebugVariablePageCursor.Request ) -> [DebugVariable] { let pageID = variablePageID(parentVariableID) - guard var state = variablePageStates[pageID], - let currentSegment = state.segments.first, - currentSegment.filter == requestedFilter, - currentSegment.nextStart == requestedStart else { return [] } - - let pageFingerprint = page.map { - GenericDebugVariablePageItemFingerprint( - name: $0.name, - value: $0.value, - type: $0.type, - evaluateName: $0.evaluateName, - variablesReference: $0.variablesReference + guard let state = variablePageStates[pageID] else { return [] } + let existing = parentVariableID.map { variableChildren[$0] ?? [] } ?? variables + var knownIDs = Set(existing.map(\.id)) + let additions = page.filter { knownIDs.insert($0.id).inserted } + let reduction: DebugVariablePageReduction + do { + reduction = try paging.reduceVariablePage( + state, + requested: requested, + page: page.map(DebugVariablePageItem.init), + newItemCount: additions.count ) - } - if !page.isEmpty, !state.loadedPageFingerprints.insert(pageFingerprint).inserted { + } catch { + record(error) variablePageStates.removeValue(forKey: pageID) return [] } - - let existing = parentVariableID.map { variableChildren[$0] ?? [] } ?? variables - var knownIDs = Set(existing.map(\.id)) - let additions = page.filter { knownIDs.insert($0.id).inserted } + if reduction.state.next == nil { + variablePageStates.removeValue(forKey: pageID) + } else { + variablePageStates[pageID] = reduction.state + } + guard reduction.accept else { return [] } if let parentVariableID { variableChildren[parentVariableID] = existing + additions } else { variables = existing + additions } - - if page.count > requestedCount || (!page.isEmpty && additions.isEmpty) { - state.segments = [] - } else { - var segment = state.segments.removeFirst() - segment.nextStart = requestedStart + page.count - let reachedReportedTotal = segment.totalCount.map { - segment.nextStart >= $0 - } ?? false - let shouldContinue = !page.isEmpty - && !reachedReportedTotal - && (segment.totalCount != nil || page.count == requestedCount) - if shouldContinue { - state.segments.insert(segment, at: 0) - } - } - if state.segments.isEmpty { - variablePageStates.removeValue(forKey: pageID) - } else { - variablePageStates[pageID] = state - } return additions } @@ -3048,14 +2986,13 @@ public final class GenericDebugFeatureModel: ObservableObject, GenericDebugFeatu to rows: inout [GenericDebugVariableRow] ) { let pageID = variablePageID(parentVariableID) - guard let pageState = displayedPageState(pageID), - !pageState.segments.isEmpty else { return } + guard let next = displayedPageState(pageID)?.next else { return } rows.append(GenericDebugVariableRow( id: "\(parentPath)/load-more", content: .loadMore( parentVariableID: parentVariableID, - nextCount: min(variablePageSize, pageState.remainingCount ?? variablePageSize), - remainingCount: pageState.remainingCount + nextCount: min(variablePageSize, next.remainingCount ?? variablePageSize), + remainingCount: next.remainingCount ), depth: depth )) diff --git a/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift b/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift index 9e65f270f2..129de0ec48 100644 --- a/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift +++ b/macos/Sources/LitheExecutionModule/Services/LanguageTestService.swift @@ -27,6 +27,7 @@ package final class LanguageTestService: ObservableObject { @Published package private(set) var runID: UUID? @Published package private(set) var processID: Int32? + private let outcomeResolver: any TestRunOutcomeResolving private let catalog: LanguageProviderCatalog private let registry: LanguageTestProviderRegistry private let executableResolver: any RunExecutableResolving @@ -45,12 +46,14 @@ package final class LanguageTestService: ObservableObject { private static let javaTestTimeoutMilliseconds = 120_000 package init( + outcomeResolver: any TestRunOutcomeResolving, catalog: LanguageProviderCatalog = .compatibilityFallback, registry: LanguageTestProviderRegistry? = nil, executableResolver: any RunExecutableResolving, processFactory: @escaping () -> any StreamingProcess, extensionRequiredLanguageIDs: Set = [] ) { + self.outcomeResolver = outcomeResolver self.catalog = catalog self.registry = registry ?? .standard(catalog: catalog) self.executableResolver = executableResolver @@ -311,7 +314,11 @@ package final class LanguageTestService: ObservableObject { } } testOutcomesByProviderID[providerID] = outcomes + report.tests - state = result.exitCode == 0 ? .passed : .failed(exitCode: result.exitCode) + applyVerdict( + termination: .exited(result.exitCode), + scope: scope, + summary: result.reports.isEmpty ? nil : report.summary + ) } catch is CancellationError { if activeOperationID == id.uuidString { state = .cancelled } } catch { @@ -511,7 +518,13 @@ package final class LanguageTestService: ObservableObject { errorMessage = message append(message + "\n") } - state = timedOut ? .timedOut : (exitCode == 0 ? .passed : .failed(exitCode: exitCode)) + // Process-backed providers report no structured results; the verdict + // then rests on how the process ended. + applyVerdict( + termination: timedOut ? .timedOut : .exited(exitCode), + scope: lastRun?.scope, + summary: nil + ) activeOperationID = nil processID = nil timedOutOperationID = nil @@ -524,6 +537,58 @@ package final class LanguageTestService: ObservableObject { L10n.string("The build tool timed out.") } + /// Shows Core's tool-neutral verdict. A run that executed no selected test + /// is a failure with its own reason, never a pass on exit code 0. + private func applyVerdict( + termination: TestRunTermination, + scope: LanguageTestScope?, + summary: BuildTestResults.Summary? + ) { + let verdict: TestRunVerdict + do { + verdict = try outcomeResolver.resolveTestRunOutcome( + termination: termination, + scope: Self.scopeKind(scope), + summary: summary.map { + TestRunSummaryCounts( + total: $0.total, + passed: $0.passed, + failed: $0.failed, + errors: $0.errors, + skipped: $0.skipped + ) + } + ) + } catch { + errorMessage = error.localizedDescription + append(error.localizedDescription + "\n") + state = .failed(exitCode: -1) + return + } + switch verdict.outcome { + case .passed: state = .passed + case .timedOut: state = .timedOut + case .cancelled: state = .cancelled + case .testsFailed, .exitedWithError: state = .failed(exitCode: verdict.exitCode ?? -1) + case .noTestsMatched, .noTestsFound: + let message = verdict.outcome == .noTestsMatched + ? L10n.string("No tests matched the selection.") + : L10n.string("No tests were found.") + errorMessage = message + append(message + "\n") + state = .failed(exitCode: verdict.exitCode ?? 0) + } + } + + private static func scopeKind(_ scope: LanguageTestScope?) -> TestRunScopeKind { + switch scope { + case .none, .workspace: return .workspace + case .file: return .suite + case .testCase(_, _, let reference?) where reference.testCase == nil: return .suite + case .testCase: return .case + } + } + private static func testTimeoutMilliseconds(for frameworkID: String?) -> Int? { switch frameworkID { case "junit": return javaTestTimeoutMilliseconds diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index 2ba179f694..e5eb12f0d9 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -14,7 +14,7 @@ struct DebugModuleTests { let manager = DebugAdapterSessionManager(providers: [provider]) { _, _ in let session = DeferredInspectionDebugSession(); created.append(session); return session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset() } var ended: [DebugSessionID] = [] feature.onSessionStateChanged = { id, state in if state == .failed { ended.append(id) } } @@ -283,7 +283,7 @@ struct DebugModuleTests { created.append((descriptor.id, session)) return session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset() } let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) let configuration = DebugLaunchConfiguration(name: "example", request: .launch, @@ -350,7 +350,7 @@ struct DebugModuleTests { fileExtensions: ["c"], selectsByFile: false) let session = DeferredInspectionDebugSession() let manager = DebugAdapterSessionManager(providers: [automatic, explicit]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset() } let root = URL(fileURLWithPath: "/workspace/project", isDirectory: true) #expect(feature.start(fileURL: root, rootURL: root, configuration: DebugLaunchConfiguration( @@ -391,7 +391,7 @@ struct DebugModuleTests { createdSessions.append(session) return session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset() } let root = URL(fileURLWithPath: "/workspace/hot-update-multiple", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") @@ -422,7 +422,7 @@ struct DebugModuleTests { createdSessions.append(session) return session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset() } let root = URL(fileURLWithPath: "/workspace/owned-sessions", isDirectory: true) let source = root.appendingPathComponent("src/main.zig") @@ -559,7 +559,7 @@ struct DebugModuleTests { @Test func debuggeeOutputPreservesSGRButDiscardsTerminalControlSequences() { let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) feature.appendDebuggeeOutput("\u{001B}[2J\u{001B}[31mready\u{001B}[0m\u{001B}]0;title\u{0007}\r\n") @@ -569,7 +569,7 @@ struct DebugModuleTests { @Test func debuggeeOutputNormalizationPreservesStateAcrossOutputChunks() { let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) feature.appendDebuggeeOutput("\u{001B}") feature.appendDebuggeeOutput("[31mready\u{001B}[0m\r") @@ -588,7 +588,7 @@ struct DebugModuleTests { adapters.append(adapter) return adapter } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset() } let root = URL(fileURLWithPath: "/workspace/bounded-output") let source = root.appendingPathComponent("Main.java") @@ -610,7 +610,7 @@ struct DebugModuleTests { @Test(arguments: ["31", "38;5;196", "38;2;4;5;6"]) func boundedConsoleOutputRebuildsCombinedColorsAndHonorsResets(foreground: String) { - let feature = GenericDebugFeatureModel(sessions: DebugAdapterSessionManager(providers: []) { _, _ in nil }) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: DebugAdapterSessionManager(providers: []) { _, _ in nil }) defer { feature.reset() } feature.appendDebuggeeOutput("\u{001B}[\(foreground)m\u{001B}[1m\u{001B}[48;2;1;2;3m" + String(repeating: "🙂", count: 400_000)) #expect(feature.output.count <= 400_000) @@ -626,7 +626,7 @@ struct DebugModuleTests { @Test func debuggeeOutputRejectsOversizedOrPrivateCSIAndPreservesFollowingText() { let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) feature.appendDebuggeeOutput("\u{001B}[" + String(repeating: "1;", count: 100)) feature.appendDebuggeeOutput("mtext\u{001B}[?25mvisible\u{001B}[36mcyan\u{001B}[0m") #expect(feature.output == "textvisible\u{001B}[36mcyan\u{001B}[0m") @@ -635,7 +635,7 @@ struct DebugModuleTests { @Test func debugConsolePresentationCoalescesChattyOutputUntilOneFlush() { let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) feature.appendDebuggeeOutput("first") feature.appendDebuggeeOutput(" second") @@ -661,7 +661,7 @@ struct DebugModuleTests { @Test func springPortConflictOutputProducesAnActionableDiagnostic() { let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) feature.appendDebuggeeOutput("Web server failed to start. Port 8080 was already in use.\n") @@ -808,7 +808,7 @@ struct DebugModuleTests { createdSessions.append(session) return session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-debug-console-history", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") let configuration = DebugLaunchConfiguration( @@ -849,7 +849,7 @@ struct DebugModuleTests { fileExtensions: ["c"] ) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/c-debug-console-input", isDirectory: true) #expect(feature.start( fileURL: root.appendingPathComponent("main.c"), @@ -900,7 +900,7 @@ struct DebugModuleTests { createdSessions.append(session) return session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-debug-feature-sessions", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") let firstConfiguration = DebugLaunchConfiguration( @@ -967,7 +967,7 @@ struct DebugModuleTests { createdSessions.append(session) return session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-debug-additional-failure", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") let firstConfiguration = DebugLaunchConfiguration( @@ -1016,7 +1016,7 @@ struct DebugModuleTests { createdSessions.append(session) return session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-debug-stop-promotion", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") let firstConfiguration = DebugLaunchConfiguration( @@ -1846,7 +1846,7 @@ struct DebugModuleTests { let core = RecordingDebugProtocolCore() let transport = RecordingTransport() let (manager, _) = makeChildCapableManager(core: core, parentTransport: transport) - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset() } var stopped: [DebugSessionID] = [] feature.onSessionStopped = { stopped.append($0) } @@ -1957,7 +1957,7 @@ struct DebugModuleTests { deadlineScheduler: RecordingDebugDeadlineScheduler() ) } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-stopped-context", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") var stoppedLocation: (URL, Int, Int)? @@ -2165,6 +2165,7 @@ struct DebugModuleTests { let persistence = RecordingDebugSteppingFilterPersistence() let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } let feature = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: manager, steppingFilterResolver: resolver, steppingFilterPersistence: persistence @@ -2208,6 +2209,7 @@ struct DebugModuleTests { ) let manager = DebugAdapterSessionManager(providers: []) { _, _ in nil } let feature = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: manager, steppingFilterResolver: resolver, steppingFilterPersistence: FailingDebugSteppingFilterPersistence() @@ -2240,6 +2242,7 @@ struct DebugModuleTests { ) } let feature = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: manager, steppingFilterResolver: core ) @@ -2267,7 +2270,7 @@ struct DebugModuleTests { let manager = DebugAdapterSessionManager(providers: [ DebugProviderDescriptor(id: "java", displayName: "Java", fileExtensions: ["java"]) ]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset() } let root = URL(fileURLWithPath: "/workspace/debug-empty-state", isDirectory: true) let source = root.appendingPathComponent("Main.java") @@ -2332,7 +2335,7 @@ struct DebugModuleTests { fileExtensions: ["java"] ) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-debug-retry", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") let configuration = DebugLaunchConfiguration( @@ -2361,7 +2364,7 @@ struct DebugModuleTests { fileExtensions: ["java"] ) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-debug-restart-fallback", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") let configuration = DebugLaunchConfiguration( @@ -2391,7 +2394,7 @@ struct DebugModuleTests { fileExtensions: ["java"] ) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-debug-execution-lock", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") let configuration = DebugLaunchConfiguration( @@ -2432,6 +2435,7 @@ struct DebugModuleTests { hideFilteredStackFrames: true ) let feature = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: manager, steppingFilterResolver: RecordingDebugSteppingFilterResolver( defaults: defaults, @@ -2518,7 +2522,7 @@ struct DebugModuleTests { fileExtensions: ["java"] ) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-preferred-frame", isDirectory: true) let source = root.appendingPathComponent("src/UserService.java") #expect(feature.start( @@ -2568,7 +2572,7 @@ struct DebugModuleTests { fileExtensions: ["java"] ) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-inspection-selection", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") #expect(feature.start( @@ -2996,7 +3000,7 @@ struct DebugModuleTests { fileExtensions: ["java"] ) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-exception-info", isDirectory: true) let source = root.appendingPathComponent("src/Main.java") #expect(feature.start( @@ -3108,7 +3112,7 @@ struct DebugModuleTests { deadlineScheduler: RecordingDebugDeadlineScheduler() ) } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: "/tmp/java-breakpoints", isDirectory: true) let source = root.appendingPathComponent("Main.java") @@ -3413,6 +3417,7 @@ struct DebugModuleTests { let service = root.appendingPathComponent("src/Service.java") let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in nil } let feature = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: manager, breakpointPersistence: persistence ) @@ -3443,6 +3448,7 @@ struct DebugModuleTests { )) let restored = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: DebugAdapterSessionManager(providers: [descriptor]) { _, _ in nil }, breakpointPersistence: persistence ) @@ -3471,6 +3477,7 @@ struct DebugModuleTests { let root = URL(fileURLWithPath: "/tmp/relocated-java-breakpoints", isDirectory: true) let sourceURL = root.appendingPathComponent("src/Main.java") let feature = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: manager, breakpointPersistence: persistence, breakpointRelocator: relocator @@ -3534,6 +3541,7 @@ struct DebugModuleTests { let root = URL(fileURLWithPath: "/tmp/inline-java-breakpoint-edit", isDirectory: true) let sourceURL = root.appendingPathComponent("src/Main.java") let feature = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: DebugAdapterSessionManager(providers: [descriptor]) { _, _ in nil }, breakpointRelocator: relocator ) @@ -3573,6 +3581,7 @@ struct DebugModuleTests { )] ) { _, _ in nil } let feature = GenericDebugFeatureModel( + paging: LinkedCoreDebugPaging(), sessions: manager, breakpointPersistence: persistence ) @@ -4440,6 +4449,7 @@ struct DebugModuleTests { let sink = Sink() let model = GenericDebugDisassemblyModel() model.environment = GenericDebugDisassemblyModel.Environment( + paging: LinkedCoreDebugPaging(), session: { nil }, capabilities: { Self.disassemblyCapabilities }, selectedFrameID: { nil }, @@ -5085,7 +5095,7 @@ struct DebugModuleTests { fileExtensions: ["java"] ) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: rootPath, isDirectory: true) let source = root.appendingPathComponent("src/Main.java") precondition(feature.start( diff --git a/macos/Tests/LitheDebugModuleTests/DebugVariableJSONSnapshotTests.swift b/macos/Tests/LitheDebugModuleTests/DebugVariableJSONSnapshotTests.swift index 27f9a3e602..0f32efd42d 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugVariableJSONSnapshotTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugVariableJSONSnapshotTests.swift @@ -119,7 +119,7 @@ struct DebugVariableJSONSnapshotTests { let session = SnapshotSession() session.pages = [100: [variable("request", reference: 1)], 1: [variable("count", "42", "int")]] let manager = DebugAdapterSessionManager(providers: [DebugProviderDescriptor(id: "java", displayName: "Java", fileExtensions: ["java"])]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) defer { feature.reset(); session.request = nil; session.pending = nil } let root = URL(fileURLWithPath: "/snapshot-fixture") #expect(feature.start(fileURL: root.appendingPathComponent("Main.java"), rootURL: root, diff --git a/macos/Tests/LitheDebugModuleTests/LinkedCoreDebugPaging.swift b/macos/Tests/LitheDebugModuleTests/LinkedCoreDebugPaging.swift new file mode 100644 index 0000000000..cdd5be93a5 --- /dev/null +++ b/macos/Tests/LitheDebugModuleTests/LinkedCoreDebugPaging.swift @@ -0,0 +1,37 @@ +import Foundation +import LitheCoreContracts +import LitheRustCore + +/// Runs variable and disassembly paging through the Rust Core linked into the +/// test bundle, so feature-model tests exercise the shared rules instead of a Swift copy. +struct LinkedCoreDebugPaging: DebugPagingCoreCommandExecuting { + struct CallError: Error { let message: String } + + func executeDebugPagingCommand( + _ command: String, + payload: Payload + ) throws -> Response { + let json = String(decoding: try JSONEncoder().encode(DebugPagingLinkedRequest(command: command, payload: payload)), as: UTF8.self) + guard let result = json.withCString({ lithe_bridge_execute_json($0) }) else { + throw CallError(message: "Rust Core is not linked into the test bundle") + } + defer { lithe_bridge_free_string(result) } + let envelope = try JSONDecoder().decode(DebugPagingLinkedEnvelope.self, from: Data(String(cString: result).utf8)) + guard envelope.ok, let data = envelope.data else { + throw CallError(message: envelope.error?.message ?? "Rust Core returned no data") + } + return data + } +} + +private struct DebugPagingLinkedRequest: Encodable { + let command: String + let payload: Body +} + +private struct DebugPagingLinkedEnvelope: Decodable { + struct Failure: Decodable { let message: String } + let ok: Bool + let data: Body? + let error: Failure? +} diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index fbfb7d0a5b..f7f8d91426 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -337,7 +337,7 @@ struct ExecutionModuleTests { @Test func backendTestsPreserveProjectIdentityAndRerunWithFreshFactsWithoutAHostProcess() async throws { var processes = 0 - let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { + let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { processes += 1; return TestStreamingProcess() }) defer { service.reset() } @@ -362,7 +362,7 @@ struct ExecutionModuleTests { @Test func backendReportsPreserveInvocationSummariesSourceLocationsAndOpaqueRerunReferences() async throws { - let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) defer { service.reset() } let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() @@ -399,7 +399,7 @@ struct ExecutionModuleTests { @Test func genericReportHistoryNeverCrossesLanguageProviderBoundaries() async throws { - let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) defer { service.reset() } func run(_ provider: String, status: BuildTestResults.TestCase.Status) async throws { let report = BuildTestResults(tests: [.init(suite: "Same", name: "checks", status: status)], @@ -407,7 +407,7 @@ struct ExecutionModuleTests { service.runUsingBackend(providerID: provider, scope: .workspace, workspaceURL: URL(fileURLWithPath: "/workspace"), label: "Tests") { _, _ in LanguageTestBackendResult(exitCode: 0, reports: [report]) } - try await awaitTestValue(service.$state) { $0 == .passed } + try await awaitTestValue(service.$state) { $0 != .running && $0 != .idle } } try await run("java", status: .failed) try await run("example", status: .passed) @@ -420,7 +420,7 @@ struct ExecutionModuleTests { @Test func backendCoverageClearsRemovedClassMethodsButPreservesMethodSiblingsAndOtherProjects() async throws { - let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) defer { service.reset() } func run(_ tests: [[String: String]], coverage: [[String: String]]? = nil) async throws { var json: [String: Any] = ["tests": tests, "summary": ["total": tests.count, "passed": tests.count, "failed": 0, "errors": 0, "skipped": 0], "success": true] @@ -429,7 +429,7 @@ struct ExecutionModuleTests { service.runUsingBackend(providerID: "java", scope: .workspace, workspaceURL: URL(fileURLWithPath: "/workspace"), label: "Tests") { _, _ in LanguageTestBackendResult(exitCode: 0, reports: [report]) } - try await awaitTestValue(service.$state) { $0 == .passed } + try await awaitTestValue(service.$state) { $0 != .running && $0 != .idle } } func test(_ project: String, _ method: String) -> [String: String] { ["project": project, "suite": "Same", "case": method, "status": "passed"] @@ -445,7 +445,7 @@ struct ExecutionModuleTests { @Test func backendTestsIgnoreCancelledRequestsThatReturnAfterReplacement() async throws { - let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) var observedProcesses: [Int32] = [] let observation = service.$processID.sink { if let pid = $0 { observedProcesses.append(pid) } } defer { observation.cancel() } @@ -1434,6 +1434,7 @@ struct ExecutionModuleTests { let builtInProcess = TestStreamingProcess() let extensionSession = TestLanguageExecutionSession() let service = LanguageTestService( + outcomeResolver: LinkedCoreTestRunOutcome(), catalog: .compatibilityFallback, registry: .standard(catalog: .compatibilityFallback), executableResolver: TestExecutableResolver(), @@ -1492,7 +1493,7 @@ struct ExecutionModuleTests { @Test func testProcessIdentityRejectsOldRunsAndClearsOnStop() async throws { let root = URL(fileURLWithPath: "/workspace/test-performance") let process = TestStreamingProcess() - let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { process }) + let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { process }) defer { service.reset() } #expect(service.run(providerID: "java", scope: .workspace, workspaceURL: root, projectFiles: [root.appendingPathComponent("build.gradle")])) @@ -1533,7 +1534,7 @@ struct ExecutionModuleTests { root.appendingPathComponent("package.json"), root.appendingPathComponent("sample.test.js"), root.appendingPathComponent("pyproject.toml"), root.appendingPathComponent("test_sample.py")] let report = try JSONDecoder().decode(BuildTestResults.self, from: Data(#"{"tests":[{"suite":"demo.FirstTest","case":"check","status":"passed"}],"summary":{"total":1,"passed":1,"failed":0,"errors":0,"skipped":0},"success":true}"#.utf8)) - let service = LanguageTestService(executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) + let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) func run(_ scope: LanguageTestScope) { service.runUsingBackend(providerID: "java", scope: scope, workspaceURL: root, label: "Tests") { _, _ in LanguageTestBackendResult(exitCode: 0, reports: [report]) @@ -1566,6 +1567,7 @@ struct ExecutionModuleTests { let files = [root.appendingPathComponent("build.gradle")] let process = TestStreamingProcess() let service = LanguageTestService( + outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { process } ) @@ -1752,6 +1754,7 @@ struct ExecutionModuleTests { let graph = ExecutionFeatureGraph( run: service, tests: LanguageTestService( + outcomeResolver: LinkedCoreTestRunOutcome(), catalog: .compatibilityFallback, registry: LanguageTestProviderRegistry(providers: []), executableResolver: resolver, @@ -2141,6 +2144,7 @@ struct ExecutionModuleTests { let buildFile = root.appendingPathComponent("build.gradle") let process = TestStreamingProcess() let service = LanguageTestService( + outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { process } ) @@ -2486,6 +2490,7 @@ private func makeTestGraph( languageRunProviders: .standard(catalog: .compatibilityFallback) ) let tests = LanguageTestService( + outcomeResolver: LinkedCoreTestRunOutcome(), catalog: .compatibilityFallback, registry: LanguageTestProviderRegistry(providers: []), executableResolver: resolver, diff --git a/macos/Tests/LitheExecutionModuleTests/LinkedCoreTestRunOutcome.swift b/macos/Tests/LitheExecutionModuleTests/LinkedCoreTestRunOutcome.swift new file mode 100644 index 0000000000..d1b7f0c264 --- /dev/null +++ b/macos/Tests/LitheExecutionModuleTests/LinkedCoreTestRunOutcome.swift @@ -0,0 +1,37 @@ +import Foundation +import LitheCoreContracts +import LitheRustCore + +/// Runs the test-run verdict through the Rust Core linked into the test +/// bundle, so service tests exercise the shared rule instead of a Swift copy. +struct LinkedCoreTestRunOutcome: TestRunOutcomeCoreCommandExecuting { + struct CallError: Error { let message: String } + + func executeTestRunOutcomeCommand( + _ command: String, + payload: Payload + ) throws -> Response { + let json = String(decoding: try JSONEncoder().encode(TestRunLinkedRequest(command: command, payload: payload)), as: UTF8.self) + guard let result = json.withCString({ lithe_bridge_execute_json($0) }) else { + throw CallError(message: "Rust Core is not linked into the test bundle") + } + defer { lithe_bridge_free_string(result) } + let envelope = try JSONDecoder().decode(TestRunLinkedEnvelope.self, from: Data(String(cString: result).utf8)) + guard envelope.ok, let data = envelope.data else { + throw CallError(message: envelope.error?.message ?? "Rust Core returned no data") + } + return data + } +} + +private struct TestRunLinkedRequest: Encodable { + let command: String + let payload: Body +} + +private struct TestRunLinkedEnvelope: Decodable { + struct Failure: Decodable { let message: String } + let ok: Bool + let data: Body? + let error: Failure? +} diff --git a/macos/Tests/LitheTests/ConsoleInlineInputTests.swift b/macos/Tests/LitheTests/ConsoleInlineInputTests.swift index f1431c33d6..5b4eadd0fc 100644 --- a/macos/Tests/LitheTests/ConsoleInlineInputTests.swift +++ b/macos/Tests/LitheTests/ConsoleInlineInputTests.swift @@ -345,7 +345,7 @@ struct ConsoleInlineInputTests { let settings = AppSettings(store: store) let model = AppModel(settings: settings, services: MacServiceContainer(store: store, settings: settings, moduleLaunchMode: .safeMode).services) - let feature = GenericDebugFeatureModel(sessions: DebugAdapterSessionManager(providers: []) { _, _ in nil }) + let feature = GenericDebugFeatureModel(paging: RustCoreBridge(), sessions: DebugAdapterSessionManager(providers: []) { _, _ in nil }) defer { feature.reset() } feature.appendDebuggeeOutput("retained console output\n") let host = NSHostingView(rootView: GenericDebugView(feature: feature).environmentObject(model) diff --git a/macos/Tests/LitheTests/DebugDisassemblyLayoutTests.swift b/macos/Tests/LitheTests/DebugDisassemblyLayoutTests.swift index 59ee5dc1b9..33ce892f48 100644 --- a/macos/Tests/LitheTests/DebugDisassemblyLayoutTests.swift +++ b/macos/Tests/LitheTests/DebugDisassemblyLayoutTests.swift @@ -27,7 +27,7 @@ struct DebugDisassemblyLayoutTests { func scrollingLoadsBothEdgesAndPreservesTheVisibleInstructions(dark: Bool) async throws { let session = LayoutDebugSession(longInstructions: false, pages: true) let model = GenericDebugDisassemblyModel() - model.environment = .init(session: { session }, capabilities: { session.capabilities }, + model.environment = .init(paging: RustCoreBridge(), session: { session }, capabilities: { session.capabilities }, selectedFrameID: { 1 }, breakpointsChanged: { _, _ in }) model.frameSelected(frame(at: 40)) defer { model.invalidate(); session.stop() } @@ -73,7 +73,7 @@ struct DebugDisassemblyLayoutTests { let session = LayoutDebugSession(longInstructions: false, pages: true) session.initialRows = 2 let model = GenericDebugDisassemblyModel() - model.environment = .init(session: { session }, capabilities: { session.capabilities }, + model.environment = .init(paging: RustCoreBridge(), session: { session }, capabilities: { session.capabilities }, selectedFrameID: { 1 }, breakpointsChanged: { _, _ in }) model.frameSelected(frame(at: 1)) defer { model.invalidate(); session.stop() } @@ -104,7 +104,7 @@ struct DebugDisassemblyLayoutTests { defer { app.accessibilitySetValue(enhanced, forAttribute: attribute) } let session = LayoutDebugSession(longInstructions: longInstructions, pages: pageFailure) let model = GenericDebugDisassemblyModel() - model.environment = .init(session: { session }, capabilities: { session.capabilities }, + model.environment = .init(paging: RustCoreBridge(), session: { session }, capabilities: { session.capabilities }, selectedFrameID: { 1 }, breakpointsChanged: { _, _ in }) model.frameSelected(frame(at: 40)) defer { model.invalidate(); session.stop() } diff --git a/macos/Tests/LitheTests/DebugModuleCoordinatorTests.swift b/macos/Tests/LitheTests/DebugModuleCoordinatorTests.swift index aeadee3f99..8b67085a88 100644 --- a/macos/Tests/LitheTests/DebugModuleCoordinatorTests.swift +++ b/macos/Tests/LitheTests/DebugModuleCoordinatorTests.swift @@ -26,7 +26,7 @@ struct DebugModuleCoordinatorTests { let sessions = DebugAdapterSessionManager(providers: [ DebugProviderDescriptor(id: "java", displayName: "Java", fileExtensions: ["java"]) ]) { _, _ in adapter } - let feature = GenericDebugFeatureModel(sessions: sessions) + let feature = GenericDebugFeatureModel(paging: RustCoreBridge(), sessions: sessions) let graph = DebugPresentationTestGraph(feature: feature) try runtime.register(ModuleFactory(manifest: DebugModule.moduleManifest, contributions: DebugModule.moduleContributions) { DebugModule(makeGraph: { graph }) }) @@ -98,7 +98,7 @@ struct DebugModuleCoordinatorTests { @MainActor private final class DebugPresentationTestGraph: DebugServiceGraph { let feature: GenericDebugFeatureModel - init(feature: GenericDebugFeatureModel = GenericDebugFeatureModel(sessions: DebugAdapterSessionManager(providers: []) { _, _ in nil })) { + init(feature: GenericDebugFeatureModel = GenericDebugFeatureModel(paging: RustCoreBridge(), sessions: DebugAdapterSessionManager(providers: []) { _, _ in nil })) { self.feature = feature } var genericFeatureTarget: any GenericDebugFeatureTarget { feature } diff --git a/macos/Tests/LitheTests/IdeCapabilitiesTests.swift b/macos/Tests/LitheTests/IdeCapabilitiesTests.swift index fd40e3f414..43ee3ce14c 100644 --- a/macos/Tests/LitheTests/IdeCapabilitiesTests.swift +++ b/macos/Tests/LitheTests/IdeCapabilitiesTests.swift @@ -560,7 +560,7 @@ private final class ProviderCapabilityExecutionFixture { languageRunProviders: .standard(catalog: .compatibilityFallback)) let runFeature = RunFeatureModel(service: run) self.execution = ExecutionFeatureAccess(runFeature: runFeature, - tests: LanguageTestService(executableResolver: resolver, processFactory: { CapabilityProcessFixture() }), + tests: LanguageTestService(outcomeResolver: RustCoreBridge(), executableResolver: resolver, processFactory: { CapabilityProcessFixture() }), projectDevelopment: ProjectDevelopmentFeatureModel(runFeature: runFeature), buildSystems: nil) } catch { try? FileManager.default.removeItem(at: root) diff --git a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift index 7ad13b00c1..3485d07d6f 100644 --- a/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift +++ b/macos/Tests/LitheTests/RealJavaDebugIntegrationTests.swift @@ -117,7 +117,7 @@ struct RealJavaDebugIntegrationTests { deadlineScheduler: MacDebugOperationDeadlineScheduler() ) } - let feature = GenericDebugFeatureModel(sessions: debugManager) + let feature = GenericDebugFeatureModel(paging: RustCoreBridge(), sessions: debugManager) let debugTerminals = RealJavaDebugTerminalOwner(workspaceURL: rootURL) feature.onRunInTerminalRequest = debugTerminals.handle let portAllocator = MacJavaTestResultServer() @@ -425,7 +425,7 @@ struct RealJavaDebugIntegrationTests { deadlineScheduler: MacDebugOperationDeadlineScheduler() ) } - let feature = GenericDebugFeatureModel(sessions: debugManager) + let feature = GenericDebugFeatureModel(paging: RustCoreBridge(), sessions: debugManager) let debugTerminals = RealJavaDebugTerminalOwner(workspaceURL: rootURL) feature.onRunInTerminalRequest = debugTerminals.handle let launchService = JavaTestDebugLaunchService( diff --git a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift index f02dba47a6..4cbe3a54d5 100644 --- a/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift +++ b/macos/Tests/LitheTests/RunConfigurationIntegrationTests.swift @@ -1234,6 +1234,7 @@ struct RunConfigurationIntegrationTests { } ) let feature = GenericDebugFeatureModel( + paging: RustCoreBridge(), sessions: manager, breakpointPersistence: persistence ) @@ -1448,6 +1449,7 @@ struct RunConfigurationIntegrationTests { let processFactory = RecordingProcessFactory() let resolver = RecordingRunExecutableResolver() let service = LanguageTestService( + outcomeResolver: RustCoreBridge(), executableResolver: resolver, processFactory: { processFactory.make() } ) @@ -2832,7 +2834,7 @@ struct RunConfigurationIntegrationTests { let adapter = TestDebugAdapterSession() let manager = DebugAdapterSessionManager(providers: LanguageProviderCatalog.standard.debugProviders) { _, _ in adapter } defer { manager.stopAll() } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: RustCoreBridge(), sessions: manager) let root = URL(fileURLWithPath: "/workspace") let source = root.appendingPathComponent("Example.java") feature.toggleBreakpoint(fileURL: source, line: 3) diff --git a/macos/Tests/LitheTests/TCPDebugAdapterTests.swift b/macos/Tests/LitheTests/TCPDebugAdapterTests.swift index 61b735c0e7..a187754d3c 100644 --- a/macos/Tests/LitheTests/TCPDebugAdapterTests.swift +++ b/macos/Tests/LitheTests/TCPDebugAdapterTests.swift @@ -29,7 +29,7 @@ struct TCPDebugAdapterTests { deadlineScheduler: deadlines, initializationTimeoutMilliseconds: 20_000 ) } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: RustCoreBridge(), sessions: manager) let source = root.appendingPathComponent("main.py") let configuration = DebugLaunchConfiguration(name: "TCP", request: .attach, arguments: [:], adapterEndpoint: endpoint) let finished = TCPTestEvent() diff --git a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift index 6570d53cc4..08a43bc534 100644 --- a/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift +++ b/macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift @@ -37,7 +37,7 @@ struct TerminalPlacementFeatureModelTests { let sessions = DebugAdapterSessionManager(providers: [ DebugProviderDescriptor(id: "java", displayName: "Java", fileExtensions: ["java"]) ]) { _, _ in InputTestDebugSession() } - let feature = GenericDebugFeatureModel(sessions: sessions) + let feature = GenericDebugFeatureModel(paging: RustCoreBridge(), sessions: sessions) let graph = InputTestDebugGraph(feature: feature) try runtime.register(ModuleFactory(manifest: DebugModule.moduleManifest, contributions: DebugModule.moduleContributions) { DebugModule(makeGraph: { graph }) }) let store = TerminalPlacementTestStore() diff --git a/rust/lithe-core/src/build/mod.rs b/rust/lithe-core/src/build/mod.rs index 77829b7cc6..1aafd0ac12 100644 --- a/rust/lithe-core/src/build/mod.rs +++ b/rust/lithe-core/src/build/mod.rs @@ -36,6 +36,7 @@ mod select; mod stored_options; mod template; mod test_discovery; +mod test_outcome; pub(crate) use commands::*; pub(crate) use invocation::host_runtime_version_arguments; @@ -46,3 +47,4 @@ pub(crate) use manifest::{ pub(crate) use project_options::project_options_command; pub(crate) use run_start::run_decode_start_command; pub(crate) use stored_options::resolve_stored_options_command; +pub(crate) use test_outcome::resolve_test_run_outcome; diff --git a/rust/lithe-core/src/build/test_outcome.rs b/rust/lithe-core/src/build/test_outcome.rs new file mode 100644 index 0000000000..9cc3e906c4 --- /dev/null +++ b/rust/lithe-core/src/build/test_outcome.rs @@ -0,0 +1,191 @@ +//! Final verdict of one test run (`build.resolveTestRunOutcome`). +//! +//! Hosts collect how the run ended and the summary the provider's decoded +//! results reported; Core reduces those facts to one tool-neutral outcome, so +//! a run that executed no selected test reads the same on every platform. No +//! tool, framework, or selector syntax is interpreted here: the selection is +//! only the scope the host asked for, and the summary is whatever the +//! provider's decoder produced. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; + +/// How the test process ended, as observed by the host. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "camelCase", deny_unknown_fields)] +pub(crate) enum TestRunTermination { + /// The tool exited by itself with this code. + #[serde(rename_all = "camelCase")] + Exited { exit_code: i32 }, + /// The host's deadline stopped the tool. + TimedOut, + /// The user or a replacement run stopped the tool. + Cancelled, +} + +/// The scope the host asked to run. Only the breadth matters: an explicit +/// suite or case that runs nothing did not match, a broader scope found none. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TestRunScope { + Workspace, + Project, + Suite, + Case, +} + +/// Counts from the provider's decoded results. +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct TestRunSummary { + pub(crate) total: u64, + #[serde(default)] + pub(crate) passed: u64, + #[serde(default)] + pub(crate) failed: u64, + #[serde(default)] + pub(crate) errors: u64, + #[serde(default)] + pub(crate) skipped: u64, +} + +/// Input of `build.resolveTestRunOutcome`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ResolveTestRunOutcomeRequest { + pub(crate) termination: TestRunTermination, + pub(crate) scope: TestRunScope, + /// `None` when the run has no structured results (the provider declares + /// none). The verdict then rests on the termination alone. + #[serde(default)] + pub(crate) summary: Option, +} + +/// The unified verdict. Every variant except `passed` is unsuccessful. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) enum TestRunOutcome { + Passed, + /// At least one test failed or errored. + TestsFailed, + /// The tool exited non-zero without reporting a failing test, for example + /// a compilation error. + ExitedWithError, + /// An explicit suite or case was selected and no test ran. + NoTestsMatched, + /// A project or workspace scope ran and no test was found. + NoTestsFound, + TimedOut, + Cancelled, +} + +/// Output of `build.resolveTestRunOutcome`. +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TestRunVerdict { + pub(crate) outcome: TestRunOutcome, + pub(crate) success: bool, + /// The tool's exit code when it exited by itself. + pub(crate) exit_code: Option, +} + +/// Reduces one run's termination and decoded summary to its verdict. +/// +/// Precedence: an interrupted run (cancelled, timed out) never claims a test +/// outcome; reported failures outrank the exit code; a non-zero exit outranks +/// an empty result; and a zero exit with decoded results but no executed test +/// is not a pass. +pub(crate) fn resolve_test_run_outcome( + request: ResolveTestRunOutcomeRequest, +) -> Result { + if let Some(summary) = request.summary { + let counted = [ + summary.passed, + summary.failed, + summary.errors, + summary.skipped, + ] + .into_iter() + .try_fold(0u64, u64::checked_add); + if counted.is_none_or(|counted| counted > summary.total) { + return Err(CoreError::new( + ErrorCode::InvalidRequest, + "Test summary counts exceed the reported total.", + )); + } + } + let exit_code = match request.termination { + TestRunTermination::Cancelled => return Ok(verdict(TestRunOutcome::Cancelled, None)), + TestRunTermination::TimedOut => return Ok(verdict(TestRunOutcome::TimedOut, None)), + TestRunTermination::Exited { exit_code } => exit_code, + }; + let outcome = match request.summary { + Some(summary) if summary.failed + summary.errors > 0 => TestRunOutcome::TestsFailed, + _ if exit_code != 0 => TestRunOutcome::ExitedWithError, + Some(summary) if summary.total == 0 => match request.scope { + TestRunScope::Suite | TestRunScope::Case => TestRunOutcome::NoTestsMatched, + TestRunScope::Workspace | TestRunScope::Project => TestRunOutcome::NoTestsFound, + }, + _ => TestRunOutcome::Passed, + }; + Ok(verdict(outcome, Some(exit_code))) +} + +fn verdict(outcome: TestRunOutcome, exit_code: Option) -> TestRunVerdict { + TestRunVerdict { + outcome, + success: outcome == TestRunOutcome::Passed, + exit_code, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/fixtures/build/test-run-outcome-v1.json" + )) + .unwrap() + } + + // The shared fixture is the cross-platform contract: both hosts feed the + // same facts and must show the same verdict. + #[test] + fn shared_fixture_cases_resolve_to_their_expected_verdicts() { + let fixture = fixture(); + let cases = fixture["cases"].as_array().unwrap(); + assert!(!cases.is_empty()); + for case in cases { + let name = case["name"].as_str().unwrap(); + let request: ResolveTestRunOutcomeRequest = + serde_json::from_value(case["request"].clone()).unwrap(); + let actual = serde_json::to_value(resolve_test_run_outcome(request).unwrap()).unwrap(); + assert_eq!(actual, case["expected"], "{name}"); + } + for case in fixture["rejected"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let accepted = + serde_json::from_value::(case["request"].clone()) + .map_err(|_| ()) + .and_then(|request| resolve_test_run_outcome(request).map_err(|_| ())); + assert!(accepted.is_err(), "{name}"); + } + } + + // Regression for RUN-03: macOS reported a zero-match selection as passed + // because it only looked at the exit code. + #[test] + fn zero_exit_without_any_executed_test_is_not_a_pass() { + let request = ResolveTestRunOutcomeRequest { + termination: TestRunTermination::Exited { exit_code: 0 }, + scope: TestRunScope::Case, + summary: Some(TestRunSummary::default()), + }; + let verdict = resolve_test_run_outcome(request).unwrap(); + assert_eq!(verdict.outcome, TestRunOutcome::NoTestsMatched); + assert!(!verdict.success); + } +} diff --git a/rust/lithe-core/src/debug/disassembly_paging.rs b/rust/lithe-core/src/debug/disassembly_paging.rs new file mode 100644 index 0000000000..2bd412d3aa --- /dev/null +++ b/rust/lithe-core/src/debug/disassembly_paging.rs @@ -0,0 +1,458 @@ +//! Bounded recovery of disassembly windows (`debug.planDisassemblyWindow`, +//! `debug.reduceDisassemblyPage`). +//! +//! The disassembly view lists instructions around the selected frame's +//! instruction pointer and extends the listing at either edge as the user +//! scrolls. Adapters may refuse a window that crosses an unmapped address or a +//! negative `instructionOffset` instead of returning a short page, and some +//! repeat the edge forever at a boundary. Core owns the bounded request +//! sequence that recovers from those replies: retry the first window forward, +//! halve a refused page, and probe the known edge before declaring a direction +//! exhausted. Hosts own the transport, the rendered rows, the viewport and the +//! scroll anchor. +//! +//! Addresses are opaque adapter strings. Core only compares two spellings of +//! the same address and never does arithmetic on them or guesses an +//! instruction length. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Largest window a host may request in one `disassemble` call. +const MAX_INSTRUCTION_COUNT: u32 = 4_096; +/// Upper bound of instructions accepted in one reply and of known edge addresses. +const MAX_REDUCED_ITEMS: usize = 8_192; +const MAX_ADDRESS_BYTES: usize = 128; +const MAX_MESSAGE_BYTES: usize = 4_096; + +/// Which part of the listing a fetch extends. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DisassemblyDirection { + /// The first window around the instruction pointer. + Around, + /// Instructions above the first listed one. + Before, + /// Instructions below the last listed one. + After, +} + +/// Where a fetch is in its bounded request sequence. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum DisassemblyFetchPhase { + /// Requesting a window of `count` instructions. + Window, + /// The first window was refused with a negative offset; retry it from the + /// instruction pointer itself. + RetryForward, + /// Even one neighbouring instruction was refused; check that the known + /// edge still disassembles before declaring the direction exhausted. + EdgeProbe, +} + +/// One DAP `disassemble` request the host sends as is. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DisassembleRequestArguments { + pub(crate) memory_reference: String, + pub(crate) instruction_offset: i64, + pub(crate) instruction_count: u32, +} + +/// The cursor of one fetch, owned by Core and echoed by the host with each reply. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DisassemblyFetch { + pub(crate) direction: DisassemblyDirection, + /// The instruction pointer (`around`) or the listed edge (`before`, `after`). + pub(crate) reference: String, + pub(crate) phase: DisassemblyFetchPhase, + /// Instructions requested by the current window. + pub(crate) count: u32, + /// Instructions listed before the anchor by the first window. + #[serde(default)] + pub(crate) instructions_before: u32, + /// The adapter's diagnostic for the refused window an edge probe follows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) window_failure: Option, + pub(crate) request: DisassembleRequestArguments, +} + +/// Input of `debug.planDisassemblyWindow`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanDisassemblyWindowRequest { + pub(crate) direction: DisassemblyDirection, + pub(crate) reference: String, + pub(crate) count: u32, + /// Only for `around`: how many of `count` lie before the anchor. + #[serde(default)] + pub(crate) instructions_before: u32, +} + +/// The identity of one returned instruction, as the adapter spelled it. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DisassemblyReplyInstruction { + pub(crate) address: String, + #[serde(default)] + pub(crate) instruction: String, + #[serde(default)] + pub(crate) instruction_bytes: Option, +} + +/// What the adapter answered to `fetch.request`. +#[derive(Debug, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub(crate) enum DisassemblyReply { + Instructions { + instructions: Vec, + }, + /// The adapter answered the request with `success: false`. + Rejected { message: String }, + /// Transport failure, timeout or cancellation; never mistaken for a boundary. + Failed { message: String }, +} + +/// Input of `debug.reduceDisassemblyPage`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ReduceDisassemblyPageRequest { + pub(crate) fetch: DisassemblyFetch, + pub(crate) reply: DisassemblyReply, + /// Addresses already listed on the side being extended; the page drops them. + #[serde(default)] + pub(crate) known_addresses: Vec, +} + +/// Output of `debug.reduceDisassemblyPage`. +#[derive(Debug, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub(crate) enum DisassemblyStep { + /// Send `fetch.request` next and reduce its reply with this fetch. + Request { fetch: DisassemblyFetch }, + /// The fetch finished. `keep` indexes the reply's instructions to list, in + /// order; `exhausted` means this direction has nothing more to list. + Page { keep: Vec, exhausted: bool }, + /// The fetch failed; show the adapter's own message. + Failed { message: String }, +} + +/// Plans the first request of a fetch. +pub(crate) fn plan_disassembly_window( + request: PlanDisassemblyWindowRequest, +) -> Result { + validate_address(&request.reference)?; + validate_count(request.count)?; + if request.direction != DisassemblyDirection::Around && request.instructions_before != 0 { + return Err(invalid( + "instructionsBefore applies only to the first window.", + )); + } + if request.instructions_before > request.count { + return Err(invalid("instructionsBefore exceeds the window.")); + } + let mut fetch = DisassemblyFetch { + direction: request.direction, + reference: request.reference, + phase: DisassemblyFetchPhase::Window, + count: request.count, + instructions_before: request.instructions_before, + window_failure: None, + request: DisassembleRequestArguments { + memory_reference: String::new(), + instruction_offset: 0, + instruction_count: 0, + }, + }; + fetch.request = arguments(&fetch); + Ok(fetch) +} + +/// Reduces one adapter reply to the next step of the fetch. +pub(crate) fn reduce_disassembly_page( + request: ReduceDisassemblyPageRequest, +) -> Result { + let ReduceDisassemblyPageRequest { + mut fetch, + reply, + known_addresses, + } = request; + validate_address(&fetch.reference)?; + validate_count(fetch.count)?; + if fetch.request != arguments(&fetch) { + return Err(invalid("The disassembly fetch does not match its request.")); + } + if known_addresses.len() > MAX_REDUCED_ITEMS { + return Err(invalid("Too many known disassembly addresses.")); + } + match reply { + DisassemblyReply::Instructions { instructions } => { + if instructions.len() > MAX_REDUCED_ITEMS { + return Err(invalid("Too many disassembled instructions.")); + } + let listed = addressable(&instructions); + match (fetch.direction, fetch.phase) { + (_, DisassemblyFetchPhase::EdgeProbe) => { + let edge = canonical_address(&fetch.reference); + if listed + .iter() + .any(|&index| canonical_address(&instructions[index].address) == edge) + { + Ok(DisassemblyStep::Page { + keep: Vec::new(), + exhausted: true, + }) + } else { + Ok(failed(fetch.window_failure.unwrap_or_default())) + } + } + (DisassemblyDirection::Around, _) => Ok(DisassemblyStep::Page { + keep: listed, + exhausted: false, + }), + _ => { + let mut seen: BTreeSet = known_addresses + .iter() + .map(|address| canonical_address(address)) + .collect(); + // Some adapters repeat the edge forever at an unmapped boundary. + let keep: Vec = listed + .into_iter() + .filter(|&index| { + seen.insert(canonical_address(&instructions[index].address)) + }) + .collect(); + let exhausted = keep.is_empty(); + Ok(DisassemblyStep::Page { keep, exhausted }) + } + } + } + // An adapter may refuse a negative `instructionOffset` in any way; the + // first window then starts at the instruction pointer instead of failing + // the whole view. + DisassemblyReply::Rejected { .. } | DisassemblyReply::Failed { .. } + if fetch.direction == DisassemblyDirection::Around + && fetch.phase == DisassemblyFetchPhase::Window + && fetch.request.instruction_offset < 0 => + { + fetch.phase = DisassemblyFetchPhase::RetryForward; + fetch.request = arguments(&fetch); + Ok(DisassemblyStep::Request { fetch }) + } + // Transport failures, timeouts and cancellation are real errors and are + // never mistaken for a mapping boundary. + DisassemblyReply::Failed { message } => Ok(failed(message)), + DisassemblyReply::Rejected { message } => match (fetch.direction, fetch.phase) { + ( + DisassemblyDirection::Before | DisassemblyDirection::After, + DisassemblyFetchPhase::Window, + ) if fetch.count > 1 => { + // A page can cross an unmapped boundary while nearer + // instructions stay readable; ask for a smaller window. + fetch.count /= 2; + fetch.request = arguments(&fetch); + Ok(DisassemblyStep::Request { fetch }) + } + ( + DisassemblyDirection::Before | DisassemblyDirection::After, + DisassemblyFetchPhase::Window, + ) => { + // Adapters report an unmapped neighbour as a generic failure. + // Only a still-readable edge proves the boundary. + fetch.phase = DisassemblyFetchPhase::EdgeProbe; + fetch.window_failure = Some(bounded_message(message)); + fetch.request = arguments(&fetch); + Ok(DisassemblyStep::Request { fetch }) + } + _ => Ok(failed(message)), + }, + } +} + +/// The DAP arguments a fetch's current phase sends. +fn arguments(fetch: &DisassemblyFetch) -> DisassembleRequestArguments { + let count = i64::from(fetch.count); + let (instruction_offset, instruction_count) = match (fetch.direction, fetch.phase) { + (_, DisassemblyFetchPhase::EdgeProbe) => (0, 1), + (DisassemblyDirection::Around, DisassemblyFetchPhase::Window) => { + (-i64::from(fetch.instructions_before), fetch.count) + } + (DisassemblyDirection::Around, DisassemblyFetchPhase::RetryForward) => (0, fetch.count), + (DisassemblyDirection::Before, _) => (-count, fetch.count), + (DisassemblyDirection::After, _) => (1, fetch.count), + }; + DisassembleRequestArguments { + memory_reference: fetch.reference.clone(), + instruction_offset, + instruction_count, + } +} + +/// Indexes of real instructions. Adapters may pad a short window with empty +/// invalid entries; they are not addressable rows and must not become the +/// next paging anchor. +fn addressable(instructions: &[DisassemblyReplyInstruction]) -> Vec { + instructions + .iter() + .enumerate() + .filter(|(_, instruction)| { + !instruction.instruction.trim().is_empty() + || instruction + .instruction_bytes + .as_deref() + .is_some_and(|bytes| !bytes.is_empty()) + }) + .map(|(index, _)| index) + .collect() +} + +/// Lowercase, no `0x`, no leading zeros, so `0x0000000100003f50` equals +/// `0x100003f50`. Text only: a 64-bit pointer is never parsed into a number. +pub(crate) fn canonical_address(address: &str) -> String { + let lowered = address.trim().to_ascii_lowercase(); + let digits = lowered.strip_prefix("0x").unwrap_or(&lowered); + let significant = digits.trim_start_matches('0'); + if significant.is_empty() { + "0".to_string() + } else { + significant.to_string() + } +} + +fn failed(message: String) -> DisassemblyStep { + DisassemblyStep::Failed { + message: bounded_message(message), + } +} + +fn bounded_message(mut message: String) -> String { + if message.len() > MAX_MESSAGE_BYTES { + let mut end = MAX_MESSAGE_BYTES; + while !message.is_char_boundary(end) { + end -= 1; + } + message.truncate(end); + } + message +} + +fn validate_address(address: &str) -> Result<(), CoreError> { + if address.trim().is_empty() || address.len() > MAX_ADDRESS_BYTES { + return Err(invalid( + "A disassembly reference must be a non-empty address.", + )); + } + Ok(()) +} + +fn validate_count(count: u32) -> Result<(), CoreError> { + if count == 0 || count > MAX_INSTRUCTION_COUNT { + return Err(invalid( + "The disassembly window must hold 1 to 4096 instructions.", + )); + } + Ok(()) +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/fixtures/debug/disassembly-paging-v1.json" + )) + .unwrap() + } + + // Each scenario replays one bounded request sequence: the requests Core asks + // for and the step it returns after every adapter reply. + #[test] + fn disassembly_paging_matches_the_shared_fixture() { + let fixture = fixture(); + for scenario in fixture["scenarios"].as_array().unwrap() { + let name = scenario["name"].as_str().unwrap(); + let mut fetch = + plan_disassembly_window(serde_json::from_value(scenario["plan"].clone()).unwrap()) + .unwrap(); + let known: Vec = + serde_json::from_value(scenario["knownAddresses"].clone()).unwrap_or_default(); + let steps = scenario["steps"].as_array().unwrap(); + for (index, step) in steps.iter().enumerate() { + assert_eq!( + serde_json::to_value(&fetch.request).unwrap(), + step["request"], + "{name}: step {index} request" + ); + let result = reduce_disassembly_page(ReduceDisassemblyPageRequest { + fetch: fetch.clone(), + reply: serde_json::from_value(step["reply"].clone()).unwrap(), + known_addresses: known.clone(), + }) + .unwrap(); + let value = serde_json::to_value(&result).unwrap(); + match result { + DisassemblyStep::Request { fetch: next } => { + assert_eq!( + json!("request"), + step["expected"]["kind"], + "{name}: step {index}" + ); + fetch = next; + } + _ => { + assert_eq!(value, step["expected"], "{name}: step {index}"); + assert_eq!(index + 1, steps.len(), "{name}: fetch ended early"); + } + } + } + } + } + + #[test] + fn address_spellings_compare_without_arithmetic() { + assert_eq!(canonical_address("0x0000000100003F50"), "100003f50"); + assert_eq!(canonical_address("0X0"), "0"); + assert_eq!(canonical_address("0x0000"), "0"); + assert_ne!( + canonical_address("0xffffffffffffffff"), + canonical_address("0xfffffffffffffffe") + ); + } + + #[test] + fn a_tampered_fetch_is_rejected() { + let mut fetch = plan_disassembly_window(PlanDisassemblyWindowRequest { + direction: DisassemblyDirection::After, + reference: "0x10".into(), + count: 64, + instructions_before: 0, + }) + .unwrap(); + fetch.request.instruction_count = 4_096; + assert!(reduce_disassembly_page(ReduceDisassemblyPageRequest { + fetch, + reply: DisassemblyReply::Failed { + message: "x".into() + }, + known_addresses: vec![], + }) + .is_err()); + } +} diff --git a/rust/lithe-core/src/debug/mod.rs b/rust/lithe-core/src/debug/mod.rs index 8748320896..920f78b01c 100644 --- a/rust/lithe-core/src/debug/mod.rs +++ b/rust/lithe-core/src/debug/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod adapters; mod backend_start; mod breakpoint_relocation; mod capabilities; +mod disassembly_paging; mod endpoint; mod engine; mod java_test; @@ -12,15 +13,18 @@ mod protocol; mod shortcut; mod transport_lifecycle; mod types; +mod variable_paging; pub(crate) use backend_start::decode_backend_configuration_command; pub(crate) use breakpoint_relocation::*; +pub(crate) use disassembly_paging::{plan_disassembly_window, reduce_disassembly_page}; pub(crate) use endpoint::*; pub(crate) use engine::*; pub(crate) use java_test::*; pub(crate) use shortcut::*; pub(crate) use transport_lifecycle::*; pub(crate) use types::*; +pub(crate) use variable_paging::{plan_variable_page, reduce_variable_page}; /// Prevents DevTools from restarting the application while a debugger replaces classes. pub(crate) const JAVA_DEBUG_DISABLE_DEVTOOLS_RESTART: &str = diff --git a/rust/lithe-core/src/debug/variable_paging.rs b/rust/lithe-core/src/debug/variable_paging.rs new file mode 100644 index 0000000000..db038c782a --- /dev/null +++ b/rust/lithe-core/src/debug/variable_paging.rs @@ -0,0 +1,377 @@ +//! Bounded paging of one debugger variable container (`debug.planVariablePage`, +//! `debug.reduceVariablePage`). +//! +//! A scope or structured variable reports how many named and indexed children +//! it has. Hosts must not ask for all of them at once, and some adapters ignore +//! `start` or report no counts at all. Core owns the page plan and the rules +//! that decide when a container is exhausted; hosts own the page size, the +//! rendered rows, which containers are expanded, and child identity. +//! +//! The state crosses the boundary as JSON on every page. It holds only cursors +//! and page fingerprints, never variable values beyond the page being reduced. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// The child collection a page reads, matching DAP `variables.filter`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum VariablePageFilter { + Named, + Indexed, +} + +/// Largest page a host may request; DAP adapters serialize every child. +const MAX_PAGE_SIZE: u32 = 1_000; +/// Fingerprints kept per container; a container this long stops paging. +const MAX_LOADED_PAGES: usize = 4_096; + +/// One run of children of the same kind: named, indexed, or (when the adapter +/// reported no counts) an unfiltered run whose length is unknown. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VariablePageSegment { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) filter: Option, + pub(crate) next_start: u64, + /// Adapter-reported child count of this kind; unknown for the unfiltered run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) total_count: Option, +} + +/// The next `variables` request for a container. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VariablePageRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) filter: Option, + pub(crate) start: u64, + pub(crate) count: u32, + /// Children not yet loaded across every remaining kind, when the adapter + /// reported all counts; hosts show it on their "load more" row. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) remaining_count: Option, +} + +/// Paging cursor of one variable container, owned by Core and echoed by the host. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VariablePageState { + pub(crate) variables_reference: i64, + pub(crate) page_size: u32, + pub(crate) segments: Vec, + /// Digests of pages already merged, to detect adapters that ignore `start`. + #[serde(default)] + pub(crate) loaded_pages: Vec, + /// The request to send next; `None` once the container is exhausted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) next: Option, +} + +/// Input of `debug.planVariablePage`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct PlanVariablePageRequest { + pub(crate) variables_reference: i64, + #[serde(default)] + pub(crate) named_variables: i64, + #[serde(default)] + pub(crate) indexed_variables: i64, + pub(crate) page_size: u32, +} + +/// The identity of one returned child, used only to fingerprint the page. +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VariablePageItem { + pub(crate) name: String, + #[serde(default)] + pub(crate) value: String, + #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] + pub(crate) r#type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) evaluate_name: Option, + #[serde(default)] + pub(crate) variables_reference: i64, +} + +/// Input of `debug.reduceVariablePage`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ReduceVariablePageRequest { + pub(crate) state: VariablePageState, + /// The request this page answers; a reply to an older cursor is ignored. + pub(crate) requested: VariablePageRequest, + pub(crate) page: Vec, + /// Children of the page the host did not already show under the same identity. + pub(crate) new_item_count: u64, +} + +/// Output of `debug.reduceVariablePage`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ReduceVariablePageResult { + /// False when the host must drop the page: it answers an older cursor or + /// repeats a page already shown. + pub(crate) accept: bool, + pub(crate) state: VariablePageState, +} + +/// Plans the first page of a container from its adapter-reported counts. +/// +/// Named children page before indexed ones. Negative counts mean "unknown" in +/// practice and are treated as zero; with neither count the container pages +/// unfiltered until a short or empty page. +pub(crate) fn plan_variable_page( + request: PlanVariablePageRequest, +) -> Result { + validate_page_size(request.page_size)?; + if request.variables_reference <= 0 { + return Err(invalid( + "A variable page needs a positive variablesReference.", + )); + } + let mut segments = Vec::new(); + for (filter, count) in [ + (VariablePageFilter::Named, request.named_variables), + (VariablePageFilter::Indexed, request.indexed_variables), + ] { + if count > 0 { + segments.push(VariablePageSegment { + filter: Some(filter), + next_start: 0, + total_count: Some(count as u64), + }); + } + } + if segments.is_empty() { + segments.push(VariablePageSegment { + filter: None, + next_start: 0, + total_count: None, + }); + } + let mut state = VariablePageState { + variables_reference: request.variables_reference, + page_size: request.page_size, + segments, + loaded_pages: Vec::new(), + next: None, + }; + state.next = next_request(&state); + Ok(state) +} + +/// Merges one returned page into the cursor. +/// +/// A container stops paging when the adapter returns more than was asked for, +/// repeats a page, returns only children the host already shows, reaches the +/// reported count, or (without a count) returns a short or empty page. +pub(crate) fn reduce_variable_page( + request: ReduceVariablePageRequest, +) -> Result { + let ReduceVariablePageRequest { + mut state, + requested, + page, + new_item_count, + } = request; + validate_page_size(state.page_size)?; + if new_item_count > page.len() as u64 { + return Err(invalid("newItemCount exceeds the page length.")); + } + let current = state.next.clone(); + let answers_current = current.as_ref().is_some_and(|next| { + next.filter == requested.filter + && next.start == requested.start + && next.count == requested.count + }); + if !answers_current { + return Ok(ReduceVariablePageResult { + accept: false, + state, + }); + } + if !page.is_empty() { + let fingerprint = page_fingerprint(&page); + if state.loaded_pages.contains(&fingerprint) { + // The adapter ignored `start` and sent the same children again. + state.segments.clear(); + state.next = None; + return Ok(ReduceVariablePageResult { + accept: false, + state, + }); + } + state.loaded_pages.push(fingerprint); + } + let returned = page.len() as u64; + if returned > u64::from(requested.count) + || (returned > 0 && new_item_count == 0) + || state.loaded_pages.len() >= MAX_LOADED_PAGES + { + state.segments.clear(); + } else if let Some(mut segment) = (!state.segments.is_empty()).then(|| state.segments.remove(0)) + { + segment.next_start = requested.start.saturating_add(returned); + let reached_total = segment + .total_count + .is_some_and(|total| segment.next_start >= total); + let unknown_length_continues = returned == u64::from(requested.count); + let continues = returned > 0 + && !reached_total + && (segment.total_count.is_some() || unknown_length_continues); + if continues { + state.segments.insert(0, segment); + } + } + state.next = next_request(&state); + Ok(ReduceVariablePageResult { + accept: true, + state, + }) +} + +fn next_request(state: &VariablePageState) -> Option { + let segment = state.segments.first()?; + let remaining_in = |segment: &VariablePageSegment| { + segment + .total_count + .map(|total| total.saturating_sub(segment.next_start)) + }; + let count = remaining_in(segment).map_or(u64::from(state.page_size), |remaining| { + remaining.min(u64::from(state.page_size)) + }); + let remaining_count = state + .segments + .iter() + .map(remaining_in) + .try_fold(0u64, |sum, remaining| { + remaining.map(|value| sum.saturating_add(value)) + }); + (count > 0).then(|| VariablePageRequest { + filter: segment.filter, + start: segment.next_start, + count: count as u32, + remaining_count, + }) +} + +/// Stable digest of the identity fields of a page, independent of the host. +fn page_fingerprint(page: &[VariablePageItem]) -> String { + let encoded = serde_json::to_vec(page).expect("Variable page identities encode"); + let digest = Sha256::digest(encoded); + digest[..16] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn validate_page_size(page_size: u32) -> Result<(), CoreError> { + if page_size == 0 || page_size > MAX_PAGE_SIZE { + return Err(invalid( + "The variable page size must be between 1 and 1000.", + )); + } + Ok(()) +} + +fn invalid(message: &str) -> CoreError { + CoreError::new(ErrorCode::InvalidRequest, message) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{json, Value}; + + fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/fixtures/debug/variable-page-plan-v1.json" + )) + .unwrap() + } + + // Each scenario replays the same plan/reduce sequence both hosts run, so a + // change in the stop rules shows up in the shared fixture first. + #[test] + fn variable_page_plan_matches_the_shared_fixture() { + let fixture = fixture(); + for scenario in fixture["scenarios"].as_array().unwrap() { + let name = scenario["name"].as_str().unwrap(); + let mut state = + plan_variable_page(serde_json::from_value(scenario["plan"].clone()).unwrap()) + .unwrap(); + assert_eq!( + serde_json::to_value(&state.next).unwrap(), + scenario["firstRequest"], + "{name}: first request" + ); + for (index, step) in scenario["pages"].as_array().unwrap().iter().enumerate() { + let requested = state.next.clone().unwrap_or(VariablePageRequest { + filter: None, + start: 0, + count: state.page_size, + remaining_count: None, + }); + let result = reduce_variable_page(ReduceVariablePageRequest { + state, + requested, + page: serde_json::from_value(step["page"].clone()).unwrap(), + new_item_count: step["newItemCount"].as_u64().unwrap(), + }) + .unwrap(); + assert_eq!( + json!(result.accept), + step["accept"], + "{name}: page {index} accept" + ); + assert_eq!( + serde_json::to_value(&result.state.next).unwrap(), + step["next"], + "{name}: page {index} next" + ); + state = result.state; + } + } + } + + #[test] + fn a_reply_to_an_older_cursor_is_ignored() { + let state = plan_variable_page(PlanVariablePageRequest { + variables_reference: 7, + named_variables: 0, + indexed_variables: 250, + page_size: 100, + }) + .unwrap(); + let result = reduce_variable_page(ReduceVariablePageRequest { + state: state.clone(), + requested: VariablePageRequest { + filter: Some(VariablePageFilter::Indexed), + start: 100, + count: 100, + remaining_count: None, + }, + page: vec![], + new_item_count: 0, + }) + .unwrap(); + assert!(!result.accept); + assert_eq!(result.state, state); + } + + #[test] + fn invalid_plans_are_rejected() { + for (reference, page_size) in [(0, 100), (3, 0), (3, MAX_PAGE_SIZE + 1)] { + assert!(plan_variable_page(PlanVariablePageRequest { + variables_reference: reference, + named_variables: 1, + indexed_variables: 0, + page_size, + }) + .is_err()); + } + } +} diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index 643db4ddbb..4b8d9e2740 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -234,6 +234,8 @@ pub enum CoreCommand { BuildSyncFingerprint, /// Maps build options to the Java language server's project import (`build.languageImport`). BuildLanguageImport, + /// Reduces a test run's termination and decoded summary to one verdict (`build.resolveTestRunOutcome`). + BuildResolveTestRunOutcome, /// Detects VCS roots and registered providers of a workspace (`vcs.detectRoots`). VcsDetectRoots, /// Assigns workspace paths to detected VCS roots (`vcs.ownerOf`). @@ -366,6 +368,14 @@ pub enum CoreCommand { DebugSteppingFilters, /// Moves source breakpoints across one UTF-16 editor mutation (`debug.relocateBreakpoints`). DebugRelocateBreakpoints, + /// Plans the first page of one variable container (`debug.planVariablePage`). + DebugPlanVariablePage, + /// Merges one variable page into its paging cursor (`debug.reduceVariablePage`). + DebugReduceVariablePage, + /// Plans the first request of one disassembly fetch (`debug.planDisassemblyWindow`). + DebugPlanDisassemblyWindow, + /// Reduces one disassembly reply to the next bounded step (`debug.reduceDisassemblyPage`). + DebugReduceDisassemblyPage, /// Replaces breakpoints for one source file (`debug.setBreakpoints`). DebugSetBreakpoints, /// Replaces exception filters for one debug session (`debug.setExceptionBreakpoints`). @@ -859,6 +869,7 @@ impl CoreCommand { "build.decodeDependencies" => Some(Self::BuildDecodeDependencies), "build.syncFingerprint" => Some(Self::BuildSyncFingerprint), "build.languageImport" => Some(Self::BuildLanguageImport), + "build.resolveTestRunOutcome" => Some(Self::BuildResolveTestRunOutcome), "vcs.detectRoots" => Some(Self::VcsDetectRoots), "vcs.ownerOf" => Some(Self::VcsOwnerOf), "vcs.decodeReply" => Some(Self::VcsDecodeReply), @@ -926,6 +937,10 @@ impl CoreCommand { "debug.javaTestLaunch" => Some(Self::DebugJavaTestLaunch), "debug.steppingFilters" => Some(Self::DebugSteppingFilters), "debug.relocateBreakpoints" => Some(Self::DebugRelocateBreakpoints), + "debug.planVariablePage" => Some(Self::DebugPlanVariablePage), + "debug.reduceVariablePage" => Some(Self::DebugReduceVariablePage), + "debug.planDisassemblyWindow" => Some(Self::DebugPlanDisassemblyWindow), + "debug.reduceDisassemblyPage" => Some(Self::DebugReduceDisassemblyPage), "debug.setBreakpoints" => Some(Self::DebugSetBreakpoints), "debug.setExceptionBreakpoints" => Some(Self::DebugSetExceptionBreakpoints), "debug.setFunctionBreakpoints" => Some(Self::DebugSetFunctionBreakpoints), @@ -1227,6 +1242,10 @@ mod tests { "debug.javaTestLaunch", "debug.steppingFilters", "debug.relocateBreakpoints", + "debug.planVariablePage", + "debug.reduceVariablePage", + "debug.planDisassemblyWindow", + "debug.reduceDisassemblyPage", "debug.setBreakpoints", "debug.setExceptionBreakpoints", "debug.setFunctionBreakpoints", diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index a69cda27be..780fb2967b 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -89,6 +89,29 @@ fn agent_response( } } +/// Decodes a typed plan/reduce payload, runs the pure `operation` on it, and +/// encodes its result; a payload that does not decode is `InvalidRequest`. +fn reducer_response( + id: Option, + payload: serde_json::Value, + invalid_message: &str, + operation: fn(T) -> Result, +) -> CoreResponse { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new(ErrorCode::InvalidRequest, invalid_message) + .with_details(error.to_string()) + }) + .and_then(operation) + { + Ok(data) => CoreResponse::success( + id, + serde_json::to_value(data).expect("Reducer results always encode"), + ), + Err(error) => CoreResponse::failure(id, error), + } +} + /// Host detailed-diagnostics state; `roots: None` enables DEBUG for every root. #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -543,6 +566,12 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { parsed.payload, crate::build::build_language_import_command, ), + CoreCommand::BuildResolveTestRunOutcome => reducer_response( + id, + parsed.payload, + "Invalid test run outcome request", + crate::build::resolve_test_run_outcome, + ), CoreCommand::VcsDetectRoots => { plugin_response(id, parsed.payload, crate::vcs::vcs_detect_roots_command) } @@ -1485,6 +1514,30 @@ fn execute_parsed(mut parsed: CoreRequest) -> CoreResponse { Err(error) => CoreResponse::failure(id, error), } } + CoreCommand::DebugPlanVariablePage => reducer_response( + id, + parsed.payload, + "Invalid debug variable page plan request", + crate::debug::plan_variable_page, + ), + CoreCommand::DebugReduceVariablePage => reducer_response( + id, + parsed.payload, + "Invalid debug variable page reduction request", + crate::debug::reduce_variable_page, + ), + CoreCommand::DebugPlanDisassemblyWindow => reducer_response( + id, + parsed.payload, + "Invalid debug disassembly window request", + crate::debug::plan_disassembly_window, + ), + CoreCommand::DebugReduceDisassemblyPage => reducer_response( + id, + parsed.payload, + "Invalid debug disassembly page reduction request", + crate::debug::reduce_disassembly_page, + ), CoreCommand::DebugRelocateBreakpoints => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index bb9e8faa71..ae467d88b6 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -1198,6 +1198,10 @@ package manager owns the download and Lithe does not infer bytes from logs. | `debug.javaTestLaunch` | Normalize JUnit or TestNG launch metadata into Java DAP arguments | | `debug.steppingFilters` | Return adapter defaults or normalize portable stepping filters | | `debug.relocateBreakpoints` | Move source breakpoints across one exact UTF-16 editor replacement | +| `debug.planVariablePage` | Plan the first bounded `variables` page of one scope or structured variable from its reported counts | +| `debug.reduceVariablePage` | Merge one returned variable page into its cursor and decide whether the container is exhausted | +| `debug.planDisassemblyWindow` | Plan the first `disassemble` request of the window around the instruction pointer or one listed edge | +| `debug.reduceDisassemblyPage` | Reduce one `disassemble` reply to the next bounded recovery request, a deduplicated page, or the adapter's failure | | `debug.setBreakpoints` | Replace and deterministically order one source's DAP breakpoints | | `debug.setExceptionBreakpoints` | Replace and deterministically order one session's exception filters | | `debug.setFunctionBreakpoints` | Replace and deterministically order one session's named function breakpoints | @@ -1419,6 +1423,7 @@ package manager owns the download and Lithe does not infer bytes from logs. | `build.decodeDependencies` | Decode dependency output or a provider result into one project's bounded tree | | `build.syncFingerprint` | Digest the files a build model depends on, to tell whether it is stale | | `build.languageImport` | Map a build system's options to the Java language server's Maven or Gradle project import | +| `build.resolveTestRunOutcome` | Reduce a test run's termination, selected scope and decoded summary to one tool-neutral verdict | | `vcs.detectRoots` | Find the VCS roots of a workspace from static markers, with registered providers, ambiguous directories, and applied mappings | | `vcs.ownerOf` | Assign workspace paths to the detected root that owns each | | `vcs.decodeReply` | Validate a VCS provider's reply and return the normalized result | @@ -6066,6 +6071,57 @@ read-only package boundaries, optimistic comparison, metadata-preserving atomic read-back. No editor command discovers or executes tools. Global defaults and complete one-time legacy migration remain incomplete; these APIs alone do not complete Stage D. +### Test run verdict + +`build.resolveTestRunOutcome` takes `{ termination, scope, summary? }`, where +`termination` is `{ kind: "exited", exitCode }`, `{ kind: "timedOut" }` or +`{ kind: "cancelled" }`, `scope` is `workspace`, `project`, `suite` or `case`, +and `summary` is the `{ total, passed?, failed?, errors?, skipped? }` the +provider's decoded results reported, or `null` when the run has no structured +results. It returns `{ outcome, success, exitCode }` with `outcome` one of +`passed`, `testsFailed`, `exitedWithError`, `noTestsMatched` (an explicit suite +or case ran nothing), `noTestsFound` (a project or workspace scope ran nothing), +`timedOut` or `cancelled`. Interruptions never claim a test outcome, reported +failures outrank the exit code, a non-zero exit outranks an empty result, and a +zero exit with decoded results but no executed test is not a pass. Counts that +exceed `total` are `InvalidRequest`. No tool or selector syntax is interpreted. +Hosts localize the outcome. Cases: `shared/fixtures/build/test-run-outcome-v1.json`. + +### Variable and disassembly paging + +`debug.planVariablePage { variablesReference, namedVariables?, indexedVariables?, pageSize }` +returns a cursor `{ variablesReference, pageSize, segments, loadedPages, next? }`. +Named children page before indexed ones; without counts the container pages +unfiltered until a short page. `next` is the `{ filter?, start, count, remainingCount? }` +to send as `debug.inspect` `variableFilter`/`start`/`count`; it is absent once the +container is exhausted. `debug.reduceVariablePage { state, requested, page, newItemCount }` +takes the page's `{ name, value, type?, evaluateName?, variablesReference }` +identities and how many of them the host did not already show, and returns +`{ accept, state }`. `accept: false` means the host drops the page: it answers an +older cursor or repeats a page (an adapter that ignores `start`). Paging stops on +an oversized page, a page of only known children, the reported count, or a short +page without a count. Hosts keep the page size, expansion and child identity. +Cases: `shared/fixtures/debug/variable-page-plan-v1.json`. + +`debug.planDisassemblyWindow { direction, reference, count, instructionsBefore? }` +returns a fetch whose `request` is the exact DAP `disassemble` arguments; +`direction` is `around` (the first window, `instructionsBefore` of `count` above +the instruction pointer), `before` or `after` (one listed edge). +`debug.reduceDisassemblyPage { fetch, reply, knownAddresses? }` takes the reply as +`{ kind: "instructions", instructions: [{ address, instruction, instructionBytes? }] }`, +`{ kind: "rejected", message }` (the adapter answered `success: false`) or +`{ kind: "failed", message }` (transport, timeout, cancellation) and returns +`{ kind: "request", fetch }`, `{ kind: "page", keep, exhausted }` or +`{ kind: "failed", message }`. `keep` indexes the reply's addressable +instructions (empty padding removed) not already in `knownAddresses`, compared +as canonical address text. A refused first window with a negative offset is +retried from the instruction pointer; a refused edge page is halved down to one +instruction, then the known edge is probed: only an edge that still +disassembles ends the direction (`exhausted`), otherwise the refused window's +message is returned. Transport failures are never treated as a boundary. +Addresses are never parsed or offset by Core or hosts. Hosts keep the viewport, +scroll anchor and rendering. Cases: `shared/fixtures/debug/disassembly-paging-v1.json`. + ### Windows source-breakpoint relocation Windows forwards the editor's exact source-preserving edits to diff --git a/shared/fixtures/build/test-run-outcome-v1.json b/shared/fixtures/build/test-run-outcome-v1.json new file mode 100644 index 0000000000..f9e620ce5d --- /dev/null +++ b/shared/fixtures/build/test-run-outcome-v1.json @@ -0,0 +1,137 @@ +{ + "version": 1, + "description": "build.resolveTestRunOutcome: one tool-neutral verdict per test run. macOS used to pass a zero-match selection on exit code 0; Windows failed it.", + "cases": [ + { + "name": "selected case that ran nothing did not match", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "case", + "summary": { "total": 0, "passed": 0, "failed": 0, "errors": 0, "skipped": 0 } + }, + "expected": { "outcome": "noTestsMatched", "success": false, "exitCode": 0 } + }, + { + "name": "selected suite that ran nothing did not match", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "suite", + "summary": { "total": 0 } + }, + "expected": { "outcome": "noTestsMatched", "success": false, "exitCode": 0 } + }, + { + "name": "project scope without tests found none", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "project", + "summary": { "total": 0 } + }, + "expected": { "outcome": "noTestsFound", "success": false, "exitCode": 0 } + }, + { + "name": "passing tests pass", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "suite", + "summary": { "total": 3, "passed": 3, "failed": 0, "errors": 0, "skipped": 0 } + }, + "expected": { "outcome": "passed", "success": true, "exitCode": 0 } + }, + { + "name": "skipped-only run is a pass", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "case", + "summary": { "total": 1, "passed": 0, "failed": 0, "errors": 0, "skipped": 1 } + }, + "expected": { "outcome": "passed", "success": true, "exitCode": 0 } + }, + { + "name": "reported failure outranks a zero exit code", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "workspace", + "summary": { "total": 2, "passed": 1, "failed": 1, "errors": 0, "skipped": 0 } + }, + "expected": { "outcome": "testsFailed", "success": false, "exitCode": 0 } + }, + { + "name": "reported error outranks a non-zero exit code", + "request": { + "termination": { "kind": "exited", "exitCode": 1 }, + "scope": "suite", + "summary": { "total": 1, "passed": 0, "failed": 0, "errors": 1, "skipped": 0 } + }, + "expected": { "outcome": "testsFailed", "success": false, "exitCode": 1 } + }, + { + "name": "non-zero exit without results is a tool failure, not a zero match", + "request": { + "termination": { "kind": "exited", "exitCode": 1 }, + "scope": "case", + "summary": { "total": 0 } + }, + "expected": { "outcome": "exitedWithError", "success": false, "exitCode": 1 } + }, + { + "name": "run without structured results relies on the exit code", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "case", + "summary": null + }, + "expected": { "outcome": "passed", "success": true, "exitCode": 0 } + }, + { + "name": "run without structured results fails on a non-zero exit", + "request": { + "termination": { "kind": "exited", "exitCode": 2 }, + "scope": "workspace" + }, + "expected": { "outcome": "exitedWithError", "success": false, "exitCode": 2 } + }, + { + "name": "timeout never claims a test outcome", + "request": { + "termination": { "kind": "timedOut" }, + "scope": "suite", + "summary": { "total": 4, "passed": 4 } + }, + "expected": { "outcome": "timedOut", "success": false, "exitCode": null } + }, + { + "name": "cancellation never claims a test outcome", + "request": { + "termination": { "kind": "cancelled" }, + "scope": "case", + "summary": { "total": 0 } + }, + "expected": { "outcome": "cancelled", "success": false, "exitCode": null } + } + ], + "rejected": [ + { + "name": "counts exceeding the total", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "suite", + "summary": { "total": 1, "passed": 2 } + } + }, + { + "name": "unknown termination kind", + "request": { + "termination": { "kind": "crashed" }, + "scope": "suite" + } + }, + { + "name": "tool-specific scope", + "request": { + "termination": { "kind": "exited", "exitCode": 0 }, + "scope": "surefireMethod" + } + } + ] +} diff --git a/shared/fixtures/debug/disassembly-paging-v1.json b/shared/fixtures/debug/disassembly-paging-v1.json new file mode 100644 index 0000000000..61c4b47814 --- /dev/null +++ b/shared/fixtures/debug/disassembly-paging-v1.json @@ -0,0 +1,868 @@ +{ + "version": 1, + "description": "debug.planDisassemblyWindow / debug.reduceDisassemblyPage: the bounded request sequence a disassembly fetch sends and the step after every adapter reply. 'rejected' is an adapter response with success=false; 'failed' is a transport failure, timeout or cancellation.", + "scenarios": [ + { + "name": "first window drops adapter padding", + "plan": { + "direction": "around", + "reference": "0x19b5f2a4c", + "count": 96, + "instructionsBefore": 24 + }, + "steps": [ + { + "request": { + "memoryReference": "0x19b5f2a4c", + "instructionOffset": -24, + "instructionCount": 96 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x19b5f2a48", + "instruction": "mov x16, #0x148" + }, + { + "address": "0x19b5f2a4c", + "instruction": "svc #0x80" + }, + { + "address": "0xffffffffffffffff", + "instruction": "" + } + ] + }, + "expected": { + "kind": "page", + "keep": [ + 0, + 1 + ], + "exhausted": false + } + } + ] + }, + { + "name": "first window refused at a negative offset retries from the instruction pointer", + "plan": { + "direction": "around", + "reference": "0x19b5f2a4c", + "count": 96, + "instructionsBefore": 24 + }, + "steps": [ + { + "request": { + "memoryReference": "0x19b5f2a4c", + "instructionOffset": -24, + "instructionCount": 96 + }, + "reply": { + "kind": "failed", + "message": "negative offsets are not supported" + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x19b5f2a4c", + "instructionOffset": 0, + "instructionCount": 96 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x19b5f2a4c", + "instruction": "svc #0x80" + } + ] + }, + "expected": { + "kind": "page", + "keep": [ + 0 + ], + "exhausted": false + } + } + ] + }, + { + "name": "retried first window failure is reported", + "plan": { + "direction": "around", + "reference": "0x10", + "count": 96, + "instructionsBefore": 24 + }, + "steps": [ + { + "request": { + "memoryReference": "0x10", + "instructionOffset": -24, + "instructionCount": 96 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x10", + "instructionOffset": 0, + "instructionCount": 96 + }, + "reply": { + "kind": "rejected", + "message": "Could not read memory" + }, + "expected": { + "kind": "failed", + "message": "Could not read memory" + } + } + ] + }, + { + "name": "repeated edge in another spelling exhausts the direction", + "plan": { + "direction": "before", + "reference": "0x19b5f2a4c", + "count": 64 + }, + "knownAddresses": [ + "0x19b5f2a4c" + ], + "steps": [ + { + "request": { + "memoryReference": "0x19b5f2a4c", + "instructionOffset": -64, + "instructionCount": 64 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x000019b5f2a4c", + "instruction": "svc #0x80" + } + ] + }, + "expected": { + "kind": "page", + "keep": [], + "exhausted": true + } + } + ] + }, + { + "name": "duplicates within a page are listed once", + "plan": { + "direction": "after", + "reference": "0x19b5f2a4c", + "count": 64 + }, + "knownAddresses": [ + "0x19b5f2a4c" + ], + "steps": [ + { + "request": { + "memoryReference": "0x19b5f2a4c", + "instructionOffset": 1, + "instructionCount": 64 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x19b5f2a50", + "instruction": "ret" + }, + { + "address": "0x19b5f2a50", + "instruction": "ret" + } + ] + }, + "expected": { + "kind": "page", + "keep": [ + 0 + ], + "exhausted": false + } + } + ] + }, + { + "name": "before: a refused page shrinks without skipping readable neighbours", + "plan": { + "direction": "before", + "reference": "0x100000014", + "count": 64 + }, + "knownAddresses": [ + "0x100000014" + ], + "steps": [ + { + "request": { + "memoryReference": "0x100000014", + "instructionOffset": -64, + "instructionCount": 64 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000014", + "instructionOffset": -32, + "instructionCount": 32 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x100000010", + "instruction": "nop" + }, + { + "address": "0xffffffffffffffff", + "instruction": "" + } + ] + }, + "expected": { + "kind": "page", + "keep": [ + 0 + ], + "exhausted": false + } + } + ] + }, + { + "name": "before: a mapping boundary ends the direction only after the known edge is verified", + "plan": { + "direction": "before", + "reference": "0x100000000", + "count": 64 + }, + "knownAddresses": [ + "0x100000000" + ], + "steps": [ + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -64, + "instructionCount": 64 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -32, + "instructionCount": 32 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -16, + "instructionCount": 16 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -8, + "instructionCount": 8 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -4, + "instructionCount": 4 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -2, + "instructionCount": 2 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -1, + "instructionCount": 1 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 0, + "instructionCount": 1 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x0000000100000000", + "instruction": "; _mh_execute_header" + } + ] + }, + "expected": { + "kind": "page", + "keep": [], + "exhausted": true + } + } + ] + }, + { + "name": "after: a refused page shrinks without skipping readable neighbours", + "plan": { + "direction": "after", + "reference": "0x100000014", + "count": 64 + }, + "knownAddresses": [ + "0x100000014" + ], + "steps": [ + { + "request": { + "memoryReference": "0x100000014", + "instructionOffset": 1, + "instructionCount": 64 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000014", + "instructionOffset": 1, + "instructionCount": 32 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x100000018", + "instruction": "nop" + }, + { + "address": "0xffffffffffffffff", + "instruction": "" + } + ] + }, + "expected": { + "kind": "page", + "keep": [ + 0 + ], + "exhausted": false + } + } + ] + }, + { + "name": "after: a mapping boundary ends the direction only after the known edge is verified", + "plan": { + "direction": "after", + "reference": "0x100000000", + "count": 64 + }, + "knownAddresses": [ + "0x100000000" + ], + "steps": [ + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 1, + "instructionCount": 64 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 1, + "instructionCount": 32 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 1, + "instructionCount": 16 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 1, + "instructionCount": 8 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 1, + "instructionCount": 4 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 1, + "instructionCount": 2 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 1, + "instructionCount": 1 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 0, + "instructionCount": 1 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x0000000100000000", + "instruction": "; _mh_execute_header" + } + ] + }, + "expected": { + "kind": "page", + "keep": [], + "exhausted": true + } + } + ] + }, + { + "name": "an edge probe that does not return the edge reports the refused window", + "plan": { + "direction": "before", + "reference": "0x100000000", + "count": 64 + }, + "knownAddresses": [ + "0x100000000" + ], + "steps": [ + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -64, + "instructionCount": 64 + }, + "reply": { + "kind": "rejected", + "message": "Could not decode the requested window" + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -32, + "instructionCount": 32 + }, + "reply": { + "kind": "rejected", + "message": "Could not decode the requested window" + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -16, + "instructionCount": 16 + }, + "reply": { + "kind": "rejected", + "message": "Could not decode the requested window" + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -8, + "instructionCount": 8 + }, + "reply": { + "kind": "rejected", + "message": "Could not decode the requested window" + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -4, + "instructionCount": 4 + }, + "reply": { + "kind": "rejected", + "message": "Could not decode the requested window" + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -2, + "instructionCount": 2 + }, + "reply": { + "kind": "rejected", + "message": "Could not decode the requested window" + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -1, + "instructionCount": 1 + }, + "reply": { + "kind": "rejected", + "message": "Could not decode the requested window" + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 0, + "instructionCount": 1 + }, + "reply": { + "kind": "instructions", + "instructions": [ + { + "address": "0x200000000", + "instruction": "nop" + } + ] + }, + "expected": { + "kind": "failed", + "message": "Could not decode the requested window" + } + } + ] + }, + { + "name": "a failed edge probe is reported instead of exhausting the listing", + "plan": { + "direction": "before", + "reference": "0x100000000", + "count": 64 + }, + "knownAddresses": [ + "0x100000000" + ], + "steps": [ + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -64, + "instructionCount": 64 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -32, + "instructionCount": 32 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -16, + "instructionCount": 16 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -8, + "instructionCount": 8 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -4, + "instructionCount": 4 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -2, + "instructionCount": 2 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -1, + "instructionCount": 1 + }, + "reply": { + "kind": "rejected", + "message": "Unexpected error while disassembling instructions." + }, + "expected": { + "kind": "request" + } + }, + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": 0, + "instructionCount": 1 + }, + "reply": { + "kind": "failed", + "message": "The request timed out." + }, + "expected": { + "kind": "failed", + "message": "The request timed out." + } + } + ] + }, + { + "name": "transport failures are not mistaken for mapping boundaries", + "plan": { + "direction": "before", + "reference": "0x100000000", + "count": 64 + }, + "knownAddresses": [ + "0x100000000" + ], + "steps": [ + { + "request": { + "memoryReference": "0x100000000", + "instructionOffset": -64, + "instructionCount": 64 + }, + "reply": { + "kind": "failed", + "message": "The request timed out." + }, + "expected": { + "kind": "failed", + "message": "The request timed out." + } + } + ] + } + ] +} diff --git a/shared/fixtures/debug/v1-baseline.json b/shared/fixtures/debug/v1-baseline.json index 463246dfce..4d7352e1f7 100644 --- a/shared/fixtures/debug/v1-baseline.json +++ b/shared/fixtures/debug/v1-baseline.json @@ -528,6 +528,34 @@ } ], "gaps": [] + }, + { + "id": "core-variable-page-cursor", + "behavior": "variable containers page by the adapter's named/indexed counts, drop repeated pages and stop when exhausted (Core-owned cursor)", + "status": "covered", + "evidence": [ + { + "platform": "rust", + "path": "rust/lithe-core/src/debug/variable_paging.rs", + "test": "variable_page_plan_matches_the_shared_fixture", + "fixture": "shared/fixtures/debug/variable-page-plan-v1.json" + } + ], + "gaps": [] + }, + { + "id": "core-disassembly-page-recovery", + "behavior": "disassembly windows shrink on rejection, retry negative first offsets from the instruction pointer and end a direction only at a readable edge", + "status": "covered", + "evidence": [ + { + "platform": "rust", + "path": "rust/lithe-core/src/debug/disassembly_paging.rs", + "test": "disassembly_paging_matches_the_shared_fixture", + "fixture": "shared/fixtures/debug/disassembly-paging-v1.json" + } + ], + "gaps": [] } ] } diff --git a/shared/fixtures/debug/variable-page-plan-v1.json b/shared/fixtures/debug/variable-page-plan-v1.json new file mode 100644 index 0000000000..a0e0dcafc5 --- /dev/null +++ b/shared/fixtures/debug/variable-page-plan-v1.json @@ -0,0 +1,1573 @@ +{ + "version": 1, + "description": "debug.planVariablePage / debug.reduceVariablePage: page cursors for one variable container. 'page' lists the identity fields the host fingerprints; 'newItemCount' is how many of them the host did not already show.", + "scenarios": [ + { + "name": "large indexed collection loads in bounded pages", + "plan": { + "variablesReference": 700, + "namedVariables": 0, + "indexedVariables": 250, + "pageSize": 100 + }, + "firstRequest": { + "filter": "indexed", + "start": 0, + "count": 100, + "remainingCount": 250 + }, + "pages": [ + { + "page": [ + { + "name": "[0]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[1]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[2]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[3]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[4]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[5]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[6]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[7]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[8]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[9]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[10]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[11]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[12]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[13]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[14]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[15]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[16]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[17]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[18]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[19]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[20]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[21]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[22]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[23]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[24]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[25]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[26]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[27]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[28]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[29]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[30]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[31]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[32]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[33]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[34]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[35]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[36]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[37]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[38]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[39]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[40]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[41]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[42]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[43]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[44]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[45]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[46]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[47]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[48]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[49]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[50]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[51]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[52]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[53]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[54]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[55]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[56]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[57]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[58]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[59]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[60]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[61]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[62]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[63]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[64]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[65]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[66]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[67]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[68]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[69]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[70]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[71]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[72]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[73]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[74]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[75]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[76]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[77]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[78]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[79]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[80]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[81]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[82]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[83]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[84]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[85]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[86]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[87]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[88]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[89]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[90]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[91]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[92]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[93]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[94]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[95]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[96]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[97]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[98]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[99]", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 100, + "accept": true, + "next": { + "filter": "indexed", + "start": 100, + "count": 100, + "remainingCount": 150 + } + }, + { + "page": [ + { + "name": "[100]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[101]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[102]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[103]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[104]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[105]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[106]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[107]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[108]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[109]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[110]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[111]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[112]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[113]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[114]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[115]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[116]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[117]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[118]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[119]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[120]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[121]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[122]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[123]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[124]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[125]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[126]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[127]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[128]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[129]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[130]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[131]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[132]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[133]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[134]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[135]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[136]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[137]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[138]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[139]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[140]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[141]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[142]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[143]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[144]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[145]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[146]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[147]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[148]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[149]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[150]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[151]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[152]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[153]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[154]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[155]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[156]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[157]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[158]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[159]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[160]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[161]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[162]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[163]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[164]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[165]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[166]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[167]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[168]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[169]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[170]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[171]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[172]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[173]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[174]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[175]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[176]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[177]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[178]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[179]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[180]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[181]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[182]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[183]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[184]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[185]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[186]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[187]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[188]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[189]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[190]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[191]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[192]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[193]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[194]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[195]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[196]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[197]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[198]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[199]", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 100, + "accept": true, + "next": { + "filter": "indexed", + "start": 200, + "count": 50, + "remainingCount": 50 + } + }, + { + "page": [ + { + "name": "[200]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[201]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[202]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[203]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[204]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[205]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[206]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[207]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[208]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[209]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[210]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[211]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[212]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[213]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[214]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[215]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[216]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[217]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[218]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[219]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[220]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[221]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[222]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[223]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[224]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[225]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[226]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[227]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[228]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[229]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[230]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[231]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[232]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[233]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[234]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[235]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[236]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[237]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[238]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[239]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[240]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[241]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[242]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[243]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[244]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[245]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[246]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[247]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[248]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[249]", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 50, + "accept": true, + "next": null + } + ] + }, + { + "name": "named children page before indexed children", + "plan": { + "variablesReference": 9, + "namedVariables": 2, + "indexedVariables": 3, + "pageSize": 100 + }, + "firstRequest": { + "filter": "named", + "start": 0, + "count": 2, + "remainingCount": 5 + }, + "pages": [ + { + "page": [ + { + "name": "size", + "value": "1", + "variablesReference": 0 + }, + { + "name": "modCount", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 2, + "accept": true, + "next": { + "filter": "indexed", + "start": 0, + "count": 3, + "remainingCount": 3 + } + }, + { + "page": [ + { + "name": "[0]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[1]", + "value": "1", + "variablesReference": 0 + }, + { + "name": "[2]", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 3, + "accept": true, + "next": null + } + ] + }, + { + "name": "adapter that ignores start repeats a page and stops paging", + "plan": { + "variablesReference": 5, + "namedVariables": 0, + "indexedVariables": 0, + "pageSize": 2 + }, + "firstRequest": { + "start": 0, + "count": 2 + }, + "pages": [ + { + "page": [ + { + "name": "a", + "value": "1", + "variablesReference": 0 + }, + { + "name": "b", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 2, + "accept": true, + "next": { + "start": 2, + "count": 2 + } + }, + { + "page": [ + { + "name": "a", + "value": "1", + "variablesReference": 0 + }, + { + "name": "b", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 0, + "accept": false, + "next": null + } + ] + }, + { + "name": "unknown length stops on a short page", + "plan": { + "variablesReference": 5, + "pageSize": 100 + }, + "firstRequest": { + "start": 0, + "count": 100 + }, + "pages": [ + { + "page": [ + { + "name": "a", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 1, + "accept": true, + "next": null + } + ] + }, + { + "name": "a page of only known children stops paging", + "plan": { + "variablesReference": 5, + "namedVariables": 10, + "pageSize": 4 + }, + "firstRequest": { + "filter": "named", + "start": 0, + "count": 4, + "remainingCount": 10 + }, + "pages": [ + { + "page": [ + { + "name": "a", + "value": "1", + "variablesReference": 0 + }, + { + "name": "b", + "value": "1", + "variablesReference": 0 + }, + { + "name": "c", + "value": "1", + "variablesReference": 0 + }, + { + "name": "d", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 4, + "accept": true, + "next": { + "filter": "named", + "start": 4, + "count": 4, + "remainingCount": 6 + } + }, + { + "page": [ + { + "name": "a", + "value": "2", + "variablesReference": 0 + }, + { + "name": "b", + "value": "2", + "variablesReference": 0 + } + ], + "newItemCount": 0, + "accept": true, + "next": null + } + ] + }, + { + "name": "an oversized page stops paging", + "plan": { + "variablesReference": 5, + "namedVariables": 10, + "pageSize": 2 + }, + "firstRequest": { + "filter": "named", + "start": 0, + "count": 2, + "remainingCount": 10 + }, + "pages": [ + { + "page": [ + { + "name": "a", + "value": "1", + "variablesReference": 0 + }, + { + "name": "b", + "value": "1", + "variablesReference": 0 + }, + { + "name": "c", + "value": "1", + "variablesReference": 0 + } + ], + "newItemCount": 3, + "accept": true, + "next": null + } + ] + }, + { + "name": "an empty page ends the segment", + "plan": { + "variablesReference": 5, + "namedVariables": 10, + "indexedVariables": 1, + "pageSize": 4 + }, + "firstRequest": { + "filter": "named", + "start": 0, + "count": 4, + "remainingCount": 11 + }, + "pages": [ + { + "page": [], + "newItemCount": 0, + "accept": true, + "next": { + "filter": "indexed", + "start": 0, + "count": 1, + "remainingCount": 1 + } + } + ] + } + ] +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index 3192a315a7..c60c58ae64 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -1039,6 +1039,8 @@ "debugger.hideRunAndDebug": "Hide Run and Debug", "debugger.into": "Into", "debugger.lineNumber": "Line {line}", + "debugger.loadMoreVariables": "Load more", + "debugger.loadScopeVariables": "Load variables", "debugger.loadingVariable": "Loading variable", "debugger.memory.addressOrExpression": "Address or expression", "debugger.memory.addressOrExpressionLabel": "Memory address or expression", @@ -9833,6 +9835,8 @@ "terminal.toolbarTabsOnRight": "Tabs on Right", "terminal.toolbarTerminalWidth": "Terminal Width", "terminal.toolbarVerticalTabs": "Vertical Tabs", + "tests.outcome.noTestsFound": "No tests were found.", + "tests.outcome.noTestsMatched": "No tests matched the selection.", "titleProject.addProject": "Add Project", "titleProject.cloneRepository": "Clone Repository…", "titleProject.closeAllProjects": "Close All Projects", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index 935a8077eb..fcbb6773bf 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -1039,6 +1039,8 @@ "debugger.hideRunAndDebug": "隐藏运行和调试", "debugger.into": "进入", "debugger.lineNumber": "第 {line} 行", + "debugger.loadMoreVariables": "加载更多", + "debugger.loadScopeVariables": "加载变量", "debugger.loadingVariable": "正在加载变量", "debugger.memory.addressOrExpression": "地址或表达式", "debugger.memory.addressOrExpressionLabel": "内存地址或表达式", @@ -9833,6 +9835,8 @@ "terminal.toolbarTabsOnRight": "标签页在右侧", "terminal.toolbarTerminalWidth": "终端宽度", "terminal.toolbarVerticalTabs": "垂直标签页", + "tests.outcome.noTestsFound": "没有找到任何测试。", + "tests.outcome.noTestsMatched": "没有测试匹配所选范围。", "titleProject.addProject": "添加项目", "titleProject.cloneRepository": "克隆仓库…", "titleProject.closeAllProjects": "关闭所有项目", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 0763eb7f1a..1cbc55fdfb 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -8382,6 +8382,20 @@ "line": "string" } }, + { + "id": "debugger.loadMoreVariables", + "description": "Debugger variables panel: Load more", + "platforms": [ + "windows" + ] + }, + { + "id": "debugger.loadScopeVariables", + "description": "Debugger variables panel: Load variables", + "platforms": [ + "windows" + ] + }, { "id": "debugger.loadingVariable", "description": "Windows UI: debugger.loadingVariable", @@ -95462,6 +95476,30 @@ "windows" ] }, + { + "id": "tests.outcome.noTestsFound", + "description": "Verdict shown when a test run executed no test", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "No tests were found.", + "arguments": [] + } + }, + { + "id": "tests.outcome.noTestsMatched", + "description": "Verdict shown when a test run executed no test", + "platforms": [ + "macos", + "windows" + ], + "macos": { + "key": "No tests matched the selection.", + "arguments": [] + } + }, { "id": "titleProject.addProject", "description": "Windows UI: titleProject.addProject", diff --git a/shared/platform-feature-matrix/features/debug-disassembly.json b/shared/platform-feature-matrix/features/debug-disassembly.json index 6846b50286..b00d3cb173 100644 --- a/shared/platform-feature-matrix/features/debug-disassembly.json +++ b/shared/platform-feature-matrix/features/debug-disassembly.json @@ -25,7 +25,12 @@ "macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift", "macos/Tests/LitheTests/DebugJSONSyntaxTests.swift", "shared/fixtures/debug/projection-v2-examples.json", - "macos/Tests/LitheTests/DebugDisassemblyLayoutTests.swift" + "macos/Tests/LitheTests/DebugDisassemblyLayoutTests.swift", + "rust/lithe-core/src/debug/disassembly_paging.rs", + "macos/Sources/LitheCoreContracts/Debug/DebugPagingContracts.swift", + "macos/Sources/Lithe/Core/Rust/RustRunDebugReducers.swift", + "macos/Tests/LitheDebugModuleTests/LinkedCoreDebugPaging.swift", + "shared/fixtures/debug/disassembly-paging-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -33,11 +38,13 @@ "windows": { "evidence": [ "rust/lithe-core/src/debug/engine.rs", - "rust/lithe-core/src/debug/capabilities.rs" + "rust/lithe-core/src/debug/capabilities.rs", + "rust/lithe-core/src/debug/disassembly_paging.rs", + "shared/fixtures/debug/disassembly-paging-v1.json" ], "implementationStatus": "missing", "verificationStatus": "not-applicable" }, "owner": "Debug", - "verification": "macOS:用只带符号表、没有调试信息的 C 程序(含 raise(SIGTRAP))经 Xcode lldb-dap 启动调试,暂停后栈帧显示函数名与地址而不是伪造的文件和第 1 行,反汇编标签自动打开并高亮当前指令;适配器不声明 supportsDisassembleRequest 时没有反汇编标签。反汇编中单步改为按指令前进(仅适配器声明 supportsSteppingGranularity 时),关闭反汇编标签后恢复按行单步;点击地址栏设置或移除指令断点(仅适配器声明 supportsInstructionBreakpoints 时),断点只属于当前会话、不写入持久化,新会话不重放。反汇编打开时,步过/步入的按钮提示变为按指令单步,头部显示“按指令单步”;适配器不支持时头部提示单步仍按源码行前进。寄存器作用域出现在寄存器标签,内存标签接受地址或保存着地址的表达式,从目标地址前 64 字节读起并分页,目标字节在十六进制和 ASCII 两侧高亮并滚入可见区,读取不到的字节显示为缺失而不是 00;右侧显示设置可切换进制、每格字节数、每行字节数、字节序与 ASCII 列;只读内存,不开放 writeMemory。连续单步时线程、调用栈、变量、反汇编、内存与 JSON 视图原地更新,不整面板清空重画,变化的内存字节被标出,继续执行或会话结束才清空。单步暂停到新作用域返回之前,旧变量树与 JSON 仍不可操作;同步取消和切换会话后的旧反汇编失败不重试、不覆盖保留的内存;传输失败清空保留数据。内存偏移表头使用请求引用而不是返回的页首地址。地址和引用对宿主是不透明字符串,宿主不做地址运算、不内置 CPU 寄存器表,也不按适配器 ID 分支。Windows 尚未实现,共享 Rust 内核已经提供能力快照与 debug.inspect 的 disassemble、readMemory,待 React 界面接入。 macOS 反汇编短指令从左侧地址列开始,长指令可横向滚动,当前指令定位只改变纵向位置;接近上下边缘时自动补充有界指令页,不再点击加载更早/更晚按钮。上方插入保留可见指令像素位置,下方追加不跳回当前指令;同页重复地址和无进展停止、失败需显式重试,关闭/继续执行后的迟到页面不得覆盖。运行 DebugDisassemblyLayoutTests 与 DebugModuleTests,真实 lldb-dap 连续滚动验收仍待完成。 映像边界回归:上下页被适配器拒绝后按 64→32→16→8→4→2→1 条缩小,不跳过仍可读取的相邻指令;单条相邻读取失败且原边缘仍可读才停止该方向,不显示永久失败条。空白无效补位不得成为分页地址,另一方向仍可滚动;超时/停止/无效回复继续显示重试,验证迟到不覆盖新暂停点。独立真实 lldb-dap 21/C 程序已复现首地址的负偏移失败与零偏移成功;运行新增 DebugModuleTests 边界回归及原生错误条左对齐测试,完整工作台连续滚动仍 pending。" + "verification": "macOS:用只带符号表、没有调试信息的 C 程序(含 raise(SIGTRAP))经 Xcode lldb-dap 启动调试,暂停后栈帧显示函数名与地址而不是伪造的文件和第 1 行,反汇编标签自动打开并高亮当前指令;适配器不声明 supportsDisassembleRequest 时没有反汇编标签。反汇编中单步改为按指令前进(仅适配器声明 supportsSteppingGranularity 时),关闭反汇编标签后恢复按行单步;点击地址栏设置或移除指令断点(仅适配器声明 supportsInstructionBreakpoints 时),断点只属于当前会话、不写入持久化,新会话不重放。反汇编打开时,步过/步入的按钮提示变为按指令单步,头部显示“按指令单步”;适配器不支持时头部提示单步仍按源码行前进。寄存器作用域出现在寄存器标签,内存标签接受地址或保存着地址的表达式,从目标地址前 64 字节读起并分页,目标字节在十六进制和 ASCII 两侧高亮并滚入可见区,读取不到的字节显示为缺失而不是 00;右侧显示设置可切换进制、每格字节数、每行字节数、字节序与 ASCII 列;只读内存,不开放 writeMemory。连续单步时线程、调用栈、变量、反汇编、内存与 JSON 视图原地更新,不整面板清空重画,变化的内存字节被标出,继续执行或会话结束才清空。单步暂停到新作用域返回之前,旧变量树与 JSON 仍不可操作;同步取消和切换会话后的旧反汇编失败不重试、不覆盖保留的内存;传输失败清空保留数据。内存偏移表头使用请求引用而不是返回的页首地址。地址和引用对宿主是不透明字符串,宿主不做地址运算、不内置 CPU 寄存器表,也不按适配器 ID 分支。Windows 尚未实现,共享 Rust 内核已经提供能力快照与 debug.inspect 的 disassemble、readMemory,待 React 界面接入。 macOS 反汇编短指令从左侧地址列开始,长指令可横向滚动,当前指令定位只改变纵向位置;接近上下边缘时自动补充有界指令页,不再点击加载更早/更晚按钮。上方插入保留可见指令像素位置,下方追加不跳回当前指令;同页重复地址和无进展停止、失败需显式重试,关闭/继续执行后的迟到页面不得覆盖。运行 DebugDisassemblyLayoutTests 与 DebugModuleTests,真实 lldb-dap 连续滚动验收仍待完成。 映像边界回归:上下页被适配器拒绝后按 64→32→16→8→4→2→1 条缩小,不跳过仍可读取的相邻指令;单条相邻读取失败且原边缘仍可读才停止该方向,不显示永久失败条。空白无效补位不得成为分页地址,另一方向仍可滚动;超时/停止/无效回复继续显示重试,验证迟到不覆盖新暂停点。独立真实 lldb-dap 21/C 程序已复现首地址的负偏移失败与零偏移成功;运行新增 DebugModuleTests 边界回归及原生错误条左对齐测试,完整工作台连续滚动仍 pending。 RUN-14:分页恢复(窗口二分缩小、首窗口负偏移回退、边缘探测与耗尽判定、重复地址去重)已下沉为 Core 的 debug.planDisassemblyWindow / debug.reduceDisassemblyPage,macOS 删除本地实现并复用 shared/fixtures/debug/disassembly-paging-v1.json 同一组用例;视口、滚动锚点和渲染仍由宿主负责。Windows 宿主尚未开放 disassemble 与 readMemory 请求(debug_send_request 只翻译 threads/stackTrace/scopes/variables/evaluate 等),因此 Windows 仍为 missing;开放后直接调用这两个 Core 命令,不再复制算法。" } diff --git a/shared/platform-feature-matrix/features/debug-state.json b/shared/platform-feature-matrix/features/debug-state.json index 8b1073797e..1471df605c 100644 --- a/shared/platform-feature-matrix/features/debug-state.json +++ b/shared/platform-feature-matrix/features/debug-state.json @@ -18,7 +18,11 @@ "macos/Sources/Lithe/Models/AppModel/AppModel+Terminal.swift", "macos/Tests/LitheTests/TerminalPlacementFeatureModelTests.swift", "macos/Sources/Lithe/Views/Workbench/OutputTextView.swift", - "macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift" + "macos/Sources/LitheDebugModule/Runtime/DebugAdapterSessionManager.swift", + "rust/lithe-core/src/debug/variable_paging.rs", + "macos/Sources/LitheCoreContracts/Debug/DebugPagingContracts.swift", + "macos/Sources/Lithe/Core/Rust/RustRunDebugReducers.swift", + "shared/fixtures/debug/variable-page-plan-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -29,12 +33,17 @@ "shared/fixtures/debug", "windows/tauri/src/features/debugger/services/debug-session-actions.test.ts", "windows/tauri/src/features/debugger/services/monaco-debug-decorations.test.ts", - "windows/tauri/src/features/maven/services/maven-module-debug.test.ts" + "windows/tauri/src/features/maven/services/maven-module-debug.test.ts", + "rust/lithe-core/src/debug/variable_paging.rs", + "shared/fixtures/debug/variable-page-plan-v1.json", + "windows/tauri/src/features/debugger/api/variable-paging-api.ts", + "windows/tauri/src/features/debugger/services/debug-variable-paging.ts", + "windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts" ], "implementationStatus": "partial", "verificationStatus": "pending" }, "owner": "Debug", - "verification": "验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 Windows验证DAP请求信封、初始断点先于launch入队、暂停线程与线程列表、继续后清空暂停状态、快速单步/失败回滚,以及并发Stop等待同一资源清理。 macOS验证Debug空状态的初始、活跃、无输出终止、关闭最后会话、失败重试、重置,以及明暗主题与配置/帮助链接。 macOS检查活动会话的标题与内容标签焦点、单行工具栏、控制台/变量切换、现有求值与程序输入、窄宽度及停止操作。 macOS验证明暗图标/禁用灰度、Console ANSI常规与亮色、1pt缩放及边界、嵌套折叠/展开/复制/源码链接、内容标签切换保留状态和400k字符范围性能。 检查首次启动/重新启动默认Console、暂停切换,以及13pt为22pt行高、折叠占位和字号改变后行高一致。 检查主工具栏运行/调试重启的绿色底与白色图标、停止红底、明暗主题、hover/按下与默认箭头光标,并验证原有启动/停止动作。 检查各会话状态均不在内容标签旁显示状态文字/圆点,暂停位置仍可跳转。 验证分块SGR、非法/超长CSI过滤、Logback时间/线程/级别/类名颜色及字重、折叠占位手形和展开后左侧9pt标记点击、原文复制及滚动/缩放。 检查收起标记展开、展开标记收起的完整往返及深浅主题资源。 验证内联输入的Enter/空行、发送失败保留、历史正文保护、输出追加与IME/光标保持、会话结束和切换、明暗主题、源码链接/折叠点击、求值与添加监视及历史菜单。 检查Debug标题/工具栏/线程选择/求值/变量监视/Console诊断条的共享1pt横线,线程变量及Performance拖动分隔线的深浅主题、收起恢复、hover与按住不变亮,并确认动作组短竖线和对话框保留各自共享样式。 Console六个左侧动作在明暗主题加载真实16pt SVG;上下箭头深色资源不回退浅色,禁用态只应用IDEA灰度一次,按钮hover复用Git Log共享入口。 Console折叠标记在真实NSScrollView中检查深浅主题实际像素与收起/展开往返点击;追加输出与滚动后折叠栏重绘,不能只验证锚点存在。 Threads & Variables核对线程/作用域菜单、13pt普通标题与代码内容、原始16pt明暗New UI图标、变量展开/加载/层级、求值/添加监视/刷新及灰度、占位/IME和焦点;暂停后的真实变量修改与求值保持原动作。 StatusText深浅主题像素检查居中、三分之一高度与文字行高;检查运行/暂停/终止、变量为空而监视非空、异常非空、线程切换/栈帧选择、Enter求值、添加监视/历史菜单;默认1:2与拖动后宽度、背景开关均保持。 Console上一处/下一处对相同URL的不同文字范围逐一导航、回绕及后退,替换输出后重置定位。 添加监视:空白求值输入禁用内联 Add to Watch,非空表达式点击后进入 Watches;下方+打开共享样式弹窗,检查新增/编辑初值、自动聚焦、共享输入边框和焦点环、中文输入法、空白禁存、Enter保存和Esc取消。 Watch新增和编辑不使用系统sheet;确认弹层从Watches的+锚定,无弹出动画,8pt共享外框及1pt边框,Enter保存、Esc和外部点击关闭,再次编辑显示正确初值。 Console源码导航在未定位时,首次下一处定位第一条、首次上一处定位最后一条;重复URL逐个范围遍历并回绕,输出替换且URL/范围相同时也重置位置。 主动Stop最后运行会话后保留Console日志及内容标签,停留Threads & Variables仍可切回Console;清空无错误的历史后恢复空状态。Console源码导航按钮在无链接→有链接→清空输出时正确禁用/启用,排队通知被新渲染覆盖或原生视图卸载后不得回写旧状态。 双Debug会话中A终端不可输入或绑定缺失、B终端可输入时,A Console必须禁用并拒绝发送给B;选中B后正常发送。超过400000字符时,前台及后台会话按完整SGR边界截断并恢复有效颜色和粗体,覆盖跨chunk、标准/256/真彩、重置及Unicode,缓存仍有界。 验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 Windows验证DAP请求信封、初始断点先于launch入队、暂停线程与线程列表、继续后清空暂停状态、快速单步/失败回滚,以及并发Stop等待同一资源清理。macOS Debug 与 Run 复用 OutputTextView 的 IDEA 控制台入口,检查 13pt 字体、ANSI 明暗配色及源码链接高亮一致,不按日志级别给普通输出整行重新着色。 macOS:Run、Debug、Test 共用 OutputTextView/LitheConsoleStyle 默认字体、行高和日志等级色;纯文本及 Maven/Spring ANSI 日志的 INFO 标签均为绿色、WARN 黄色、ERROR 红色,正文及其他 ANSI 片段保留;检查明暗主题、复制、折叠与源码链接。 macOS多会话:startDebugging 子会话带父链接且不抢provider选择、子会话启动前重放已有断点、停止父会话同时停止子会话、功能模型清理子会话快照并回退选择;线程选择器会话分组缩进、状态圆点及单会话右键停止。Windows 暂无多会话实现。", + "verification": "验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 Windows验证DAP请求信封、初始断点先于launch入队、暂停线程与线程列表、继续后清空暂停状态、快速单步/失败回滚,以及并发Stop等待同一资源清理。 macOS验证Debug空状态的初始、活跃、无输出终止、关闭最后会话、失败重试、重置,以及明暗主题与配置/帮助链接。 macOS检查活动会话的标题与内容标签焦点、单行工具栏、控制台/变量切换、现有求值与程序输入、窄宽度及停止操作。 macOS验证明暗图标/禁用灰度、Console ANSI常规与亮色、1pt缩放及边界、嵌套折叠/展开/复制/源码链接、内容标签切换保留状态和400k字符范围性能。 检查首次启动/重新启动默认Console、暂停切换,以及13pt为22pt行高、折叠占位和字号改变后行高一致。 检查主工具栏运行/调试重启的绿色底与白色图标、停止红底、明暗主题、hover/按下与默认箭头光标,并验证原有启动/停止动作。 检查各会话状态均不在内容标签旁显示状态文字/圆点,暂停位置仍可跳转。 验证分块SGR、非法/超长CSI过滤、Logback时间/线程/级别/类名颜色及字重、折叠占位手形和展开后左侧9pt标记点击、原文复制及滚动/缩放。 检查收起标记展开、展开标记收起的完整往返及深浅主题资源。 验证内联输入的Enter/空行、发送失败保留、历史正文保护、输出追加与IME/光标保持、会话结束和切换、明暗主题、源码链接/折叠点击、求值与添加监视及历史菜单。 检查Debug标题/工具栏/线程选择/求值/变量监视/Console诊断条的共享1pt横线,线程变量及Performance拖动分隔线的深浅主题、收起恢复、hover与按住不变亮,并确认动作组短竖线和对话框保留各自共享样式。 Console六个左侧动作在明暗主题加载真实16pt SVG;上下箭头深色资源不回退浅色,禁用态只应用IDEA灰度一次,按钮hover复用Git Log共享入口。 Console折叠标记在真实NSScrollView中检查深浅主题实际像素与收起/展开往返点击;追加输出与滚动后折叠栏重绘,不能只验证锚点存在。 Threads & Variables核对线程/作用域菜单、13pt普通标题与代码内容、原始16pt明暗New UI图标、变量展开/加载/层级、求值/添加监视/刷新及灰度、占位/IME和焦点;暂停后的真实变量修改与求值保持原动作。 StatusText深浅主题像素检查居中、三分之一高度与文字行高;检查运行/暂停/终止、变量为空而监视非空、异常非空、线程切换/栈帧选择、Enter求值、添加监视/历史菜单;默认1:2与拖动后宽度、背景开关均保持。 Console上一处/下一处对相同URL的不同文字范围逐一导航、回绕及后退,替换输出后重置定位。 添加监视:空白求值输入禁用内联 Add to Watch,非空表达式点击后进入 Watches;下方+打开共享样式弹窗,检查新增/编辑初值、自动聚焦、共享输入边框和焦点环、中文输入法、空白禁存、Enter保存和Esc取消。 Watch新增和编辑不使用系统sheet;确认弹层从Watches的+锚定,无弹出动画,8pt共享外框及1pt边框,Enter保存、Esc和外部点击关闭,再次编辑显示正确初值。 Console源码导航在未定位时,首次下一处定位第一条、首次上一处定位最后一条;重复URL逐个范围遍历并回绕,输出替换且URL/范围相同时也重置位置。 主动Stop最后运行会话后保留Console日志及内容标签,停留Threads & Variables仍可切回Console;清空无错误的历史后恢复空状态。Console源码导航按钮在无链接→有链接→清空输出时正确禁用/启用,排队通知被新渲染覆盖或原生视图卸载后不得回写旧状态。 双Debug会话中A终端不可输入或绑定缺失、B终端可输入时,A Console必须禁用并拒绝发送给B;选中B后正常发送。超过400000字符时,前台及后台会话按完整SGR边界截断并恢复有效颜色和粗体,覆盖跨chunk、标准/256/真彩、重置及Unicode,缓存仍有界。 验证变量分页、异常信息、step filters、暂停/继续和 disconnect policy。 Windows验证DAP请求信封、初始断点先于launch入队、暂停线程与线程列表、继续后清空暂停状态、快速单步/失败回滚,以及并发Stop等待同一资源清理。macOS Debug 与 Run 复用 OutputTextView 的 IDEA 控制台入口,检查 13pt 字体、ANSI 明暗配色及源码链接高亮一致,不按日志级别给普通输出整行重新着色。 macOS:Run、Debug、Test 共用 OutputTextView/LitheConsoleStyle 默认字体、行高和日志等级色;纯文本及 Maven/Spring ANSI 日志的 INFO 标签均为绿色、WARN 黄色、ERROR 红色,正文及其他 ANSI 片段保留;检查明暗主题、复制、折叠与源码链接。 macOS多会话:startDebugging 子会话带父链接且不抢provider选择、子会话启动前重放已有断点、停止父会话同时停止子会话、功能模型清理子会话快照并回退选择;线程选择器会话分组缩进、状态圆点及单会话右键停止。Windows 暂无多会话实现。 RUN-06:变量分页游标改由 Core debug.planVariablePage / debug.reduceVariablePage 决定(命名与索引子项分段、重复页丢弃、耗尽判定),macOS 删除本地分页状态机,两端共用 shared/fixtures/debug/variable-page-plan-v1.json。Windows 现在保留 Scope 与变量的 namedVariables/indexedVariables,按需只请求非 expensive 的 Scope(expensive 作用域由用户点击加载),首页和后续页携带 filter/start/count,“加载更多”继续取下一页;Windows 前端用 bun test 的 debug-adapter-events.test.ts 验证,真实 Windows 调试会话仍 pending。RUN-07:Windows 栈帧保留 sourceReference、instructionPointerReference 与 isFiltered;带 sourceReference 的栈帧不再把 sourcePath 当磁盘文件打开或高亮,被步进策略过滤的帧在调用栈中淡化。Windows 暂未提供读取 adapter 托管源码内容(source 请求)的界面,反编译/反汇编源码内容的展示仍待后续。", "notes": "共享 Rust Core 调试内核(#1210)新增能力快照与扩展注册表、停止代数与过期引用丢弃(旧停止下发出的 variables/evaluate 回复报 StaleReference,不再覆盖新停止的数据)、父子会话树及 v2 投影事件;macOS 已接入 startDebugging 子会话:宿主按父会话 Core ID 创建并关联子会话,启动前重放已保存的源码/异常/函数/数据断点,停父即停子;界面保持单会话标签,会话切换并入线程选择器的“会话”分组。Windows 未同步,多会话保持 pending。 Windows 真实调试产品链路仍在 #466 跟进。 本次Windows改动保留Core/JDT的DAP及Java目标所有权,仅修复前端事件、请求和资源生命周期;完整界面及跨平台验收保持pending。 macOS空状态按Community可见内容语义判断并复用Git Log文字度量;无内容时隐藏会话控件和工具栏,失败启动保留诊断与重试。配置链接复用Run面板,无独立选择快捷键时不显示快捷键。空状态标题栏沿用Git Log/Terminal的41pt高度、专用分隔线及标准IDEA图标按钮。 Debug标题栏移除重复配置选择器,统一使用窗口顶部入口。活动会话复用共享工具窗口标签、关闭按钮和图标按钮,调试操作与内容标签并排,去掉固定蓝底及局部状态胶囊;保留已有动作,不新增Spring专属功能。变量值/类型采用共享Debugger主题颜色,控制台复用原输出渲染器。 Debug图标按PlatformIconMappings切换New UI资源并按主题GrayFilter禁用;Console显式启用独立配色、ANSI色板与局部字号操作,支持保留8帧的长堆栈和默认内部行嵌套折叠,复制还原原文。个人折叠设置及完整stdout/stderr通道语义尚未移植。 启动与重新启动调试默认打开Console,暂停仍切线程/变量;正文与折叠占位统一按Console默认1.2行距和字体度量取整计算行高,13pt JetBrains Mono基准为22pt,字号变化同步重算。 主工具栏运行中按钮与停止按钮复用30pt共享入口,按Community RunWidget/Islands token绘制绿色/红色底及白色16pt描边图标;保留既有动作与可见条件。 Debug内容标签旁不再显示会话状态圆点与状态文字,保留暂停位置跳转。 输出区悬浮复制按钮仅显示图标,保留复制操作、悬停提示与可访问名称。 跳到最新输出使用Community ScrollToTheEndToolbarAction的New UI scrollDown明暗图标,复用共享图标按钮hover,无常驻背景和文字。 调试输出清理器跨分块保留有界SGR颜色/字重码,继续过滤OSC、光标/屏幕控制和非法CSI;日志时间、级别、类名按程序ANSI输出及IDEA色板显示。折叠文字hover手形,展开标记改为官方9pt Fold SVG在正文左侧绘制,不占原文宽度;标记命中与绘制仅查询可见字符。 收起与展开的Console多行折叠均显示左侧官方Unfold/Fold标记,点击同一折叠栏位置可展开和再次收起。 Console移除底部固定求值与标准输入栏。求值复用变量区已有入口,Enter执行并切Console查看结果,+仍添加监视,历史通过求值图标共享右键菜单访问。标准输入只在当前活跃会话可接收时作为原生控制台可编辑尾部,使用现有Console字体、配色和行高,无新增背景、边框或工具栏;日志正文只读。草稿跨输出刷新与页签切换保留,发送失败保留,成功清空,会话切换清理;生产者更新不打断IME。 Debug内容横线复用LitheToolWindowHeaderDivider;纵向边界统一toolWindowBorder,线程变量拆分复用Git Log的LitheSplitPaneView,避免原生HSplitView的独立边框外观。 Console动作栏补齐up/down深色资源,禁用图标复用IDEA GrayFilter并移除二次透明度淡化。 修复NSTextView正文绘制遗留裁剪导致左侧9pt折叠图标不显示;恢复图形状态后绘制官方SVG,并在输出及滚动时刷新可见折叠栏。原生像素回归修复前失败、修复后通过。 Threads & Variables移到Community New UI图标,内联添加监视用Debugger.AddToWatch;使用共享Tree文字/缩进/hover、StatusText提示和原生输入占位入口,保留多行变量及监视内容。 Threads & Variables线程选择标题和表达式输入前不再显示前导图标;线程菜单项图标保留,表达式历史通过已有添加监视按钮的右键菜单访问,输入框原生菜单不受影响。 调用栈默认三分之一;同类空提示复用StatusText普通字体/灰色及水平居中、三分之一高度;右侧只留一条状态提示,移除计数和三角求值按钮,无监视时隐藏刷新入口,Enter/历史/添加监视及已有数据操作保留。 监视表达式弹窗复用LitheSearchTextField/litheSearchField及共享主次按钮,移除原生roundedBorder和局部11pt输入样式;内联添加监视在空输入时禁用,避免静默无响应。 Watch编辑外层迁移到共享litheDropdown/LitheContextMenuPresenter,去掉系统sheet、额外内容底色和固定高度。 修正未定位状态的Console前进初始索引,保留后退和回绕逻辑;原生回归同时覆盖两个方向与输出替换。" } diff --git a/shared/platform-feature-matrix/features/run-java-test.json b/shared/platform-feature-matrix/features/run-java-test.json index 8b40609f90..d7c6fbd2ac 100644 --- a/shared/platform-feature-matrix/features/run-java-test.json +++ b/shared/platform-feature-matrix/features/run-java-test.json @@ -31,7 +31,11 @@ "shared/contracts/plugins/v1/fixtures/build-providers/build-test-durations.json", "shared/fixtures/run-configuration/provider-prepared-java.json", "shared/fixtures/build-provider/test-results-v1.json", - "shared/contracts/plugins/v1/fixtures/build-providers/build-provider-preparation.plugin.json" + "shared/contracts/plugins/v1/fixtures/build-providers/build-provider-preparation.plugin.json", + "rust/lithe-core/src/build/test_outcome.rs", + "macos/Sources/LitheCoreContracts/Execution/TestRunOutcomeContracts.swift", + "macos/Tests/LitheExecutionModuleTests/LinkedCoreTestRunOutcome.swift", + "shared/fixtures/build/test-run-outcome-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -41,12 +45,15 @@ "windows/tauri/src/features/run", "shared/fixtures/debug", "windows/tauri/src/features/run/components/run-pane.tsx", - "windows/tauri/src/features/run/components/run-output-text.tsx" + "windows/tauri/src/features/run/components/run-output-text.tsx", + "rust/lithe-core/src/build/test_outcome.rs", + "windows/tauri/src/features/run/api/test-run-outcome-api.ts", + "shared/fixtures/build/test-run-outcome-v1.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Run", - "verification": "阶段 D 协议 9 增量通过 350 项 macOS、1368 项 Core/集成计时、当前 SDK E2E 及 Maven 3.9.16/JDK 26 组合;完整发行 UI/JDT、正式 SDK/插件锁定和 Windows 实机仍 pending。运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 macOS Tests 顶部核对 41pt 无图标/计数标题、统一底线、运行名称标签的活动/非活动状态;运行中和完成后关闭标签均清理本次运行,保留发现列表并可重新运行。 核对第二行刷新、重跑、停止、清空的 IDEA 原始 16pt 明暗 SVG 与 22pt 按钮悬停范围。 Tests 详情无重复标题/文字 Run 按钮、工具栏右侧无运行状态;第二行首个 Run/Rerun 图标按选中提供方与精确作用域切换,运行中禁用,Stop 常驻;切换测试后运行当前选择,Java 单项 Debug 入口保留。 Tests 布局核对左侧报告状态树(通过/失败/跳过和组折叠)、顶部单行结果摘要、IDEA 控制台及右侧导航/换行/滚动/打印/清空动作;右侧 Details 复用共享收缩条与分隔容器,展开保留作用域、命令和失败定位。控制台左右工具栏均验证明暗主题、背景与原生文本身份。 第二行核对刷新紧挨垃圾桶左侧,两者继续执行发现刷新和输出清空。 Test 的 Performance 展开采集本次进程树 CPU 与 Resident Memory(含构建启动器和测试子进程),复用 Debug 图表及共享折叠条;收起/隐藏停止采集,重跑不混旧数据,结束保留历史,Details 继续显示原有作用域、命令与失败定位。 Stop 等图标禁用时使用 IDEA 灰度,只有可用停止动作保留红色;Performance 收起再展开后时间轴继续保持进程年龄。 Test 右侧 Performance/Details 共用一个可收缩区域,顶部两个标签切换;核对11pt常规标题和Islands文字色。Java选择本次启动器后代中的JUnit/Surefire/Gradle测试JVM,实时CPU/堆曲线,多个worker切换。开始/停止JFR录制、Show Results查看JDK热点方法和分配报告,结束/重跑/关闭正确取消及清理。非Java测试继续用明确标注的CPU/RSS;缺失worker或工具明确提示,不采样Maven/Gradle启动器。 macOS:Run、Debug、Test 共用 OutputTextView/LitheConsoleStyle 默认字体、行高和日志等级色;纯文本及 Maven/Spring ANSI 日志的 INFO 标签均为绿色、WARN 黄色、ERROR 红色,正文及其他 ANSI 片段保留;检查明暗主题、复制、折叠与源码链接。 Test Performance 录制和 Show Results 核对明暗主题、原始录制 SVG、禁用灰度不叠加透明度;Show Results 复用 Commit 的共享 LitheActionButtonStyle,核对28pt可见高度、3pt外留白、13pt常规字、透明/白色背景和独立禁用文字/边框,录制结束后可打开结果。 Show Results 结果窗口使用共享 LitheCenteredPopup 原生标题栏与对话框背景/底部关闭动作,验证 Esc、关闭按钮、系统关闭、重复打开、父窗口关闭及明暗主题,内容保持共享 OutputTextView 的复制和横纵滚动。 Test 结果摘要下方移除普通横线,核对3pt半圆端状态条:失败/错误红色优先于通过绿色;无结果、运行准备和全部跳过不画,重跑清空旧状态。深浅主题使用 ProgressBar 专用色,保持摘要文字和控制台工具栏不变。 调整面板宽度时状态条右端始终停在控制台右侧工具栏左边,不覆盖工具栏及其分隔线。 非Maven无结构化报告时核对成功、失败、取消和超时摘要与状态条;Gradle内置测试通过--no-daemon保持worker属于本次进程树,真实集成先启动兼容常驻daemon,再确认发现与采样的worker身份及独立清理。 Test左侧结果树:根/方法名13pt常规文字、共享Tree文字色;原始16pt明暗SVG覆盖未运行/通过/断言失败/执行错误/跳过和展开箭头,errors保留Error图标而非Failed;核对24pt行高、19pt缩进、12pt两侧留白、折叠、选中/失焦/hover及长名称省略,明暗主题不重染SVG。 结果摘要核对IDEA连续文案(失败、通过、忽略)、中英文单复数、13ptRegular、Label专用失败/警告色、灰色总数和16pt状态图标,覆盖通过但含忽略和全部忽略,不造耗时。结果树类/目录行双击名称展开或折叠,单击选择、箭头点击不变,叶子双击不折叠、不运行。 Test发现列表在首次运行、刷新和重跑等待结果时按工作区/文件/类/方法区分16pt图标,文件复用共享分类;明暗SVG不缺失,结果生成后状态图标优先,保留共享字体、行高、选中与双击展开。 Test未运行且无结构化结果时不显示摘要空态行,仅保留共享控制台的Run a test to see its output提示;运行中及无报告的成功、失败、取消、超时仍显示摘要。 Maven编译失败产生零计数报告、部分测试通过但整体非零退出时,保留Failed/Timed Out文字、错误图标和红色状态条;Gradle同时启用fork编译和测试时,Performance仅发现带Gradle Test Executor身份的worker,不采样通用Gradle Worker Daemon。 回归验证单测→workspace/file→顶栏Run/Debug仍保留新scope;共享testcase-durations-v1.json贯穿Rust报告解析、聚合、通用build.decodeTestResults与Swift呈现,覆盖不同方法耗时、重复调用、部分缺失、非法/负值/超大值及零值;实际Windows UI尚未验收。 回归普通配置仍运行时选择测试,顶栏Run只运行当前测试;单方法JDT启动标签为logsIn()时暂停/继续仍关联所选scope。合成结果树的渲染、选择与默认选中共用节点,报告类/方法可重跑;无语义源码关联的报告节点明确禁用Debug,不猜测源码路径。 macOS边界回归:A处于running/paused时切换测试B,实际注册Debug动作必须准备B;保持同一目标时才重启。混合Java/Node/Python项目只在当前运行的provider组展示报告结果、状态和耗时;关闭或清空带真实方法结果的运行后恢复工作区、文件和未执行测试入口,同时保留编辑器标记历史,并可重新选择文件/工作区运行。 Review 修正:macOS 结果服务、Tests 呈现及 Java 行标记直接消费 BuildTestResults;保留上游摘要与调用次数,耗时只读规范报告,名称不解析括号或参数后缀。非 Maven fixture 覆盖来源定位、独立重跑引用、清空引用及多报告汇总;旧 Windows className/method 字段与通用 suite/case 的行标记 fixture 必须一致。 准备任务使用 buildSystems 4 的 preparation.purpose/sourceRoles,由插件声明内部任务 ID,宿主按当前模型匹配唯一任务;覆盖缺失用途、缺失角色、角色冲突、未知源码根、临时 Profile、取消及工作区变化。真实 Maven 自定义过滤资源经生产 macOS 会话与 Core 执行,插件包清单不变;Windows 产品准入仍关闭。 通用报告按语言提供者隔离编辑器历史,混合结果保留精确源码节点与全部无源码报告项,不因部分 JDT 匹配而丢弃其余用例;源码执行标识原样保留。", + "verification": "阶段 D 协议 9 增量通过 350 项 macOS、1368 项 Core/集成计时、当前 SDK E2E 及 Maven 3.9.16/JDK 26 组合;完整发行 UI/JDT、正式 SDK/插件锁定和 Windows 实机仍 pending。运行 main、单测试和测试类,验证参数、输出、失败状态和终端策略。 Windows Run 运行输出使用 Geist Mono 显示 ====、==>、=>、!=、>=,确认禁用连字后逐字符显示且复制文本与原始输出一致;检查有输出与空态、ANSI 颜色、自动换行切换及横向滚动。 macOS Tests 顶部核对 41pt 无图标/计数标题、统一底线、运行名称标签的活动/非活动状态;运行中和完成后关闭标签均清理本次运行,保留发现列表并可重新运行。 核对第二行刷新、重跑、停止、清空的 IDEA 原始 16pt 明暗 SVG 与 22pt 按钮悬停范围。 Tests 详情无重复标题/文字 Run 按钮、工具栏右侧无运行状态;第二行首个 Run/Rerun 图标按选中提供方与精确作用域切换,运行中禁用,Stop 常驻;切换测试后运行当前选择,Java 单项 Debug 入口保留。 Tests 布局核对左侧报告状态树(通过/失败/跳过和组折叠)、顶部单行结果摘要、IDEA 控制台及右侧导航/换行/滚动/打印/清空动作;右侧 Details 复用共享收缩条与分隔容器,展开保留作用域、命令和失败定位。控制台左右工具栏均验证明暗主题、背景与原生文本身份。 第二行核对刷新紧挨垃圾桶左侧,两者继续执行发现刷新和输出清空。 Test 的 Performance 展开采集本次进程树 CPU 与 Resident Memory(含构建启动器和测试子进程),复用 Debug 图表及共享折叠条;收起/隐藏停止采集,重跑不混旧数据,结束保留历史,Details 继续显示原有作用域、命令与失败定位。 Stop 等图标禁用时使用 IDEA 灰度,只有可用停止动作保留红色;Performance 收起再展开后时间轴继续保持进程年龄。 Test 右侧 Performance/Details 共用一个可收缩区域,顶部两个标签切换;核对11pt常规标题和Islands文字色。Java选择本次启动器后代中的JUnit/Surefire/Gradle测试JVM,实时CPU/堆曲线,多个worker切换。开始/停止JFR录制、Show Results查看JDK热点方法和分配报告,结束/重跑/关闭正确取消及清理。非Java测试继续用明确标注的CPU/RSS;缺失worker或工具明确提示,不采样Maven/Gradle启动器。 macOS:Run、Debug、Test 共用 OutputTextView/LitheConsoleStyle 默认字体、行高和日志等级色;纯文本及 Maven/Spring ANSI 日志的 INFO 标签均为绿色、WARN 黄色、ERROR 红色,正文及其他 ANSI 片段保留;检查明暗主题、复制、折叠与源码链接。 Test Performance 录制和 Show Results 核对明暗主题、原始录制 SVG、禁用灰度不叠加透明度;Show Results 复用 Commit 的共享 LitheActionButtonStyle,核对28pt可见高度、3pt外留白、13pt常规字、透明/白色背景和独立禁用文字/边框,录制结束后可打开结果。 Show Results 结果窗口使用共享 LitheCenteredPopup 原生标题栏与对话框背景/底部关闭动作,验证 Esc、关闭按钮、系统关闭、重复打开、父窗口关闭及明暗主题,内容保持共享 OutputTextView 的复制和横纵滚动。 Test 结果摘要下方移除普通横线,核对3pt半圆端状态条:失败/错误红色优先于通过绿色;无结果、运行准备和全部跳过不画,重跑清空旧状态。深浅主题使用 ProgressBar 专用色,保持摘要文字和控制台工具栏不变。 调整面板宽度时状态条右端始终停在控制台右侧工具栏左边,不覆盖工具栏及其分隔线。 非Maven无结构化报告时核对成功、失败、取消和超时摘要与状态条;Gradle内置测试通过--no-daemon保持worker属于本次进程树,真实集成先启动兼容常驻daemon,再确认发现与采样的worker身份及独立清理。 Test左侧结果树:根/方法名13pt常规文字、共享Tree文字色;原始16pt明暗SVG覆盖未运行/通过/断言失败/执行错误/跳过和展开箭头,errors保留Error图标而非Failed;核对24pt行高、19pt缩进、12pt两侧留白、折叠、选中/失焦/hover及长名称省略,明暗主题不重染SVG。 结果摘要核对IDEA连续文案(失败、通过、忽略)、中英文单复数、13ptRegular、Label专用失败/警告色、灰色总数和16pt状态图标,覆盖通过但含忽略和全部忽略,不造耗时。结果树类/目录行双击名称展开或折叠,单击选择、箭头点击不变,叶子双击不折叠、不运行。 Test发现列表在首次运行、刷新和重跑等待结果时按工作区/文件/类/方法区分16pt图标,文件复用共享分类;明暗SVG不缺失,结果生成后状态图标优先,保留共享字体、行高、选中与双击展开。 Test未运行且无结构化结果时不显示摘要空态行,仅保留共享控制台的Run a test to see its output提示;运行中及无报告的成功、失败、取消、超时仍显示摘要。 Maven编译失败产生零计数报告、部分测试通过但整体非零退出时,保留Failed/Timed Out文字、错误图标和红色状态条;Gradle同时启用fork编译和测试时,Performance仅发现带Gradle Test Executor身份的worker,不采样通用Gradle Worker Daemon。 回归验证单测→workspace/file→顶栏Run/Debug仍保留新scope;共享testcase-durations-v1.json贯穿Rust报告解析、聚合、通用build.decodeTestResults与Swift呈现,覆盖不同方法耗时、重复调用、部分缺失、非法/负值/超大值及零值;实际Windows UI尚未验收。 回归普通配置仍运行时选择测试,顶栏Run只运行当前测试;单方法JDT启动标签为logsIn()时暂停/继续仍关联所选scope。合成结果树的渲染、选择与默认选中共用节点,报告类/方法可重跑;无语义源码关联的报告节点明确禁用Debug,不猜测源码路径。 macOS边界回归:A处于running/paused时切换测试B,实际注册Debug动作必须准备B;保持同一目标时才重启。混合Java/Node/Python项目只在当前运行的provider组展示报告结果、状态和耗时;关闭或清空带真实方法结果的运行后恢复工作区、文件和未执行测试入口,同时保留编辑器标记历史,并可重新选择文件/工作区运行。 Review 修正:macOS 结果服务、Tests 呈现及 Java 行标记直接消费 BuildTestResults;保留上游摘要与调用次数,耗时只读规范报告,名称不解析括号或参数后缀。非 Maven fixture 覆盖来源定位、独立重跑引用、清空引用及多报告汇总;旧 Windows className/method 字段与通用 suite/case 的行标记 fixture 必须一致。 准备任务使用 buildSystems 4 的 preparation.purpose/sourceRoles,由插件声明内部任务 ID,宿主按当前模型匹配唯一任务;覆盖缺失用途、缺失角色、角色冲突、未知源码根、临时 Profile、取消及工作区变化。真实 Maven 自定义过滤资源经生产 macOS 会话与 Core 执行,插件包清单不变;Windows 产品准入仍关闭。 通用报告按语言提供者隔离编辑器历史,混合结果保留精确源码节点与全部无源码报告项,不因部分 JDT 匹配而丢弃其余用例;源码执行标识原样保留。 RUN-03:测试结束的最终判定由 Core build.resolveTestRunOutcome 归约(超时/取消不声明测试结果,已报告的失败优先于退出码,非零退出优先于空结果,退出码 0 但没有执行任何选中测试判为失败:显式套件/用例为“没有测试匹配所选范围”,项目/工作区为“没有找到任何测试”)。macOS LanguageTestService 与 Windows maven.store 均改为调用该命令,不再各自按退出码判定,两端共用 shared/fixtures/build/test-run-outcome-v1.json;Core 不含 Maven/JUnit 语义。", "notes": "Java 启动资源准备新增公开 Provider 任务约定,等待保存、当前模型和成功状态后才交给 JDT;旧 Core 资源规划仅为未携带当前回执的 Windows 调用保留。单次覆盖不写 Build 默认值,失败/取消/ABA/工作区切换必须拒绝启动;本机实际资源过滤和原生取消回归通过;整合 preview 后 344 项计时通过,后续最低运行时新增 CI、完整 UI/JDT 和 Windows 实机仍待验证。 协议 9 测试链路已删除 macOS Maven CLI/报告 adapter,JDT 标识原样传给后端;规范报告按项目隔离并声明覆盖范围,整类重跑清除旧方法、单方法保留兄弟项,参数化调用的完整安全耗时相加。350 项原生回归和真实 Maven Provider 通过;最低运行时新增 CI、完整 UI/JDT、正式发行和 Windows 保持 pending。 阶段 D:macOS Maven 测试已走协议 9,JDT 原样给出语义标识,Build Provider 按当前工程/选项执行并返回规范报告。旧宿主 Maven 测试命令、报告扫描和 RustJavaMavenOperations 的 Maven 方法已删除。报告重跑保留后端和工程身份;同名模块结果/耗时隔离,PID 来自宿主受管 Provider 会话。保存、重跑、取消、过期输出、模型/配置 ABA 与测试 Debug 准备均保留身份。 顶栏保留并重跑完整测试scope,工作区/文件/类方法切换不会回退旧单测。方法耗时由Core读取Surefire XML的time并通过可选durationMillis传递,参数化调用累加,任一缺失/非法/溢出则隐藏;控制台整次摘要不作为方法耗时。通用Maven构建结果保留同一字段,Windows契约允许可选值。 顶栏测试动作优先于底层普通配置重启,普通配置运行样式不会串到测试目标。合成结果节点进入统一选择集合;保留JDT发现节点的源码关联,无源码报告节点禁用Debug。 当前报告与provider绑定,Tests列表不使用编辑器历史testOutcomes回填;顶栏Debug先校验当前测试目标再决定重启。" } diff --git a/windows/tauri/src/features/debugger/api/variable-paging-api.ts b/windows/tauri/src/features/debugger/api/variable-paging-api.ts new file mode 100644 index 0000000000..330ac2a896 --- /dev/null +++ b/windows/tauri/src/features/debugger/api/variable-paging-api.ts @@ -0,0 +1,53 @@ +import { invoke } from "@/platform/tauri-core"; + +/** Which kind of children one `variables` request asks for. */ +export type VariablePageFilter = "named" | "indexed"; + +/** The exact `variables` arguments Core wants sent next. */ +export interface VariablePageRequest { + filter?: VariablePageFilter; + start: number; + count: number; + remainingCount?: number; +} + +/** + * Core-owned paging cursor of one variable container. The host stores it and + * echoes it back; only `next` is meant to be read. + */ +export interface VariablePageCursor { + variablesReference: number; + pageSize: number; + segments: unknown[]; + loadedPages: string[]; + next?: VariablePageRequest; +} + +/** Identity fields Core fingerprints to detect a repeated page. */ +export interface VariablePageItem { + name: string; + value: string; + type?: string; + evaluateName?: string; + variablesReference: number; +} + +/** Plans the first bounded page of a container from its reported child counts (`debug.planVariablePage`). */ +export function planVariablePage(request: { + variablesReference: number; + namedVariables?: number; + indexedVariables?: number; + pageSize: number; +}): Promise { + return invoke("debug.planVariablePage", request); +} + +/** Merges one returned page into its cursor (`debug.reduceVariablePage`). */ +export function reduceVariablePage(request: { + state: VariablePageCursor; + requested: VariablePageRequest; + page: VariablePageItem[]; + newItemCount: number; +}): Promise<{ accept: boolean; state: VariablePageCursor }> { + return invoke<{ accept: boolean; state: VariablePageCursor }>("debug.reduceVariablePage", request); +} diff --git a/windows/tauri/src/features/debugger/components/debugger-panels.tsx b/windows/tauri/src/features/debugger/components/debugger-panels.tsx index 2f325366c0..2d3ab76a18 100644 --- a/windows/tauri/src/features/debugger/components/debugger-panels.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-panels.tsx @@ -151,6 +151,7 @@ export function DebugStackFrames({ className={cn( "font-sans flex w-full items-start gap-2 px-3 py-1.5 text-left ui-text-sm hover:bg-accent/70", isSelected && "bg-selected/70", + frame.isFiltered && "opacity-60", )} onClick={() => void onSelect(frame.id, frame.sourcePath, frame.line)} > diff --git a/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx b/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx index c5f365f277..17087fe813 100644 --- a/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-variables-panel.tsx @@ -3,9 +3,12 @@ import { useEffect, useState } from "react"; import { Spinner } from "@/ui/spinner"; import { useTranslation } from "@/i18n/locale-provider"; import { cn } from "@/utils/cn"; -import { createDebugOperationId, sendDebugAdapterRequest } from "../services/debug-adapter-service"; -import { useDebuggerStore } from "../stores/debugger.store"; +import { + requestFirstVariablePage, + requestNextVariablePage, +} from "../services/debug-variable-paging"; import type { DebugRequestContext, DebugScope, DebugVariable } from "../types/debugger.types"; +import type { VariablePageCursor } from "../api/variable-paging-api"; import { DebugEmptyState } from "./debugger-panels"; interface DebugVariablesPanelProps { @@ -13,6 +16,7 @@ interface DebugVariablesPanelProps { selectedFrameId: number | null; scopes: DebugScope[]; variablesByReference: Record; + variablePageCursors: Record; pendingRequests: Record; } @@ -21,9 +25,9 @@ export function DebugVariablesPanel({ selectedFrameId, scopes, variablesByReference, + variablePageCursors, pendingRequests, }: DebugVariablesPanelProps) { - const debuggerActions = useDebuggerStore.use.actions(); const [expandedVariableReferences, setExpandedVariableReferences] = useState>( () => new Set(), ); @@ -39,7 +43,8 @@ export function DebugVariablesPanel({ request.command === "variables" && request.variablesReference === variablesReference, ); - const toggleVariableExpansion = async (variablesReference: number) => { + const toggleVariableExpansion = async (container: DebugVariable) => { + const { variablesReference } = container; if (!activeSessionId || variablesReference <= 0) return; const shouldLoadChildren = @@ -56,11 +61,7 @@ export function DebugVariablesPanel({ if (!shouldLoadChildren) return; try { - const operationId = createDebugOperationId(); - debuggerActions.registerAdapterRequest(operationId, { command: "variables", variablesReference }); - await sendDebugAdapterRequest(activeSessionId, "variables", { - variablesReference, - }, operationId); + await requestFirstVariablePage(activeSessionId, container); } catch { setExpandedVariableReferences((current) => { const next = new Set(current); @@ -70,6 +71,19 @@ export function DebugVariablesPanel({ } }; + const renderLoadMore = (variablesReference: number, depth: number) => + activeSessionId && variablePageCursors[variablesReference]?.next ? ( + + ) : null; + const renderVariables = (variables: DebugVariable[], parentReference: number, depth = 0) => variables.map((variable, index) => { const canExpand = variable.variablesReference > 0; @@ -90,7 +104,7 @@ export function DebugVariablesPanel({ canExpand && "hover:text-foreground", )} disabled={!canExpand} - onClick={() => void toggleVariableExpansion(variable.variablesReference)} + onClick={() => void toggleVariableExpansion(variable)} > 0 ? renderVariables(childVariables, variable.variablesReference, depth + 1) : null} + {isExpanded ? renderLoadMore(variable.variablesReference, depth + 1) : null} ); }); @@ -131,13 +146,23 @@ export function DebugVariablesPanel({ {scope.name} {variables.length} - {variables.length === 0 ? ( + {scope.expensive && variables.length === 0 && activeSessionId ? ( + + ) : variables.length === 0 ? (
{t("debugger.empty")}
) : ( renderVariables(variables, scope.variablesReference) )} + {renderLoadMore(scope.variablesReference, 0)} ); })} diff --git a/windows/tauri/src/features/debugger/components/debugger-view.tsx b/windows/tauri/src/features/debugger/components/debugger-view.tsx index 50627efdf4..9638e658a1 100644 --- a/windows/tauri/src/features/debugger/components/debugger-view.tsx +++ b/windows/tauri/src/features/debugger/components/debugger-view.tsx @@ -115,6 +115,7 @@ export default function DebuggerView() { const selectedFrameId = useDebuggerStore.use.selectedFrameId(); const scopes = useDebuggerStore.use.scopes(); const variablesByReference = useDebuggerStore.use.variablesByReference(); + const variablePageCursors = useDebuggerStore.use.variablePageCursors(); const adapterOutput = useDebuggerStore.use.adapterOutput(); const endedSessions = useDebuggerStore.use.endedSessions(); const pendingRequests = useDebuggerStore.use.pendingRequests(); @@ -631,6 +632,7 @@ export default function DebuggerView() { selectedFrameId={selectedFrameId} scopes={scopes} variablesByReference={variablesByReference} + variablePageCursors={variablePageCursors} pendingRequests={pendingRequests} /> diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts index a53727956b..dec49a1c93 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.test.ts @@ -38,6 +38,26 @@ mock.module("@/features/debugger/services/debug-adapter-service", () => ({ }, })); +// Stands in for Core's paging reducers; their own cases live in the shared fixture. +const planVariablePage = mock(async (request: { variablesReference: number; pageSize: number }) => ({ + variablesReference: request.variablesReference, + pageSize: request.pageSize, + segments: [], + loadedPages: [], + next: { filter: "indexed" as const, start: 0, count: request.pageSize, remainingCount: 250 }, +})); +const reduceVariablePage = mock( + async (request: { state: { variablesReference: number; pageSize: number; segments: unknown[]; loadedPages: string[] } }) => ({ + accept: true, + state: { ...request.state, next: undefined }, + }), +); +mock.module("@/features/debugger/api/variable-paging-api", () => ({ + planVariablePage, + reduceVariablePage, +})); +mock.module("../api/variable-paging-api", () => ({ planVariablePage, reduceVariablePage })); + const { initializeDebuggerEventBridge, selectDebugThread } = await import("./debug-adapter-events"); const { useDebuggerStore } = await import("../stores/debugger.store"); @@ -395,3 +415,91 @@ test("normalized running events clear stale paused inspection and ignore its lat expect(state.variablesByReference).toEqual({}); expect(state.threads).toEqual([{ id: 7, name: "main" }]); }); + +test("scopes load only non-expensive scopes, paging by the reported counts", async () => { + await initializeDebuggerEventBridge(); + startSession("session-1"); + useDebuggerStore.getState().actions.setSessionStatus("paused"); + useDebuggerStore.getState().actions.selectStackFrame(5); + useDebuggerStore.getState().actions.registerAdapterRequest("scopes-op", { + command: "scopes", + frameId: 5, + }); + planVariablePage.mockClear(); + reduceVariablePage.mockClear(); + sendDebugAdapterRequest.mockClear(); + + await emitMessage("session-1", { + type: "operationCompleted", + operationId: "scopes-op", + result: { + kind: "scopes", + scopes: [ + { name: "Locals", variablesReference: 10, indexedVariables: 250 }, + { name: "Registers", variablesReference: 11, expensive: true }, + ], + }, + }); + + expect(planVariablePage).toHaveBeenCalledTimes(1); + expect(planVariablePage.mock.calls[0]?.[0]).toMatchObject({ + variablesReference: 10, + indexedVariables: 250, + }); + expect(sendDebugAdapterRequest).toHaveBeenCalledTimes(1); + const request = sendDebugAdapterRequest.mock.calls[0]; + expect(request?.[2]).toEqual({ + variablesReference: 10, + filter: "indexed", + start: 0, + count: 100, + }); + + await emitMessage("session-1", { + type: "operationCompleted", + operationId: request?.[3], + result: { kind: "variables", variables: [{ name: "[0]", value: "1", variablesReference: 0 }] }, + }); + expect(reduceVariablePage.mock.calls[0]?.[0]).toMatchObject({ newItemCount: 1 }); + expect(useDebuggerStore.getState().variablesByReference[10]).toEqual([ + { name: "[0]", value: "1", type: undefined, evaluateName: undefined, variablesReference: 0, namedVariables: undefined, indexedVariables: undefined }, + ]); +}); + +test("a frame served by sourceReference is never treated as a disk file", async () => { + await initializeDebuggerEventBridge(); + startSession("session-1"); + useDebuggerStore.getState().actions.setSessionStatus("paused"); + useDebuggerStore.getState().actions.setStoppedState({ reason: "pause", threadId: 1 }); + useDebuggerStore.getState().actions.registerAdapterRequest("stack-op", { + command: "stackTrace", + threadId: 1, + }); + + await emitMessage("session-1", { + type: "operationCompleted", + operationId: "stack-op", + result: { + kind: "stackTrace", + stackFrames: [ + { + id: 1, + name: "decompiled", + sourcePath: "/Foo.class", + sourceReference: 7, + instructionPointerReference: "0x10", + isFiltered: true, + line: 3, + column: 1, + }, + ], + }, + }); + + expect(useDebuggerStore.getState().stackFrames[0]).toMatchObject({ + sourcePath: undefined, + sourceReference: 7, + instructionPointerReference: "0x10", + isFiltered: true, + }); +}); diff --git a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts index cee850bddc..dda79ce76e 100644 --- a/windows/tauri/src/features/debugger/services/debug-adapter-events.ts +++ b/windows/tauri/src/features/debugger/services/debug-adapter-events.ts @@ -14,6 +14,7 @@ import type { DebugVariable, } from "@/features/debugger/types/debugger.types"; import { releaseDebugSessionResources } from "./debug-session-resources"; +import { acceptVariablePage, requestFirstVariablePage } from "./debug-variable-paging"; let unsubscribeDebuggerEvents: (() => void) | null = null; let pendingSubscription: Promise | null = null; @@ -203,16 +204,21 @@ async function handleOperationCompleted(sessionId: string, event: Record scope.variablesReference > 0) - .map((scope) => requestVariables(sessionId, scope.variablesReference)), + .filter((scope) => scope.variablesReference > 0 && !scope.expensive) + .map((scope) => requestFirstVariablePage(sessionId, scope)), ); return; } case "variables": { if (context?.command === "variables") { - store.actions.setVariables(context.variablesReference, toVariables(result?.variables)); + await acceptVariablePage( + context.variablesReference, + context.requested, + toVariables(result?.variables), + ); } return; } @@ -269,12 +275,6 @@ async function requestScopes(sessionId: string, frameId: number) { await sendDebugAdapterRequest(sessionId, "scopes", { frameId }, operationId); } -async function requestVariables(sessionId: string, variablesReference: number) { - const operationId = createDebugOperationId(); - registerContext(operationId, { command: "variables", variablesReference }); - await sendDebugAdapterRequest(sessionId, "variables", { variablesReference }, operationId); -} - function registerContext(operationId: string, context: DebugRequestContext) { if (!operationId) return; useDebuggerStore.getState().actions.registerAdapterRequest(operationId, context); @@ -309,7 +309,19 @@ function toStackFrames(value: unknown): DebugStackFrame[] { return { id: frame.id, name: typeof frame.name === "string" ? frame.name : `Frame ${frame.id}`, - sourcePath: typeof frame.sourcePath === "string" ? frame.sourcePath : undefined, + // An adapter-served source (decompiled class, disassembly) names no file on + // disk even when its path looks absolute, so it must never reach the editor. + sourcePath: + typeof frame.sourcePath === "string" && typeof frame.sourceReference !== "number" + ? frame.sourcePath + : undefined, + sourceReference: + typeof frame.sourceReference === "number" ? frame.sourceReference : undefined, + instructionPointerReference: + typeof frame.instructionPointerReference === "string" + ? frame.instructionPointerReference + : undefined, + isFiltered: frame.isFiltered === true ? true : undefined, line: typeof frame.line === "number" ? frame.line : 0, column: typeof frame.column === "number" ? frame.column : 0, }; @@ -328,6 +340,9 @@ function toScopes(value: unknown): DebugScope[] { name: typeof scope.name === "string" ? scope.name : "Scope", variablesReference: scope.variablesReference, expensive: typeof scope.expensive === "boolean" ? scope.expensive : undefined, + namedVariables: typeof scope.namedVariables === "number" ? scope.namedVariables : undefined, + indexedVariables: + typeof scope.indexedVariables === "number" ? scope.indexedVariables : undefined, }; }) .filter((scope): scope is DebugScope => Boolean(scope)); @@ -344,8 +359,13 @@ function toVariables(value: unknown): DebugVariable[] { name: variable.name, value: typeof variable.value === "string" ? variable.value : "", type: typeof variable.type === "string" ? variable.type : undefined, + evaluateName: typeof variable.evaluateName === "string" ? variable.evaluateName : undefined, variablesReference: typeof variable.variablesReference === "number" ? variable.variablesReference : 0, + namedVariables: + typeof variable.namedVariables === "number" ? variable.namedVariables : undefined, + indexedVariables: + typeof variable.indexedVariables === "number" ? variable.indexedVariables : undefined, }; }) .filter((variable): variable is DebugVariable => Boolean(variable)); diff --git a/windows/tauri/src/features/debugger/services/debug-variable-paging.ts b/windows/tauri/src/features/debugger/services/debug-variable-paging.ts new file mode 100644 index 0000000000..d639cbcc68 --- /dev/null +++ b/windows/tauri/src/features/debugger/services/debug-variable-paging.ts @@ -0,0 +1,102 @@ +import { + planVariablePage, + reduceVariablePage, + type VariablePageCursor, + type VariablePageRequest, +} from "../api/variable-paging-api"; +import { useDebuggerStore } from "../stores/debugger.store"; +import type { DebugVariable } from "../types/debugger.types"; +import { createDebugOperationId, sendDebugAdapterRequest } from "./debug-adapter-service"; + +/** Children fetched per `variables` request; Core decides when a container is exhausted. */ +export const VARIABLE_PAGE_SIZE = 100; + +/** Child identity used to drop repeated children; mirrors the fields Core fingerprints. */ +function variableKey(variable: DebugVariable): string { + return [variable.name, variable.value, variable.type ?? "", variable.evaluateName ?? "", variable.variablesReference].join("\u0000"); +} + +async function sendPage(sessionId: string, variablesReference: number, requested: VariablePageRequest) { + const operationId = createDebugOperationId(); + useDebuggerStore.getState().actions.registerAdapterRequest(operationId, { + command: "variables", + variablesReference, + requested, + }); + await sendDebugAdapterRequest( + sessionId, + "variables", + { + variablesReference, + ...(requested.filter ? { filter: requested.filter } : {}), + start: requested.start, + count: requested.count, + }, + operationId, + ); +} + +/** + * Starts a container from its first page, keeping the adapter-reported named + * and indexed counts so a large container pages instead of loading at once. + */ +export async function requestFirstVariablePage( + sessionId: string, + container: { variablesReference: number; namedVariables?: number; indexedVariables?: number }, +) { + const cursor = await planVariablePage({ + variablesReference: container.variablesReference, + namedVariables: container.namedVariables, + indexedVariables: container.indexedVariables, + pageSize: VARIABLE_PAGE_SIZE, + }); + if (!cursor.next) return; + useDebuggerStore.getState().actions.setVariablePageCursor(container.variablesReference, cursor); + await sendPage(sessionId, container.variablesReference, cursor.next); +} + +/** Requests the page after the last accepted one; a no-op once Core reports the container exhausted. */ +export async function requestNextVariablePage(sessionId: string, variablesReference: number) { + const cursor = useDebuggerStore.getState().variablePageCursors[variablesReference]; + if (!cursor?.next) return; + await sendPage(sessionId, variablesReference, cursor.next); +} + +/** + * Merges one adapter reply into the tree. Core decides whether the page is + * shown (an older cursor or an adapter ignoring `start` is dropped) and when + * the container is exhausted. + */ +export async function acceptVariablePage( + variablesReference: number, + requested: VariablePageRequest | undefined, + page: DebugVariable[], +) { + const { actions, variablePageCursors, variablesByReference } = useDebuggerStore.getState(); + const state: VariablePageCursor | undefined = variablePageCursors[variablesReference]; + if (!requested || !state) { + actions.setVariables(variablesReference, page); + return; + } + const known = new Set((variablesByReference[variablesReference] ?? []).map(variableKey)); + const additions = page.filter((variable) => { + const key = variableKey(variable); + if (known.has(key)) return false; + known.add(key); + return true; + }); + const reduction = await reduceVariablePage({ + state, + requested, + page: page.map(({ name, value, type, evaluateName, variablesReference: reference }) => ({ + name, + value, + type, + evaluateName, + variablesReference: reference, + })), + newItemCount: additions.length, + }); + actions.setVariablePageCursor(variablesReference, reduction.state); + if (reduction.accept) actions.appendVariables(variablesReference, additions); +} diff --git a/windows/tauri/src/features/debugger/stores/debugger.store.ts b/windows/tauri/src/features/debugger/stores/debugger.store.ts index cfa7a5c5cf..961643c244 100644 --- a/windows/tauri/src/features/debugger/stores/debugger.store.ts +++ b/windows/tauri/src/features/debugger/stores/debugger.store.ts @@ -1,6 +1,7 @@ import { debugSourcePathKey } from "../utils/debug-source-path"; import { create } from "zustand"; import { createSelectors } from "@/utils/zustand-selectors"; +import type { VariablePageCursor } from "../api/variable-paging-api"; import type { DebugBreakpoint, DebugLaunchConfig, @@ -40,6 +41,8 @@ interface DebuggerState { selectedFrameId: number | null; scopes: DebugScope[]; variablesByReference: Record; + /** Core-owned paging cursors of containers that still have children to load. */ + variablePageCursors: Record; stoppedState: DebugStoppedState | null; pendingRequests: Record; actions: { @@ -70,6 +73,8 @@ interface DebuggerState { selectStackFrame: (frameId: number | null) => void; setScopes: (scopes: DebugScope[]) => void; setVariables: (variablesReference: number, variables: DebugVariable[]) => void; + appendVariables: (variablesReference: number, variables: DebugVariable[]) => void; + setVariablePageCursor: (variablesReference: number, cursor: VariablePageCursor | null) => void; setStoppedState: (stoppedState: DebugStoppedState | null) => void; clearAdapterTranscript: () => void; getBreakpointsForFile: (filePath: string) => DebugBreakpoint[]; @@ -171,6 +176,7 @@ export const useDebuggerStore = createSelectors( selectedFrameId: null, scopes: [], variablesByReference: {}, + variablePageCursors: {}, stoppedState: null, pendingRequests: {}, actions: { @@ -347,6 +353,7 @@ export const useDebuggerStore = createSelectors( selectedFrameId: null, scopes: [], variablesByReference: {}, + variablePageCursors: {}, watchResults: {}, stoppedState: null, pendingRequests: {}, @@ -441,6 +448,27 @@ export const useDebuggerStore = createSelectors( })); }, + appendVariables: (variablesReference, variables) => { + set((state) => ({ + variablesByReference: { + ...state.variablesByReference, + [variablesReference]: [ + ...(state.variablesByReference[variablesReference] ?? []), + ...variables, + ], + }, + })); + }, + + setVariablePageCursor: (variablesReference, cursor) => { + set((state) => { + const { [variablesReference]: _dropped, ...rest } = state.variablePageCursors; + return { + variablePageCursors: cursor?.next ? { ...rest, [variablesReference]: cursor } : rest, + }; + }); + }, + setStoppedState: (stoppedState) => { set((state) => { const shouldClearInspection = @@ -455,6 +483,7 @@ export const useDebuggerStore = createSelectors( selectedFrameId: null, scopes: [], variablesByReference: {}, + variablePageCursors: {}, watchResults: {}, pendingRequests: {}, } diff --git a/windows/tauri/src/features/debugger/types/debugger.types.ts b/windows/tauri/src/features/debugger/types/debugger.types.ts index dac3927141..45b3fd6bc7 100644 --- a/windows/tauri/src/features/debugger/types/debugger.types.ts +++ b/windows/tauri/src/features/debugger/types/debugger.types.ts @@ -1,3 +1,4 @@ +import type { VariablePageRequest } from "../api/variable-paging-api"; export type DebuggerRuntime = "bun" | "node" | "python" | "rust" | "go" | "custom"; type DebugSessionStatus = "idle" | "running" | "paused"; @@ -96,6 +97,15 @@ export interface DebugStackFrame { id: number; name: string; sourcePath?: string; + /** + * The adapter serves this frame's source itself (a decompiled class or a + * disassembly); `sourcePath` then names no file on disk. + */ + sourceReference?: number; + /** Address of the frame's current instruction, for frames without a file. */ + instructionPointerReference?: string; + /** The active stepping policy skips this frame. */ + isFiltered?: boolean; line: number; column: number; } @@ -104,13 +114,18 @@ export interface DebugScope { name: string; variablesReference: number; expensive?: boolean; + namedVariables?: number; + indexedVariables?: number; } export interface DebugVariable { name: string; value: string; type?: string; + evaluateName?: string; variablesReference: number; + namedVariables?: number; + indexedVariables?: number; } export interface DebugStoppedState { @@ -138,5 +153,10 @@ export type DebugRequestContext = | { command: "threads" } | { command: "stackTrace"; threadId: number } | { command: "scopes"; frameId: number } - | { command: "variables"; variablesReference: number } + | { + command: "variables"; + variablesReference: number; + /** The page this request asked for; absent for unpaged requests. */ + requested?: VariablePageRequest; + } | { command: "evaluate"; expressionId: string }; diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index d94002c718..3a86c5339c 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -186,6 +186,26 @@ const updateJavaMavenConfiguration = mock( ): Promise => ({ kind: "noSession" }), ); +// Stands in for Core's verdict reducer; the reducer's own cases live in the shared fixture. +const resolveTestRunOutcome = mock( + async (request: { + termination: { kind: string; exitCode?: number }; + scope: string; + summary: { total: number; failed: number; errors: number } | null; + }) => { + const exitCode = request.termination.exitCode ?? null; + const executed = request.summary?.total ?? 0; + const failed = (request.summary?.failed ?? 0) + (request.summary?.errors ?? 0) > 0; + if (exitCode === 0 && executed === 0) { + const outcome = request.scope === "case" || request.scope === "suite" ? "noTestsMatched" : "noTestsFound"; + return { outcome, success: false, exitCode } as const; + } + return failed || exitCode !== 0 + ? ({ outcome: "testsFailed", success: false, exitCode } as const) + : ({ outcome: "passed", success: true, exitCode } as const); + }, +); + const dependencies = { createMavenPomWatchOperations, createMavenDependencyOutput, @@ -196,6 +216,7 @@ const dependencies = { parseMavenTestResults, readMavenDependencies, removeMavenDependencyOutput, + resolveTestRunOutcome, resolveEffectiveMavenExecutable, resolveMavenLaunch, resolveJavaTestClass, @@ -1379,6 +1400,10 @@ describe("Maven workspace state", () => { }); test("reports a successful Maven launch with no matching tests as a failure", async () => { + // The message is localized; pin English so the assertion does not follow the machine locale. + const { useSettingsStore } = await import("@/features/settings/stores/settings.store"); + const previous = useSettingsStore.getState().settings; + useSettingsStore.setState({ settings: { ...previous, displayLanguage: "en-US" } }); parseMavenTestResults.mockResolvedValueOnce({ testsRun: 0, failures: 0, @@ -1400,12 +1425,12 @@ describe("Maven workspace state", () => { .actions.runGoals(["test", "-Dtest=com.example.MissingTest"], null, testRun.title, testRun); const sessionId = store.getState().activeSessionId; store.getState().actions.finishProcess(sessionId!, 0); - await Promise.resolve(); - await Promise.resolve(); + for (let tick = 0; tick < 6; tick += 1) await Promise.resolve(); expect(store.getState().taskStatus).toBe("failed"); - expect(store.getState().taskError).toBe('No tests matched selector "com.example.MissingTest".'); + expect(store.getState().taskError).toBe("No tests matched the selection."); expect(store.getState().testResults?.success).toBe(false); + useSettingsStore.setState({ settings: previous }); }); test("parses test results after completion and drops a stale result after a newer run", async () => { diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index fc3217e624..853d40dd0a 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -3,6 +3,9 @@ import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-works import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; import { frontendTrace } from "@/utils/frontend-trace"; +import { createTranslator } from "@/i18n/locale"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { resolveTestRunOutcome } from "@/features/run/api/test-run-outcome-api"; import { createMavenDependencyPlan, createMavenLaunchPlan, @@ -75,6 +78,7 @@ export interface MavenStoreDependencies { parseMavenDiagnostics: typeof parseMavenDiagnostics; readMavenDependencies: typeof readMavenDependencies; parseMavenTestResults: typeof parseMavenTestResults; + resolveTestRunOutcome: typeof resolveTestRunOutcome; removeMavenDependencyOutput: typeof removeMavenDependencyOutput; resolveEffectiveMavenExecutable: typeof resolveEffectiveMavenExecutable; resolveMavenLaunch: typeof resolveMavenLaunch; @@ -98,6 +102,7 @@ const defaultMavenStoreDependencies: MavenStoreDependencies = { parseMavenDiagnostics, readMavenDependencies, parseMavenTestResults, + resolveTestRunOutcome, removeMavenDependencyOutput, resolveEffectiveMavenExecutable, resolveMavenLaunch, @@ -1421,20 +1426,41 @@ export const createMavenStore = ( ), }); } - if (diagnosticsRevision === revision && get().root === root) { - const noTestsMatched = exitCode === 0 && testResults.testsRun === 0; - if (noTestsMatched && testRun) { - const message = `No tests matched selector "${testRun.selector}".`; + if (diagnosticsRevision !== revision || get().root !== root) return; + set({ testResults }); + // Core owns the verdict; a run that executed no selected test fails on + // both platforms instead of passing on exit code 0. + return dependencies + .resolveTestRunOutcome({ + termination: { kind: "exited", exitCode }, + scope: testRun.selector.includes("#") ? "case" : "suite", + summary: { + total: testResults.testsRun, + passed: testResults.passed, + failed: testResults.failures, + errors: testResults.errors, + skipped: testResults.skipped, + }, + }) + .then((verdict) => { + if (diagnosticsRevision !== revision || get().root !== root) return; + if (verdict.outcome !== "noTestsMatched" && verdict.outcome !== "noTestsFound") { + if (!verdict.success) set({ taskStatus: "failed" }); + return; + } + const t = createTranslator(useSettingsStore.getState().settings.displayLanguage); + const message = t( + verdict.outcome === "noTestsMatched" + ? "tests.outcome.noTestsMatched" + : "tests.outcome.noTestsFound", + ); set({ testResults: { ...testResults, success: false }, taskStatus: "failed", taskError: message, output: trimOutput(`${get().output}${get().output.endsWith("\n") ? "" : "\n"}${message}\n`), }); - } else { - set({ testResults }); - } - } + }); }) .catch((error) => { if (diagnosticsRevision !== revision || get().root !== root) return; diff --git a/windows/tauri/src/features/run/api/test-run-outcome-api.ts b/windows/tauri/src/features/run/api/test-run-outcome-api.ts new file mode 100644 index 0000000000..49ca1e8ea3 --- /dev/null +++ b/windows/tauri/src/features/run/api/test-run-outcome-api.ts @@ -0,0 +1,46 @@ +import { invoke } from "@/platform/tauri-core"; + +/** How a test process ended, as the host observed it. */ +export type TestRunTermination = + | { kind: "exited"; exitCode: number } + | { kind: "timedOut" } + | { kind: "cancelled" }; + +/** The breadth the host asked to run; Core never reads selector syntax. */ +export type TestRunScope = "workspace" | "project" | "suite" | "case"; + +export interface TestRunSummary { + total: number; + passed: number; + failed: number; + errors: number; + skipped: number; +} + +export type TestRunOutcome = + | "passed" + | "testsFailed" + | "exitedWithError" + | "noTestsMatched" + | "noTestsFound" + | "timedOut" + | "cancelled"; + +export interface TestRunVerdict { + outcome: TestRunOutcome; + success: boolean; + exitCode: number | null; +} + +/** + * Reduces a finished test run to the tool-neutral verdict macOS shows too + * (`build.resolveTestRunOutcome`). `summary` is null when the run produced no + * structured results. + */ +export function resolveTestRunOutcome(request: { + termination: TestRunTermination; + scope: TestRunScope; + summary: TestRunSummary | null; +}): Promise { + return invoke("build.resolveTestRunOutcome", request); +} diff --git a/windows/tauri/src/i18n/shared.generated.ts b/windows/tauri/src/i18n/shared.generated.ts index 0687644398..898bc16ec1 100644 --- a/windows/tauri/src/i18n/shared.generated.ts +++ b/windows/tauri/src/i18n/shared.generated.ts @@ -1019,6 +1019,8 @@ export const sharedCatalogs = { "debugger.hideRunAndDebug": "Hide Run and Debug", "debugger.into": "Into", "debugger.lineNumber": "Line {line}", + "debugger.loadMoreVariables": "Load more", + "debugger.loadScopeVariables": "Load variables", "debugger.loadingVariable": "Loading variable", "debugger.noCommandAvailable": "No command available", "debugger.noLaunchJsonFound": "No launch.json found", @@ -4739,6 +4741,8 @@ export const sharedCatalogs = { "terminal.toolbarTabsOnRight": "Tabs on Right", "terminal.toolbarTerminalWidth": "Terminal Width", "terminal.toolbarVerticalTabs": "Vertical Tabs", + "tests.outcome.noTestsFound": "No tests were found.", + "tests.outcome.noTestsMatched": "No tests matched the selection.", "titleProject.addProject": "Add Project", "titleProject.cloneRepository": "Clone Repository…", "titleProject.closeAllProjects": "Close All Projects", @@ -6071,6 +6075,8 @@ export const sharedCatalogs = { "debugger.hideRunAndDebug": "隐藏运行和调试", "debugger.into": "进入", "debugger.lineNumber": "第 {line} 行", + "debugger.loadMoreVariables": "加载更多", + "debugger.loadScopeVariables": "加载变量", "debugger.loadingVariable": "正在加载变量", "debugger.noCommandAvailable": "没有可用命令", "debugger.noLaunchJsonFound": "未找到 launch.json", @@ -9791,6 +9797,8 @@ export const sharedCatalogs = { "terminal.toolbarTabsOnRight": "标签页在右侧", "terminal.toolbarTerminalWidth": "终端宽度", "terminal.toolbarVerticalTabs": "垂直标签页", + "tests.outcome.noTestsFound": "没有找到任何测试。", + "tests.outcome.noTestsMatched": "没有测试匹配所选范围。", "titleProject.addProject": "添加项目", "titleProject.cloneRepository": "克隆仓库…", "titleProject.closeAllProjects": "关闭所有项目", From edf2034fb1fb293c9200ad46ed639f86ebd548a0 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 4 Nov 2026 22:47:26 -0800 Subject: [PATCH 2/3] =?UTF-8?q?test(macos):=20RUN-03=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=8C=89=E6=96=B0=E5=88=A4=E5=AE=9A=E6=96=AD=E8=A8=80=E7=BB=88?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 带失败用例期望 .failed,零测试期望 .failed 并断言“没有找到任何测试”文案,正常通过仍期望 .passed。 Co-Authored-By: Claude Opus 5.5 --- .../LitheExecutionModuleTests/ExecutionModuleTests.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift index f7f8d91426..9ea249e931 100644 --- a/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift +++ b/macos/Tests/LitheExecutionModuleTests/ExecutionModuleTests.swift @@ -402,12 +402,14 @@ struct ExecutionModuleTests { let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) defer { service.reset() } func run(_ provider: String, status: BuildTestResults.TestCase.Status) async throws { + // RUN-03: a reported failure outranks exit code 0. + let expected: LanguageTestRunState = status == .passed ? .passed : .failed(exitCode: 0) let report = BuildTestResults(tests: [.init(suite: "Same", name: "checks", status: status)], summary: .init(total: 1, passed: status == .passed ? 1 : 0, failed: status == .failed ? 1 : 0, errors: 0, skipped: 0), success: status == .passed) service.runUsingBackend(providerID: provider, scope: .workspace, workspaceURL: URL(fileURLWithPath: "/workspace"), label: "Tests") { _, _ in LanguageTestBackendResult(exitCode: 0, reports: [report]) } - try await awaitTestValue(service.$state) { $0 != .running && $0 != .idle } + try await awaitTestValue(service.$state) { $0 == expected } } try await run("java", status: .failed) try await run("example", status: .passed) @@ -423,13 +425,16 @@ struct ExecutionModuleTests { let service = LanguageTestService(outcomeResolver: LinkedCoreTestRunOutcome(), executableResolver: TestExecutableResolver(), processFactory: { TestStreamingProcess() }) defer { service.reset() } func run(_ tests: [[String: String]], coverage: [[String: String]]? = nil) async throws { + // RUN-03: a run that executed no test is a failure, never a pass on exit code 0. + let expected: LanguageTestRunState = tests.isEmpty ? .failed(exitCode: 0) : .passed var json: [String: Any] = ["tests": tests, "summary": ["total": tests.count, "passed": tests.count, "failed": 0, "errors": 0, "skipped": 0], "success": true] if let coverage { json["coverage"] = coverage } let report = try JSONDecoder().decode(BuildTestResults.self, from: JSONSerialization.data(withJSONObject: json)) service.runUsingBackend(providerID: "java", scope: .workspace, workspaceURL: URL(fileURLWithPath: "/workspace"), label: "Tests") { _, _ in LanguageTestBackendResult(exitCode: 0, reports: [report]) } - try await awaitTestValue(service.$state) { $0 != .running && $0 != .idle } + try await awaitTestValue(service.$state) { $0 == expected } + #expect(service.errorMessage == (tests.isEmpty ? L10n.string("No tests were found.") : nil)) } func test(_ project: String, _ method: String) -> [String: String] { ["project": project, "suite": "Same", "case": method, "status": "passed"] From 5fefded17c470bc59781afcbf71cd5998be2f7e2 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Thu, 5 Nov 2026 01:37:28 -0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20rebase=20=E5=90=8E=20D?= =?UTF-8?q?ebugModuleTests=20=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1746 新增的 makeDeclaredFeature 仍用旧的 GenericDebugFeatureModel(sessions:) 初始化, 而本 PR 给初始化器增加了必填的 paging 参数,补上 paging: LinkedCoreDebugPaging()。 Co-Authored-By: Claude Opus 5.5 --- macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift index e5eb12f0d9..e39c4857fe 100644 --- a/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift +++ b/macos/Tests/LitheDebugModuleTests/DebugModuleTests.swift @@ -5116,7 +5116,7 @@ struct DebugModuleTests { ) -> GenericDebugFeatureModel { let descriptor = DebugProviderDescriptor(id: "example-dap", displayName: "Example", fileExtensions: ["ex"]) let manager = DebugAdapterSessionManager(providers: [descriptor]) { _, _ in session } - let feature = GenericDebugFeatureModel(sessions: manager) + let feature = GenericDebugFeatureModel(paging: LinkedCoreDebugPaging(), sessions: manager) let root = URL(fileURLWithPath: rootPath, isDirectory: true) precondition(feature.start( fileURL: root.appendingPathComponent("main.ex"),