From 5044e20171564d201d5fe49c18e2ea6f90c53010 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 9 Nov 2026 00:39:20 +0800 Subject: [PATCH] test: focus diagnostics and evidence checks on behavior (#9303) * test: focus configuration diagnostics on their owners Preserve shape errors, selected paths, recovery guidance, and unchanged-file behavior across parser, setup, and uninstall boundaries. * test: focus artifact and evidence checks on behavior Retain format, identity, malformed-input, and cleanup coverage while removing fixed prose and closing the admitted test source. * refactor(debugger): use configured session admission limits * test: focus evidence and diagnostics on behavior * fix(test): restore verifier export and formatting * test(config): verify debugger limit ranges * test(rizin): keep fixture executable authoritative * test(rizin): isolate debugger fixture environment --- scripts/ci/scopes.mjs | 2 - src/adb/AdbConfiguration.ts | 4 +- src/apktool/ApktoolCommandRecovery.test.ts | 12 +- src/apktool/ApktoolConfiguration.ts | 4 +- src/apktool/ApktoolDecodeTimeout.test.ts | 14 +- src/apktool/ApktoolProvider.test.ts | 19 +- src/apktool/ApktoolVersionProbe.test.ts | 20 +- .../AppBundleOpenStepDepth.test.ts | 3 +- .../BrowserObservationService.test.ts | 2 +- .../BrowserScenarioCaptureService.test.ts | 2 +- src/application/CapabilityInventory.test.ts | 15 +- .../ClientConfigurationDocument.shape.test.ts | 38 ++-- src/application/Doctor.test.ts | 23 +- src/application/MacHopper.test.ts | 43 ++-- src/application/MacHopper.ts | 4 +- ...ctionObligationCandidates.semantic.test.ts | 18 +- ...ReconstructionObligationCandidates.test.ts | 35 +++- ...nstructionObligationLedger.closure.test.ts | 6 +- .../ReferenceSourceImportEntries.test.ts | 7 +- src/application/SetupInstallFailure.test.ts | 31 ++- src/application/UpdateRuntime.release.test.ts | 96 ++++++--- src/application/UpdateRuntime.ts | 8 +- .../WebNetworkCaptureService.test.ts | 3 + src/application/WebRuntimeService.test.ts | 2 +- .../WebSourceLocationService.test.ts | 8 +- .../analysisView/BrowserScenarioViews.test.ts | 6 +- .../binary/BinarySessionStatus.test.ts | 9 +- .../desktop/DesktopApplicationExport.test.ts | 7 +- .../environment/environmentScenario.test.ts | 6 +- .../ElectronObservationService.test.ts | 3 +- ...criptArtifactGraphElectronCoverage.test.ts | 6 +- ...avaScriptRuntimeObservationService.test.ts | 2 +- .../JavaScriptSemanticGraphBuilder.test.ts | 4 +- .../NativeFunctionMatchingService.test.ts | 12 +- src/application/process/ProcessCli.ts | 2 +- .../ArtifactOperationFailure.test.ts | 6 +- src/artifacts/ArtifactPaths.test.ts | 49 +++-- src/artifacts/ArtifactResourceScope.test.ts | 2 +- .../ArtifactSourceResolution.test.ts | 19 +- src/artifacts/AsarHeader.test.ts | 2 +- src/artifacts/ChangeTimeBaseline.test.ts | 26 ++- src/artifacts/DfuSeArtifactReader.test.ts | 8 +- .../EspFlashImageArtifactReader.test.ts | 21 +- .../MachOSliceArtifactReader.test.ts | 5 +- src/artifacts/NativeDmgArtifactReader.ts | 2 +- src/artifacts/NativeDmgProtocol.test.ts | 9 +- .../NativeDmgRootObservation.test.ts | 17 +- src/artifacts/NodeSeaArtifactReader.test.ts | 78 ++++--- src/artifacts/ZipArtifactReader.test.ts | 4 +- .../apple/AppleAssetCatalogAnalysis.test.ts | 12 +- .../AppleAssetCatalogResourceCoverage.test.ts | 20 +- .../apple/KeyedArchiveReader.test.ts | 38 ++-- src/artifacts/fit/FitStructure.test.ts | 30 +-- src/artifacts/nodeSea/NodeSeaBlob.test.ts | 158 ++++++++++---- src/artifacts/nodeSea/NodeSeaBlob.ts | 34 ++- .../pyinstaller/PyInstallerAdmission.test.ts | 22 +- .../PyInstallerFormatBoundaries.test.ts | 1 - src/browser/PngVisualDiff.test.ts | 6 +- src/browser/history/HarCapture.test.ts | 18 +- .../PlaywrightTraceDecoder.test.ts | 21 +- .../protobuf/ProtobufBodyTrailer.test.ts | 5 +- src/cli/jsonLines.ts | 2 +- src/cli/options.ts | 2 +- src/config.test.ts | 20 +- src/config/asarHeaderAdmission.ts | 2 +- src/config/bridgeMaxRequestBytes.test.ts | 13 +- src/config/bridgeMaxRequestBytes.ts | 2 +- src/config/captureDecoderHeap.test.ts | 25 ++- src/config/captureDecoderHeap.ts | 2 +- src/config/debuggerSessionLimits.test.ts | 58 +++-- src/config/debuggerSessionLimits.ts | 17 +- src/config/ghidraExistingProject.ts | 2 +- src/config/nativeMatchingAllowance.test.ts | 15 +- src/config/nativeMatchingAllowance.ts | 2 +- src/config/regexMatchingDeadline.test.ts | 23 +- src/config/regexMatchingDeadline.ts | 2 +- src/config/regexWorkerHeapRequest.test.ts | 25 ++- src/config/regexWorkerHeapRequest.ts | 2 +- src/config/windowsChangeTimeSettle.test.ts | 18 +- src/config/windowsChangeTimeSettle.ts | 4 +- src/contracts/workflowInputContracts.test.ts | 33 ++- src/desktop/DesktopSessionRequests.test.ts | 50 ++--- src/desktop/DesktopSessionRequests.ts | 2 +- src/domain/analysisErrorProjection.test.ts | 20 +- .../artifactInventoryView.test.ts | 2 +- .../environmentManifestView.test.ts | 24 ++- .../environmentStateScenarioTiming.test.ts | 6 +- .../analysisView/environmentStateView.test.ts | 12 +- src/domain/analysisView/machoView.test.ts | 8 +- .../scenarioCaptureRecords.test.ts | 6 +- .../apkSignature/apkSignatureReport.test.ts | 15 +- src/domain/apple/appleApplication.test.ts | 8 +- src/domain/apple/keyedArchive.test.ts | 4 +- src/domain/apple/machoSectionHeader.test.ts | 2 +- src/domain/apple/machoSegmentHeader.test.ts | 2 +- src/domain/apple/plistValue.ts | 4 +- src/domain/artifactInventoryEvidence.test.ts | 5 +- src/domain/browserScenarioDiff.test.ts | 4 +- src/domain/bundleComparison.test.ts | 14 +- src/domain/canonicalDigest.test.ts | 16 +- src/domain/canonicalJsonDigestSteps.test.ts | 27 ++- src/domain/desktop/appExportInstall.test.ts | 50 +++-- src/domain/elfDynamicEntry.test.ts | 5 +- src/domain/elfHashSymbolCount.test.ts | 23 +- src/domain/elfProgramHeader.test.ts | 2 +- src/domain/elfRawTableDeclarations.test.ts | 2 +- src/domain/elfRelocation.test.ts | 2 +- src/domain/elfSectionHeader.test.ts | 2 +- src/domain/elfSymbol.test.ts | 5 +- src/domain/firmware/dfuse.test.ts | 38 +++- src/domain/firmware/espFlashLayout.test.ts | 6 +- src/domain/firmware/espFlashPlan.test.ts | 38 ++-- src/domain/firmware/hexRecords.test.ts | 4 +- src/domain/firmware/uf2.test.ts | 20 +- src/domain/fixedAllowanceRemediation.test.ts | 39 ++-- src/domain/flutter/flutterBuildAnalysis.ts | 2 +- src/domain/functionComparison.test.ts | 5 +- src/domain/inputIssueProjection.test.ts | 54 ++--- ...avascriptFeatureSeed.array_members.test.ts | 12 +- .../javascriptSemanticGraphQuery.test.ts | 26 ++- .../sourceToBundleComparison.test.ts | 5 +- src/domain/native/elfNoteLayout.test.ts | 5 +- src/domain/peCoffHeader.test.ts | 5 +- src/domain/peOptionalHeader.test.ts | 9 +- src/domain/propertyListKeys.test.ts | 45 ++-- src/domain/propertyListOpenStep.test.ts | 5 +- .../propertyListOpenStepComments.test.ts | 3 +- src/domain/reconstructionVerification.test.ts | 6 +- src/domain/skillManifest.test.ts | 2 +- src/domain/sourceMapEnvelope.test.ts | 5 +- src/domain/webBundleAnalyzer.test.ts | 5 +- src/dotnet/ManagedMemberInspector.test.ts | 17 +- .../ManagedNativeBoundaryInspector.test.ts | 5 +- src/filesystem/ChangeTimeBaseline.ts | 2 +- src/firmware/FirmwareFiles.ts | 2 +- src/flutter/DartElf.test.ts | 12 +- src/gdb/GdbSessionManager.ts | 35 +--- src/ghidra/GhidraDoctor.test.ts | 8 +- src/ghidra/GhidraLoadImageValues.test.ts | 14 ++ src/ghidra/GhidraPeProfile.test.ts | 23 +- src/ghidra/GhidraProvider.behavior.test.ts | 25 ++- src/hopper/HopperMachOImage.test.ts | 5 +- src/hopper/HopperRegexSearch.test.ts | 3 +- src/ida/IdaNativeProtocol.test.ts | 23 +- src/ida/IdaProvider.test.ts | 15 +- src/ida/IdaReferenceCoverage.test.ts | 4 +- src/javascript/recovery/WakaruReport.test.ts | 5 +- src/main/messages.test.ts | 2 - src/mcpDoctor.ts | 4 +- src/native/AppleDispatchMetadata.test.ts | 10 +- src/native/MachoProjectionBudget.test.ts | 1 + src/native/NativeMachoInspection.test.ts | 41 ++-- src/native/NativeUiObservation.test.ts | 19 +- src/native/apple/AppleIpsParser.test.ts | 14 +- src/native/apple/losslessJson.test.ts | 6 +- src/native/elf/ElfNoteParser.test.ts | 16 +- src/native/elf/KernelModuleParser.test.ts | 19 +- src/native/metadata/BinaryContainer.test.ts | 8 +- src/native/metadata/ElfStringTable.test.ts | 10 +- src/native/metadata/PeImage.test.ts | 2 +- src/native/parsers/lipo.test.ts | 28 ++- src/native/pe/PeImportParser.test.ts | 5 +- src/native/pe/PeResourceParser.test.ts | 2 +- src/pcap/PcapNativeBound.test.ts | 4 +- src/pcap/PcapNativeBound.ts | 2 +- ...arwinProcessRunTokenReader.timeout.test.ts | 16 +- src/process/DarwinProcessRunTokenReader.ts | 2 +- src/process/OwnedCommand.test.ts | 5 +- src/process/OwnedLease.test.ts | 1 + .../ProcessOwnership.finalSweep.test.ts | 2 +- .../ProcessOwnership.observation.test.ts | 5 +- src/process/ProcessOwnership.part2.test.ts | 2 +- src/process/ProcessOwnership.part3.test.ts | 5 +- src/process/ProcessOwnership.test.ts | 6 +- .../ProcessOwnership.validation.test.ts | 18 +- src/process/RegexMatcher.test.ts | 7 - .../capture/ProcessCaptureCapability.ts | 2 +- .../capture/ProcessCaptureLifecycle.ts | 4 +- .../capture/ProcessCaptureObservation.test.ts | 18 ++ .../ProcessCaptureTerminalRetention.test.ts | 13 +- .../capture/ProcessNormalization.test.ts | 38 +++- src/process/capture/ProcessNormalization.ts | 2 +- .../ProcessScenarioRuntimeValidation.test.ts | 34 ++- .../ProcessScenarioRuntimeValidation.ts | 2 +- .../capture/ProcessSensitiveTerminal.test.ts | 3 +- .../ProcessTerminalObservation.test.ts | 3 +- src/rizin/RizinDebugSessionManager.ts | 44 +--- src/server/EvidenceMcpServer.test.ts | 28 +-- stryker.semantics.config.mjs | 4 +- .../artifactDmgAttachmentRecoveryCli.test.ts | 13 +- tests/acceptance/espFlashImageCli.test.ts | 28 +-- .../acceptance/firmwareContainersCli.test.ts | 4 +- tests/acceptance/hexRecordsCli.test.ts | 14 +- tests/acceptance/jsonLinesCli.test.ts | 7 +- tests/acceptance/managedCliOutput.test.ts | 7 +- tests/acceptance/pyinstallerArchive.test.ts | 9 +- .../tools/historicalCaptureResources.test.ts | 10 +- tests/boundary/cli/cliJsonStdinCli.test.ts | 6 +- .../cli/evidenceBundleCommands.test.ts | 11 +- .../cli/gamecubeDolCliOptions.test.ts | 13 -- tests/boundary/cli/ghidraJavaDoctor.test.ts | 13 +- .../cli/ghidraPackedProgramDoctor.test.ts | 39 ++-- .../javascriptApplicationJsonOutput.test.ts | 7 +- tests/boundary/cli/jsonLinesOutput.test.ts | 11 +- .../cli/keyedArchiveXmlNestingCli.test.ts | 5 +- .../cli/managedMemberComparisonCli.test.ts | 8 + tests/boundary/cli/rawBinaryCli.test.ts | 45 ++++ .../boundary/cli/referenceSourceUtf8.test.ts | 10 +- .../filesystem/asarHeaderAdmission.test.ts | 8 +- .../boundary/filesystem/binaryTarget.test.ts | 18 +- .../boundary/filesystem/ciChangeScope.test.ts | 2 - .../boundary/filesystem/cliJsonInput.test.ts | 18 +- .../filesystem/clientLifecycle.test.ts | 50 +++-- .../filesystem/evidenceBundleFiles.test.ts | 18 +- .../javascriptApplicationService.test.ts | 5 +- .../javascriptRuntimeReconciliation.test.ts | 7 +- .../javascriptApplicationWorkflows.test.ts | 37 ++-- .../filesystem/keyedArchiveXmlNesting.test.ts | 13 +- .../filesystem/processCliFileInput.test.ts | 36 +++- .../filesystem/riscvIsaDeclaration.test.ts | 14 +- .../setupClientConfigCleanupOwnership.test.ts | 5 +- tests/boundary/filesystem/setupConfig.test.ts | 15 +- .../filesystem/targetDiagnostics.test.ts | 25 ++- .../firmware/firmwareReportFiles.test.ts | 26 ++- .../firmwareStagingBudgetWiring.test.ts | 34 ++- tests/boundary/gpu/codeObjects.test.ts | 10 +- .../boundary/il2cpp/il2cppBinaryFile.test.ts | 68 ++++-- .../javascriptRuntimeObservation.test.ts | 5 +- .../javascriptParentResultAdmission.test.ts | 5 +- .../javascriptSourceAnalysisWorker.test.ts | 5 +- .../javascript/workerOrderAudit.test.ts | 10 +- tests/boundary/mcp/artifactMcp.test.ts | 32 +-- .../mcp/capabilityRemediationMcp.test.ts | 34 ++- .../enhancedTools.callPathsAndBatches.test.ts | 10 +- tests/boundary/mcp/espFlashImageMcp.test.ts | 10 +- .../boundary/mcp/evidenceBundleInline.test.ts | 11 +- tests/boundary/mcp/flutterMcp.test.ts | 9 +- tests/boundary/mcp/mcpDoctor.test.ts | 47 +++-- .../mcp/processEvidenceIdentityMcp.test.ts | 33 +-- .../mcp/rizinOutputPreservation.test.ts | 9 +- tests/boundary/mcp/rizinPendingOutput.test.ts | 102 +++++++-- tests/boundary/mcp/universalSliceMcp.test.ts | 5 +- tests/boundary/pcap/pcapProvider.test.ts | 12 +- .../darwinProcessRunTokenReader.test.ts | 24 ++- ...riptRuntimeReconciliationAmbiguity.test.ts | 5 +- .../process/ownedDebuggerSession.test.ts | 40 ++-- .../process/processCaptureCapability.test.ts | 78 ++++--- .../process/processCaptureLifecycle.test.ts | 17 +- tests/boundary/process/processCli.test.ts | 10 +- tests/boundary/process/regexMatcher.test.ts | 15 ++ .../GdbSessionManagerCoverage.test.ts | 144 +++++-------- .../GdbSessionReviewRegressions.test.ts | 33 ++- .../providers/RizinCommandTimeout.test.ts | 11 - .../RizinDebugSessionCoverage.fixtures.ts | 9 +- .../RizinDebugSessionCoverage.part2.test.ts | 8 +- .../RizinDebugSessionCoverage.part3.test.ts | 198 ++++++++++-------- .../RizinDebugSessionCoverage.part4.test.ts | 49 +++-- .../RizinDebugSessionCoverage.part5.test.ts | 60 +++--- .../RizinDebugSessionInferiorStdin.test.ts | 16 +- .../RizinDebugSessionManagers.test.ts | 6 +- ...RizinDebugSessionReviewRegressions.test.ts | 16 +- .../providers/RizinInferiorTty.test.ts | 41 ++-- .../providers/RizinTimeoutEvidence.test.ts | 63 ------ .../providers/fixedAllowanceRecovery.test.ts | 12 +- .../hopper/hopperBridgeFacade.test.ts | 5 +- .../kuna/kunaCommandFailures.test.ts | 10 +- .../kuna/kunaInventoryPlacement.test.ts | 5 +- .../providers/kuna/kunaMachoInventory.test.ts | 41 ++-- .../providers/kuna/kunaProvider.test.ts | 11 +- .../kuna/kunaSingleFunctionRuns.test.ts | 10 +- .../providers/kuna/kunaStrings.test.ts | 10 +- .../managed/managedMemberComparison.test.ts | 12 ++ .../managed/managedStaticProvider.test.ts | 5 +- .../native/nativeCallObservation.test.ts | 28 ++- .../nativeMachoUuidlessInspection.test.ts | 5 +- .../sqlite/sqliteDatabaseBusy.test.ts | 14 +- .../sqlite/sqliteDatabaseInspection.test.ts | 3 +- .../sqlite/sqliteDatabaseProvider.test.ts | 5 +- .../managedApplicationGraph.test.ts | 34 +-- .../objdumpSelectedDisassembly.test.ts | 6 +- tests/composition/reverseEngineering.test.ts | 18 +- .../reverseEngineeringEdgeCases.test.ts | 21 +- .../managed/managedNativeVerification.test.ts | 6 +- tests/fixtures/mcpDoctorReply.mjs | 8 + .../fixtures/ownerObservationExpectation.ts | 8 +- .../firmwareInheritedFileSizeCeiling.test.ts | 18 +- .../macHopperReleaseMinimum.test.ts | 11 +- tsconfig.reverse-tools-mutation.json | 2 - vitest.reverse-tools-semantics.config.ts | 3 +- vitest.reverse-tools.config.ts | 2 - 290 files changed, 2808 insertions(+), 2030 deletions(-) delete mode 100644 tests/boundary/cli/gamecubeDolCliOptions.test.ts delete mode 100644 tests/boundary/providers/RizinCommandTimeout.test.ts delete mode 100644 tests/boundary/providers/RizinTimeoutEvidence.test.ts rename src/application/ownerObservationExpectation.fixture.ts => tests/fixtures/ownerObservationExpectation.ts (89%) diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index c50f0c19d2..4eae115ee2 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -98,8 +98,6 @@ const PATH_SCOPES = { "tests/boundary/providers/GdbCommandQueue.test.ts", "tests/boundary/providers/gdbTestHelpers.ts", "tests/boundary/providers/RizinDebugSessionCoverage.part*.test.ts", - "tests/boundary/providers/RizinCommandTimeout.test.ts", - "tests/boundary/providers/RizinTimeoutEvidence.test.ts", "tests/boundary/providers/RizinDebugSession*.test.ts", "tests/boundary/providers/RizinInferiorTty.test.ts", "tests/boundary/providers/RizinDecoderInputs.test.ts", diff --git a/src/adb/AdbConfiguration.ts b/src/adb/AdbConfiguration.ts index c32f86db29..3378d546f5 100644 --- a/src/adb/AdbConfiguration.ts +++ b/src/adb/AdbConfiguration.ts @@ -32,9 +32,9 @@ const VERSION_TIMEOUT_MS = 10_000; const ADB_VERSION_REMEDIATION = `Set ${ADB_BINARY_ENV} to an Android SDK Platform-Tools adb binary whose \`adb version\` reports its version, or put one on PATH.`; /** Repair when the selected adb binary does not exist. */ -export const ADB_MISSING_REMEDIATION = `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary.`; +const ADB_MISSING_REMEDIATION = `Set ${ADB_BINARY_ENV} to an absolute path to an existing adb binary.`; /** Repair when execution of the adb binary selected by the environment is denied. */ -export const ADB_NOT_EXECUTABLE_REMEDIATION = `Set ${ADB_BINARY_ENV} to an absolute path to the adb executable file, not a directory, and check execute permission on it and search permission on its parent directories.`; +const ADB_NOT_EXECUTABLE_REMEDIATION = `Set ${ADB_BINARY_ENV} to an absolute path to the adb executable file, not a directory, and check execute permission on it and search permission on its parent directories.`; const VERSION_MAX_OUTPUT_BYTES = 64 * 1024; /** How the adb binary was selected for this provider instance. */ diff --git a/src/apktool/ApktoolCommandRecovery.test.ts b/src/apktool/ApktoolCommandRecovery.test.ts index e8e6fb6ce1..6707b1657e 100644 --- a/src/apktool/ApktoolCommandRecovery.test.ts +++ b/src/apktool/ApktoolCommandRecovery.test.ts @@ -31,9 +31,11 @@ const provider = () => environment: { REA_APKTOOL_COMMAND: "/authored/apktool" }, }); const version = { - stdout: { text: "2.9.3\n" }, - stderr: { text: "" }, -} as Awaited>; + stdout: { text: "2.9.3\n", bytes: 6, observedBytes: 6 }, + stderr: { text: "", bytes: 0, observedBytes: 0 }, + exitCode: 0, + signal: null, +}; const inspect = (client: ApktoolProvider, signal?: AbortSignal) => client.execute( { operation: "inspect_apktool_client", input: {} }, @@ -255,8 +257,8 @@ it("attributes prerequisite cleanup to decode and preserves its live recovery", try { const result = await client.execute({ operation: "decode_android_resources", - input: {}, - } as never); + input: { path: "/fixture/input.apk", include_strings: false }, + }); if (result.ok) throw new Error("Expected prerequisite failure"); expect(result.error).toMatchObject({ operation: "decode_android_resources", diff --git a/src/apktool/ApktoolConfiguration.ts b/src/apktool/ApktoolConfiguration.ts index 50cbf095a2..66c80b813d 100644 --- a/src/apktool/ApktoolConfiguration.ts +++ b/src/apktool/ApktoolConfiguration.ts @@ -48,9 +48,9 @@ const apktoolVersionProbeTimeoutGuidance = (): { const APKTOOL_VERSION_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an Apktool 2.7.0 or newer launcher whose --version prints its version, or put one on PATH.`; /** Repair when the selected apktool launcher does not exist. */ -export const APKTOOL_MISSING_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script).`; +const APKTOOL_MISSING_REMEDIATION = `Set ${APKTOOL_COMMAND_ENV} to an absolute apktool launcher (the upstream wrapper or a java -jar script).`; /** Repair when execution of the selected apktool launcher is denied. */ -export const APKTOOL_NOT_EXECUTABLE_REMEDIATION = `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher.`; +const APKTOOL_NOT_EXECUTABLE_REMEDIATION = `Check file type and execute permission for ${APKTOOL_COMMAND_ENV} or select an executable apktool launcher.`; /** How the apktool launcher was selected for this provider instance. */ export interface ApktoolCommandSelection { diff --git a/src/apktool/ApktoolDecodeTimeout.test.ts b/src/apktool/ApktoolDecodeTimeout.test.ts index 3409851617..e5c016db7a 100644 --- a/src/apktool/ApktoolDecodeTimeout.test.ts +++ b/src/apktool/ApktoolDecodeTimeout.test.ts @@ -49,11 +49,15 @@ afterEach(() => { vi.mocked(runOwnedCommand).mockReset(); }); -const completed = (text: string): ProviderProcessSnapshot => - ({ - stdout: { text }, - stderr: { text: "" }, - }) as ProviderProcessSnapshot; +const completed = (text: string): ProviderProcessSnapshot => { + const bytes = Buffer.byteLength(text); + return { + stdout: { text, bytes, observedBytes: bytes }, + stderr: { text: "", bytes: 0, observedBytes: 0 }, + exitCode: 0, + signal: null, + }; +}; const installLauncher = (): void => { vi.mocked(runOwnedCommand).mockImplementation(async (spawn, limits) => { diff --git a/src/apktool/ApktoolProvider.test.ts b/src/apktool/ApktoolProvider.test.ts index ac5e825f21..1897a18260 100644 --- a/src/apktool/ApktoolProvider.test.ts +++ b/src/apktool/ApktoolProvider.test.ts @@ -1555,12 +1555,12 @@ it("discloses qualified and unfamiliar strings tables while preserving exact loc locale: locale ?? null, coverage: "complete", }); - expect(result.value.limitations).toContain( - `Other strings tables are not selected by locale; their resource configuration suffixes are preserved without inferring Android runtime matching: ${tables - .map((suffix) => JSON.stringify(`values-${suffix}`)) - .sort() - .join(", ")}`, + const otherTables = result.value.limitations.find((line) => + line.includes("values-unknown_CONFIGURATION"), ); + expect(otherTables).toMatch(/without inferring.*runtime matching/iu); + for (const suffix of tables) + expect(otherTables).toContain(JSON.stringify(`values-${suffix}`)); if (locale === undefined) expect(result.value.result).toMatchObject({ strings: [ @@ -1570,8 +1570,8 @@ it("discloses qualified and unfamiliar strings tables while preserving exact loc }); else if (locale === "en-rUS") { expect(result.value.result).toMatchObject({ strings: [] }); - expect(result.value.limitations).toContain( - "No strings.xml was found for locale en-rUS", + expect(result.value.limitations).toContainEqual( + expect.stringMatching(/strings\.xml.*locale en-rUS/iu), ); } else expect(result.value.result).toMatchObject({ @@ -1691,8 +1691,9 @@ it.each([ }); const limitations = result.value.limitations.join("\n"); if (kind === "unsupported") { - expect(limitations).toContain( - "String entries omitted due to unsupported markup or missing names: 2", + expect(limitations).toMatch(/omitted[^\n]*2/iu); + expect(limitations).toMatch( + /omitted.*unsupported markup.*missing names/iu, ); expect(limitations).not.toContain("entry counts are unknown"); } else { diff --git a/src/apktool/ApktoolVersionProbe.test.ts b/src/apktool/ApktoolVersionProbe.test.ts index 819e7d4871..870dc0217e 100644 --- a/src/apktool/ApktoolVersionProbe.test.ts +++ b/src/apktool/ApktoolVersionProbe.test.ts @@ -36,9 +36,11 @@ const owner = { it("runs the version probe as an owned interpreter-script process group", async () => { vi.mocked(runOwnedCommand).mockImplementationOnce(async (_spawn, limits) => { const output = { - stdout: { text: "2.9.3\n" }, - stderr: { text: "" }, - } as Awaited>; + stdout: { text: "2.9.3\n", bytes: 6, observedBytes: 6 }, + stderr: { text: "", bytes: 0, observedBytes: 0 }, + exitCode: 0, + signal: null, + }; limits.observeCompleted?.(output); return output; }); @@ -152,8 +154,8 @@ it("keeps unresolved probe cleanup typed when the launcher overflows its output" vi.mocked(runOwnedCommand).mockRejectedValueOnce(overflow()); const result = await provider.execute({ operation: "decode_android_resources", - input: {}, - } as never); + input: { path: "/fixture/input.apk", include_strings: false }, + }); expect(result.ok ? null : result.error.cleanupIncomplete).toBe(true); expect(result.ok ? null : projectAnalysisError(result.error)).toMatchObject({ code: "cleanup_incomplete", @@ -169,9 +171,11 @@ it.each(["linux", "win32"] as const)( vi.mocked(runOwnedCommand).mockImplementationOnce( async (_spawn, limits) => { const output = { - stdout: { text: "2.9.2\n" }, - stderr: { text: "" }, - } as Awaited>; + stdout: { text: "2.9.2\n", bytes: 6, observedBytes: 6 }, + stderr: { text: "", bytes: 0, observedBytes: 0 }, + exitCode: 0, + signal: null, + }; limits.observeCompleted?.(output); return output; }, diff --git a/src/application/AppBundleOpenStepDepth.test.ts b/src/application/AppBundleOpenStepDepth.test.ts index ee5ab98ac8..38ff38862c 100644 --- a/src/application/AppBundleOpenStepDepth.test.ts +++ b/src/application/AppBundleOpenStepDepth.test.ts @@ -3,7 +3,6 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { plistNestingLimitMessage } from "../domain/apple/plistValue.js"; import { resolveAppBundleExecutable } from "./AppBundleExecutable.js"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; @@ -34,7 +33,7 @@ describe("OpenStep Info.plist nesting during app target resolution", () => { error: { path: join(app, "Contents", "Info.plist"), cause: expect.objectContaining({ - message: plistNestingLimitMessage(), + message: expect.stringMatching(/128 nesting levels/iu), }), }, }); diff --git a/src/application/BrowserObservationService.test.ts b/src/application/BrowserObservationService.test.ts index d6326ecb0f..ad801c782f 100644 --- a/src/application/BrowserObservationService.test.ts +++ b/src/application/BrowserObservationService.test.ts @@ -18,7 +18,7 @@ import { expectKeptPartial, expectOutputError, expectOwnerError, -} from "./ownerObservationExpectation.fixture.js"; +} from "../../tests/fixtures/ownerObservationExpectation.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import { err, ok } from "../domain/result.js"; import { passiveNetworkCapture } from "../../tests/fixtures/passiveNetworkCapture.js"; diff --git a/src/application/BrowserScenarioCaptureService.test.ts b/src/application/BrowserScenarioCaptureService.test.ts index 49722d9445..a664be50e8 100644 --- a/src/application/BrowserScenarioCaptureService.test.ts +++ b/src/application/BrowserScenarioCaptureService.test.ts @@ -20,7 +20,7 @@ import { expectDroppedPartial, expectKeptPartial, expectOwnerError, -} from "./ownerObservationExpectation.fixture.js"; +} from "../../tests/fixtures/ownerObservationExpectation.js"; const scenario = browserScenarioInputSchema.parse({ browser: { mode: "launch", executable_path: "/fixture/chrome" }, diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 074dd92d23..f1d054fc1a 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -698,8 +698,7 @@ describe("partial navigation availability", () => { expect(availability).toMatchObject({ reason: "provider_missing", - remediation: - "Configure a provider for one of: current_document, current_address, resolve_containing_procedure.", + remediation: expect.stringMatching(/configure.*provider/i), modes: [ { name: "current_selection", @@ -718,6 +717,13 @@ describe("partial navigation availability", () => { }, ], }); + for (const operation of [ + "current_document", + "current_address", + "resolve_containing_procedure", + ]) { + expect(availability.remediation?.split(operation)).toHaveLength(2); + } }); }); @@ -820,8 +826,9 @@ describe("binary_overview capability requirements", () => { ).toMatchObject({ available: false, reason: "provider_missing", - remediation: - "Configure a provider for one of: current_document, list_documents.", + remediation: expect.stringMatching( + /configure.*provider.*current_document.*list_documents/i, + ), }); }); diff --git a/src/application/ClientConfigurationDocument.shape.test.ts b/src/application/ClientConfigurationDocument.shape.test.ts index 2992a2ddb2..c975b75078 100644 --- a/src/application/ClientConfigurationDocument.shape.test.ts +++ b/src/application/ClientConfigurationDocument.shape.test.ts @@ -1,34 +1,32 @@ import { describe, expect, it } from "vitest"; import { + ClientConfigurationShapeError, parseClientConfiguration, shadowedClientServers, } from "./ClientConfigurationDocument.js"; describe("parseClientConfiguration shape diagnostics", () => { it.each([ - ["null", "null"], - ["array", "[]"], - ["string", '"x"'], - ] as const)("names a non-object JSON root (%s)", (_label, text) => { - expect(() => parseClientConfiguration(text, "json")).toThrow( - new TypeError("The configuration root must be an object"), - ); + ["null root", "null", "root"], + ["array root", "[]", "root"], + ["string root", '"x"', "root"], + ["null server table", '{"mcpServers":null,"keep":1}', "mcpServers"], + ["array server table", '{"mcpServers":[],"keep":1}', "mcpServers"], + ["string server table", '{"mcpServers":"x","keep":1}', "mcpServers"], + ])("reports a non-object %s", (_label, text, location) => { + let error: unknown; + try { + parseClientConfiguration(text, "json"); + } catch (cause: unknown) { + error = cause; + } + expect(error).toBeInstanceOf(ClientConfigurationShapeError); + if (!(error instanceof Error)) throw new Error("Expected shape error"); + expect(error.message).toContain(location); + expect(error.message).toContain("object"); }); - it.each([ - ["null", "null"], - ["array", "[]"], - ["string", '"x"'], - ] as const)( - 'names a non-object "mcpServers" value (%s)', - (_label, servers) => { - expect(() => - parseClientConfiguration(`{"mcpServers":${servers},"keep":1}`, "json"), - ).toThrow(new TypeError('"mcpServers" must be an object')); - }, - ); - it("still rejects unparsable JSON with SyntaxError", () => { expect(() => parseClientConfiguration("not-json", "json")).toThrow( SyntaxError, diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index 4867f09382..6c0172a319 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -255,8 +255,9 @@ describe("doctor native analysis configuration", () => { expect.objectContaining({ name: "hopper-startup-timeout", ok: false, - detail: - "REA_HOPPER_STARTUP_TIMEOUT_MS: must be a positive decimal integer", + detail: expect.stringMatching( + /REA_HOPPER_STARTUP_TIMEOUT_MS.*positive decimal integer/i, + ), }), ); expect(result.scope_checks).toContainEqual( @@ -650,8 +651,9 @@ describe("doctor", () => { ok: false, classification: "config_drift", detail: "/missing/ilspycmd", - remediation: - "Unset REA_ILSPY_CMD_PATH or point it at a runnable ilspycmd executable.", + remediation: expect.stringMatching( + /REA_ILSPY_CMD_PATH.*runnable ilspycmd/i, + ), }); }); @@ -669,8 +671,9 @@ describe("doctor", () => { ok: false, classification: "config_drift", detail: configuredPath, - remediation: - "Unset or update HOPPER_LAUNCHER_PATH to an executable Hopper launcher.", + remediation: expect.stringMatching( + /HOPPER_LAUNCHER_PATH.*executable Hopper launcher/i, + ), }); }); }); @@ -738,7 +741,7 @@ describe("doctor installation identity", () => { skill: { installed_version: "10", state: "stale", - remediation: "Run rea setup to update the installed REA skill.", + remediation: expect.stringMatching(/rea setup.*update.*skill/i), }, }); }); @@ -751,14 +754,14 @@ describe("doctor installation identity", () => { expect(result.healthy).toBe(false); expect(result.identity?.skill).toMatchObject({ state: "missing", - remediation: "Run rea setup to update the installed REA skill.", + remediation: expect.stringMatching(/rea setup.*update.*skill/i), }); expect(result.checks).toContainEqual({ name: "skill:identity", ok: false, classification: "config_drift", - detail: "Installed REA skill identity is missing.", - remediation: "Run rea setup to update the installed REA skill.", + detail: expect.stringMatching(/skill identity.*missing/i), + remediation: result.identity?.skill.remediation, }); }); it("classifies an unreadable installed skill as unknown, not drift", async () => { diff --git a/src/application/MacHopper.test.ts b/src/application/MacHopper.test.ts index c0962fd757..d2c75ff296 100644 --- a/src/application/MacHopper.test.ts +++ b/src/application/MacHopper.test.ts @@ -10,8 +10,6 @@ import { } from "./HopperDownload.js"; import { installMacHopper, - macHopperHostVersionDetail, - macHopperIncomparableMinimumDetail, type MacHopperBundleVerification, type MacHopperBundleInstallResult, type MacHopperInstallHost, @@ -309,17 +307,19 @@ describe("macOS Hopper release host requirements", () => { host.feedBytes = macFeed(host, { deployment_target: "12.4" }); const result = await installMacHopper({}, host); - expect(result).toEqual({ + expect(result).toMatchObject({ status: "failed", reason: "incompatible_host_version", - diagnostic: macHopperHostVersionDetail({ - source: "release_feed", - packageUrl: PACKAGE_URL, - minimumVersion: "12.4", - hostVersion, - }), }); - expectNamed(result, [PACKAGE_URL, "12.4", hostVersion]); + expectNamed(result, [ + PACKAGE_URL, + "12.4", + hostVersion, + "deployment_target", + ]); + if (result.status !== "failed") + throw new Error("Expected version refusal"); + expect(result.diagnostic).toMatch(/not downloaded/i); expect(host.packageDownloads).toBe(0); expect(host.mounts).toBe(0); expect(host.installs).toBe(0); @@ -353,16 +353,14 @@ describe("macOS Hopper release host requirements", () => { host.feedBytes = macFeed(host, fields); const result = await installMacHopper({}, host); - expect(result).toEqual({ + expect(result).toMatchObject({ status: "failed", reason: "incompatible_host_version", - diagnostic: macHopperHostVersionDetail({ - source: "signed_app", - minimumVersion: "12.4", - hostVersion: "12.0", - }), }); expectNamed(result, ["LSMinimumSystemVersion", "12.4", "12.0"]); + if (result.status !== "failed") + throw new Error("Expected version refusal"); + expect(result.diagnostic).toMatch(/nothing was copied/i); expect(host.packageDownloads).toBe(1); expect(host.installs).toBe(0); expect(host.opens).toBe(0); @@ -400,12 +398,13 @@ describe("macOS Hopper release host requirements", () => { host.appMinimum = "12.x"; const result = await installMacHopper({}, host); - expect(result).toEqual({ - status: "failed", - reason: "bundle", - diagnostic: macHopperIncomparableMinimumDetail("12.x", "14.5"), - }); - expectNamed(result, ['"12.x"', "14.5"]); + expect(result).toMatchObject({ status: "failed", reason: "bundle" }); + if (result.status !== "failed") throw new Error("Expected bundle failure"); + expect(result.diagnostic).toContain("LSMinimumSystemVersion"); + expect(result.diagnostic).toContain("12.x"); + expect(result.diagnostic).toContain("14.5"); + expect(result.diagnostic).toMatch(/cannot compare/i); + expect(result.diagnostic).toMatch(/nothing was copied/i); expect(host.installs).toBe(0); expect(host.unmounts).toBe(1); expect(host.cleanups).toBe(1); diff --git a/src/application/MacHopper.ts b/src/application/MacHopper.ts index e8cdd3df09..bb8a1fd7f9 100644 --- a/src/application/MacHopper.ts +++ b/src/application/MacHopper.ts @@ -576,7 +576,7 @@ export const systemMacHopperInstallHost = ( }; /** Installer detail naming the declared minimum this Mac's version does not meet. */ -export const macHopperHostVersionDetail = ( +const macHopperHostVersionDetail = ( refusal: ( | { readonly source: "release_feed"; readonly packageUrl: string } | { readonly source: "signed_app" } @@ -587,7 +587,7 @@ export const macHopperHostVersionDetail = ( : `The signed Hopper application declares LSMinimumSystemVersion ${refusal.minimumVersion}; this Mac runs macOS ${refusal.hostVersion}. Nothing was copied.`; /** Installer detail for a signed-app minimum REA cannot compare with this Mac's version. */ -export const macHopperIncomparableMinimumDetail = ( +const macHopperIncomparableMinimumDetail = ( declared: string, hostVersion: string, ): string => diff --git a/src/application/ReconstructionObligationCandidates.semantic.test.ts b/src/application/ReconstructionObligationCandidates.semantic.test.ts index 9b11b56f5f..36803859de 100644 --- a/src/application/ReconstructionObligationCandidates.semantic.test.ts +++ b/src/application/ReconstructionObligationCandidates.semantic.test.ts @@ -185,10 +185,20 @@ describe("semantic uncertainty at the obligation candidate boundary", () => { expect(ledger.obligations).toHaveLength(1); expect(ledger.reports.residual_unknown_ids).toEqual([]); expect(ledger.coverage.status).toBe("partial"); - expect(ledger.limitations).toEqual([ - `Semantic graph ${source.graph.graph_id} coverage is unknown; omitted candidates remain unknown.`, - `Semantic graph ${source.graph.graph_id}: Event source was not fully examined.`, - ]); + expect(ledger.limitations).toHaveLength(2); + expect(ledger.limitations).toContainEqual( + expect.stringMatching( + /coverage is unknown.*omitted candidates remain unknown/iu, + ), + ); + expect( + ledger.limitations.every((limitation) => + limitation.includes(source.graph.graph_id), + ), + ).toBe(true); + expect(ledger.limitations).toContainEqual( + expect.stringContaining("Event source was not fully examined."), + ); }); it("keeps a complete semantic source complete without fabricating uncertainty", () => { diff --git a/src/application/ReconstructionObligationCandidates.test.ts b/src/application/ReconstructionObligationCandidates.test.ts index 9051685d8b..041e884784 100644 --- a/src/application/ReconstructionObligationCandidates.test.ts +++ b/src/application/ReconstructionObligationCandidates.test.ts @@ -198,8 +198,11 @@ describe("reconstruction obligation candidates", () => { const generated = deriveCandidates(createEvidenceBundle([evidence]), []); expect(generated.candidates).toEqual([]); - expect(generated.limitations).toContain( - `Process capture ${evidence.evidence_id} has no validated executable artifact identity; packaged-process lifecycle obligation was not bound to an artifact.`, + const warning = generated.limitations.find((value) => + value.includes(evidence.evidence_id), + ); + expect(warning).toMatch( + /process capture.*no validated executable artifact identity.*lifecycle obligation.*not bound to an artifact/iu, ); }); @@ -229,9 +232,11 @@ describe("reconstruction obligation candidates", () => { }); const generated = deriveCandidates(createEvidenceBundle([evidence]), []); - expect(generated.limitations).toEqual([ - `Process Evidence ${evidence.evidence_id} could not generate candidates: at manifest.completed_at: Invalid input: expected string, received undefined (1 more issues)`, - ]); + expect(generated.limitations).toHaveLength(1); + expect(generated.limitations[0]).toContain(evidence.evidence_id); + expect(generated.limitations[0]).toMatch( + /Process Evidence.*could not generate candidates.*manifest\.completed_at/iu, + ); }); }); @@ -319,10 +324,22 @@ it("keeps each semantic candidate bound to its own file and authority context", createEvidenceBundle([...sourceRecords, application]), [], ); - expect(generated.limitations).toEqual([ - `Semantic graph ${semanticGraph.graph_id} coverage is unknown; omitted candidates remain unknown.`, - `Semantic graph ${semanticGraph.graph_id}: Semantic relations are unavailable in this minimal example.`, - ]); + expect(generated.limitations).toHaveLength(2); + expect(generated.limitations).toContainEqual( + expect.stringMatching( + /coverage is unknown.*omitted candidates remain unknown/iu, + ), + ); + expect( + generated.limitations.every((limitation) => + limitation.includes(semanticGraph.graph_id), + ), + ).toBe(true); + expect(generated.limitations).toContainEqual( + expect.stringContaining( + "Semantic relations are unavailable in this minimal example.", + ), + ); expect(generated.candidates).toHaveLength(requests.length); for (const [index, node] of requests.entries()) { const source = sourceRecords[Math.floor(index / 2)]; diff --git a/src/application/ReconstructionObligationLedger.closure.test.ts b/src/application/ReconstructionObligationLedger.closure.test.ts index fb788c06df..d06b20de14 100644 --- a/src/application/ReconstructionObligationLedger.closure.test.ts +++ b/src/application/ReconstructionObligationLedger.closure.test.ts @@ -304,8 +304,10 @@ describe("reconstruction obligation manifest references", () => { expect(result.status).toBe("unknown"); expect(result.coverage.status).toBe("partial"); expect(result.evidence_links).toContain(absent); - expect(result.limitations).toContain( - "The ledger retains 1 Evidence reference(s) absent from the input bundle; closure cannot be claimed.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /1 Evidence reference.*absent from the input bundle.*closure cannot be claimed/iu, + ), ); } const contradiction = build({ diff --git a/src/application/ReferenceSourceImportEntries.test.ts b/src/application/ReferenceSourceImportEntries.test.ts index 598c92d3d7..70dab2e31c 100644 --- a/src/application/ReferenceSourceImportEntries.test.ts +++ b/src/application/ReferenceSourceImportEntries.test.ts @@ -92,9 +92,10 @@ it("keeps source entries when a module has an empty specifier", () => { "other.txt", ]); expect(parsed.relationships).toEqual([]); - expect(parsed.limitations).toEqual([ - "Empty module specifier in main.mjs was not recorded as a relationship.", - ]); + expect(parsed.limitations).toHaveLength(1); + expect(parsed.limitations[0]).toContain("main.mjs"); + expect(parsed.limitations[0]).toMatch(/empty.*specifier/iu); + expect(parsed.limitations[0]).toMatch(/not recorded.*relationship/iu); }); it("retains Kotlin source identity and language across the inventory projection", () => { diff --git a/src/application/SetupInstallFailure.test.ts b/src/application/SetupInstallFailure.test.ts index afdd3494f9..bb5c89a4dd 100644 --- a/src/application/SetupInstallFailure.test.ts +++ b/src/application/SetupInstallFailure.test.ts @@ -5,20 +5,27 @@ import { setupInstallFailure } from "./SetupInstallFailure.js"; describe("macOS Hopper setup failure projection", () => { it("projects a publisher mismatch onto its own code and recovery", () => { - expect(setupInstallFailure("publisher_verification_failed")).toEqual({ + const result = setupInstallFailure("publisher_verification_failed"); + expect(result).toMatchObject({ status: "failed", code: "publisher_verification_failed", - remediation: `The mounted Hopper application is not signed by the publisher REA pins (${HOPPER_PUBLISHER.bundleIdentifier}, Apple team ${HOPPER_PUBLISHER.teamIdentifier}); nothing was copied or launched. Install Hopper yourself and set HOPPER_LAUNCHER_PATH to its launcher, then rerun rea setup.`, }); + for (const fact of [ + HOPPER_PUBLISHER.bundleIdentifier, + HOPPER_PUBLISHER.teamIdentifier, + "HOPPER_LAUNCHER_PATH", + "rea setup", + ]) + expect(result.remediation).toContain(fact); + expect(result.remediation).toMatch(/nothing was copied or launched/i); }); it("keeps a broken bundle distinct from a publisher mismatch", () => { - expect(setupInstallFailure("bundle")).toEqual({ - status: "failed", - code: "bundle_invalid", - remediation: - "The mounted package did not contain the expected Hopper application bundle. Retry setup; if it repeats, update REA before installing.", - }); + const result = setupInstallFailure("bundle"); + expect(result).toMatchObject({ status: "failed", code: "bundle_invalid" }); + expect(result.remediation).toMatch(/bundle/i); + expect(result.remediation).toMatch(/retry setup/i); + expect(result.remediation).toMatch(/update REA/i); }); it("keeps a host below the release minimum distinct from an unsupported host", () => { @@ -59,12 +66,14 @@ describe("Linux package-manager setup failure projection", () => { }); it("preserves the established remediation if no package output exists", () => { - expect(setupInstallFailure("authorization_or_package_manager")).toEqual({ + const result = setupInstallFailure("authorization_or_package_manager"); + expect(result).toMatchObject({ status: "failed", code: "authorization_or_package_manager_failed", - remediation: - "Allow the system package-manager operation or install the planned Hopper package and Linux runtime dependencies, then rerun rea setup.", }); + expect(result.remediation).toMatch(/package.manager/i); + expect(result.remediation).toMatch(/Linux runtime dependencies/i); + expect(result.remediation).toContain("rea setup"); }); it("keeps truncated package output within its excerpt budget", () => { diff --git a/src/application/UpdateRuntime.release.test.ts b/src/application/UpdateRuntime.release.test.ts index 8cdd7df702..5fbff4b05f 100644 --- a/src/application/UpdateRuntime.release.test.ts +++ b/src/application/UpdateRuntime.release.test.ts @@ -1,13 +1,13 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { err, ok } from "../domain/result.js"; +import { err, ok, type Result } from "../domain/result.js"; import { safeParseJson } from "../domain/safeJson.js"; import { PRODUCT_IDENTITY } from "../identity.js"; import { - npmLatestVersionCommand, - npmReleaseLookup, - parseNpmLatestVersion, -} from "./UpdateRuntime.js"; + UpdateCommandRunner, + type UpdateCommandFailure, +} from "./UpdateCommandRunner.js"; +import { systemUpdateHost } from "./UpdateRuntime.js"; const installation = { manager: "npm", @@ -15,14 +15,30 @@ const installation = { packageRoot: "/opt/rea/lib/node_modules/rea-agents", } as const; -describe("npm latest release metadata", () => { - it("accepts npm 11's scalar JSON response", () => { - expect(parseNpmLatestVersion('"6.0.0"')).toEqual(ok("6.0.0")); - }); +const lookup = async ( + response: Result, + selected: typeof installation | null = installation, +) => { + const probe = vi + .spyOn(UpdateCommandRunner.prototype, "runProbe") + .mockResolvedValue(response); + const host = systemUpdateHost(); + try { + const result = await host.latestVersion(selected ?? undefined); + return { result, command: probe.mock.calls[0]?.[0] }; + } finally { + await host.close?.(); + probe.mockRestore(); + } +}; - it("accepts npm 12's singleton-array JSON response", () => { - expect(parseNpmLatestVersion('[\n "6.0.0"\n]\n')).toEqual(ok("6.0.0")); - }); +describe("npm latest release metadata", () => { + it.each(['"6.0.0"', '[\n "6.0.0"\n]\n'])( + "accepts a scalar or singleton-array response: %j", + async (metadata) => { + expect((await lookup(ok(metadata))).result).toEqual(ok("6.0.0")); + }, + ); it.each([ ["empty array", "[]"], @@ -31,39 +47,52 @@ describe("npm latest release metadata", () => { ["number", "6"], ["object", '{"version":"6.0.0"}'], ["null", "null"], - ])("rejects %s response data", (_description, metadata) => { - expect(parseNpmLatestVersion(metadata)).toEqual( - err( - "Invalid npm release metadata: expected a version string or a single-element version array.", - ), + ])("rejects %s response data", async (_description, metadata) => { + const { result } = await lookup(ok(metadata)); + expect(result).toMatchObject({ + ok: false, + error: { kind: "invalid-metadata" }, + }); + if (result.ok || result.error.kind !== "invalid-metadata") + throw new Error("Expected invalid metadata"); + expect(result.error.detail).toMatch( + /version string.*single-element version array/i, ); }); - it("preserves malformed JSON separately from a wrong metadata shape", () => { + it("preserves malformed JSON separately from a wrong metadata shape", async () => { const metadata = '"6.0.0'; const decoded = safeParseJson(metadata); - expect(decoded.ok).toBe(false); - if (decoded.ok) return; - expect(parseNpmLatestVersion(metadata)).toEqual(err(decoded.error)); - expect(decoded.error).not.toContain("single-element version array"); + if (decoded.ok) throw new Error("Expected malformed JSON"); + expect((await lookup(ok(metadata))).result).toEqual( + err({ kind: "invalid-metadata", detail: decoded.error }), + ); }); - it("keeps command failures distinct from rejected metadata", () => { - expect(npmReleaseLookup(err("npm registry returned HTTP 503"))).toEqual( + it("keeps command failures distinct from rejected metadata", async () => { + expect( + ( + await lookup( + err({ kind: "execution", detail: "npm registry returned HTTP 503" }), + ) + ).result, + ).toEqual( err({ kind: "unavailable", detail: "npm registry returned HTTP 503" }), ); - expect(npmReleaseLookup(ok("[]"))).toEqual( + expect( + (await lookup(err({ kind: "launch", detail: "npm missing" }))).result, + ).toEqual( err({ - kind: "invalid-metadata", - detail: - "Invalid npm release metadata: expected a version string or a single-element version array.", + kind: "package-manager-unavailable", + manager: "npm", + detail: "npm missing", }), ); - expect(npmReleaseLookup(ok('["6.0.0"]'))).toEqual(ok("6.0.0")); }); - it("keeps the global registry query and adds the owning prefix", () => { - expect(npmLatestVersionCommand(installation)).toEqual([ + it("keeps the global registry query and adds the owning prefix", async () => { + const selected = await lookup(ok('"6.0.0"')); + expect(selected.command).toEqual([ "npm", "view", "--global", @@ -75,7 +104,8 @@ describe("npm latest release metadata", () => { "--fetch-retries=0", "--fetch-timeout=10000", ]); - expect(npmLatestVersionCommand(undefined)).toEqual([ + const unspecified = await lookup(ok('"6.0.0"'), null); + expect(unspecified.command).toEqual([ "npm", "view", "--global", diff --git a/src/application/UpdateRuntime.ts b/src/application/UpdateRuntime.ts index d2510c641b..4e04bed385 100644 --- a/src/application/UpdateRuntime.ts +++ b/src/application/UpdateRuntime.ts @@ -153,7 +153,7 @@ export const BUN_LATEST_VERSION_COMMAND: readonly string[] = [ ]; /** npm view arguments for dist-tags.latest, scoped to the owning prefix when known. */ -export const npmLatestVersionCommand = ( +const npmLatestVersionCommand = ( installation: NpmInstallation | undefined, ): readonly string[] => [ "npm", @@ -171,9 +171,7 @@ export const npmLatestVersionCommand = ( * Read one latest version from `npm view --json`. * npm 11 emits a JSON string. npm 12 emits that string as a one-element array. */ -export const parseNpmLatestVersion = ( - metadata: string, -): Result => { +const parseNpmLatestVersion = (metadata: string): Result => { const decoded = safeParseJson(metadata); if (!decoded.ok) return err(decoded.error); const version = readNpmLatestVersion(decoded.value); @@ -193,7 +191,7 @@ const nonemptyVersion = (value: string): string | undefined => value.length === 0 ? undefined : value; /** Classify `npm view` or `bun info` output as a version or a lookup failure. */ -export const npmReleaseLookup = ( +const npmReleaseLookup = ( response: Result, ): Result => { if (!response.ok) return err({ kind: "unavailable", detail: response.error }); diff --git a/src/application/WebNetworkCaptureService.test.ts b/src/application/WebNetworkCaptureService.test.ts index eef3156045..2b6b972f3d 100644 --- a/src/application/WebNetworkCaptureService.test.ts +++ b/src/application/WebNetworkCaptureService.test.ts @@ -142,6 +142,9 @@ it("projects the unknown-subject explanation before generating its Evidence ID", }); if (!result.ok) throw result.error; expect(result.value.subject).toBeNull(); + expect(result.value.limitations).toContainEqual( + expect.stringMatching(/digest.*unknown.*Evidence/u), + ); expect(result.value.limitations.every((text) => !text.includes("a"))).toBe( true, ); diff --git a/src/application/WebRuntimeService.test.ts b/src/application/WebRuntimeService.test.ts index 600b64aa7d..db2388de6d 100644 --- a/src/application/WebRuntimeService.test.ts +++ b/src/application/WebRuntimeService.test.ts @@ -17,7 +17,7 @@ import { expectDroppedPartial, expectKeptPartial, expectOwnerError, -} from "./ownerObservationExpectation.fixture.js"; +} from "../../tests/fixtures/ownerObservationExpectation.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { parseEvidence } from "../domain/evidence.js"; diff --git a/src/application/WebSourceLocationService.test.ts b/src/application/WebSourceLocationService.test.ts index 976d92da39..818d7f42ff 100644 --- a/src/application/WebSourceLocationService.test.ts +++ b/src/application/WebSourceLocationService.test.ts @@ -106,8 +106,7 @@ it.each([ path: ["generated_position", "line"], minimum: 1, maximum: 2, - message: - "No line 3; the retained script ends at line 2 (one-based lines).", + message: expect.stringMatching(/line 3.*line 2.*one-based/i), }, }, { @@ -116,8 +115,9 @@ it.each([ path: ["generated_position", "column"], minimum: 0, maximum: 3, - message: - "No column 4 on line 1; it ends at column 3 (zero-based UTF-16 columns).", + message: expect.stringMatching( + /column 4.*line 1.*column 3.*zero-based UTF-16/i, + ), }, }, { diff --git a/src/application/analysisView/BrowserScenarioViews.test.ts b/src/application/analysisView/BrowserScenarioViews.test.ts index 2ffc5397e0..38e524a3a0 100644 --- a/src/application/analysisView/BrowserScenarioViews.test.ts +++ b/src/application/analysisView/BrowserScenarioViews.test.ts @@ -262,8 +262,10 @@ describe("browser scenario analysis view pages", () => { }, }; const summary = await view(dropped, { kind: "summary" }); - expect(summary.unknowns).toContain( - "3 events were dropped by capture budgets; counts and pages cover retained events only.", + expect(summary.unknowns).toContainEqual( + expect.stringMatching( + /3 events.*dropped.*counts and pages.*retained events only/iu, + ), ); expect(summary).toMatchObject({ summary: { diff --git a/src/application/binary/BinarySessionStatus.test.ts b/src/application/binary/BinarySessionStatus.test.ts index cd6b17fc87..bc80f1281c 100644 --- a/src/application/binary/BinarySessionStatus.test.ts +++ b/src/application/binary/BinarySessionStatus.test.ts @@ -32,7 +32,6 @@ import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../../native/NativeMacosProvider import { buildCapabilityInventory, compactToolAvailability, - MULTI_MODE_REMEDIATION, } from "../CapabilityInventory.js"; const projectedStatusSchema = z.object({ @@ -583,7 +582,9 @@ it.each([ : ["inspect_native_dispatch_metadata", "list_names"], }); if (!route) { - expect(trace?.remediation).toBe(MULTI_MODE_REMEDIATION); + expect(trace?.remediation).toMatch( + /no mode.*inspect each mode.*missing operations and remediation/iu, + ); const authored = trace?.modes?.find( (mode) => mode.name === "authored_ui_dispatch", ); @@ -638,7 +639,9 @@ it("keeps distinct recovery for an unbound non-bundle session", async () => { expect(trace).toMatchObject({ available: false, reason: "provider_unavailable", - remediation: MULTI_MODE_REMEDIATION, + remediation: expect.stringMatching( + /no mode.*inspect each mode.*missing operations and remediation/iu, + ), }); const authored = trace?.modes?.find( (mode) => mode.name === "authored_ui_dispatch", diff --git a/src/application/desktop/DesktopApplicationExport.test.ts b/src/application/desktop/DesktopApplicationExport.test.ts index 4bcee29954..f7cca04023 100644 --- a/src/application/desktop/DesktopApplicationExport.test.ts +++ b/src/application/desktop/DesktopApplicationExport.test.ts @@ -296,9 +296,12 @@ describe("exportDesktopApplication and the desktop Trash", () => { "Documents/today.txt", ], }); - expect(result.limitations).toContain( - "The desktop Trash held 2 file(s) at the seed checkpoint, which are not seeded: ~/.local/share/Trash/files/today.txt, ~/.local/share/Trash/info/today.txt.trashinfo. Restore one the task needs and capture again.", + const warning = result.limitations.find((line) => + line.includes("Trash/files/today.txt"), ); + expect(warning).toContain("~/.local/share/Trash/info/today.txt.trashinfo"); + expect(warning).toMatch(/Trash.*2 file.*not seeded/iu); + expect(warning).toMatch(/restore.*capture again/iu); }); }); diff --git a/src/application/environment/environmentScenario.test.ts b/src/application/environment/environmentScenario.test.ts index 2debd00892..1624f439a1 100644 --- a/src/application/environment/environmentScenario.test.ts +++ b/src/application/environment/environmentScenario.test.ts @@ -44,8 +44,10 @@ describe("environment manifests from capture_browser_scenario parents", () => { expect( manifest.tools.some((entry) => entry.name.includes("redacted")), ).toBe(false); - expect(manifest.limitations).toContain( - "Scenario records are the capture's network transactions in request order; each record ordinal is the transaction's zero-based position.", + expect(manifest.limitations).toContainEqual( + expect.stringMatching( + /network transactions in request order.*ordinal.*zero-based position/iu, + ), ); }); diff --git a/src/application/javascript/ElectronObservationService.test.ts b/src/application/javascript/ElectronObservationService.test.ts index cfe606c9e4..6a6302ce58 100644 --- a/src/application/javascript/ElectronObservationService.test.ts +++ b/src/application/javascript/ElectronObservationService.test.ts @@ -23,7 +23,7 @@ import { expectDroppedPartial, expectKeptPartial, expectOwnerError, -} from "../ownerObservationExpectation.fixture.js"; +} from "../../../tests/fixtures/ownerObservationExpectation.js"; const identity = { id: "electron-fixture", name: "Fixture", version: "1" }; const pageProvider = (failure: AnalysisError): ElectronObservationPort => ({ @@ -236,6 +236,7 @@ it.each(["list", "inspect"] as const)( it("keeps cancellation when the retained Electron inspection is incomplete", async () => { const reason = { requested: "stop before page inspection completed" }; + // A JSON-safe but incomplete inspection cannot be converted into Evidence. const partial = { kind: "electron-page-observation", inspection: { target_id: "electron-page" }, diff --git a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts index 3aaac46202..d0a9021658 100644 --- a/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphElectronCoverage.test.ts @@ -131,8 +131,10 @@ describe("Electron semantic coverage", () => { `const { ipcMain } = require("electron");\nipcMain.handle("ping", () => 1);\n`, ); expect(limitations).not.toContain(EMPTY_COVERAGE); - expect(limitations).toContain( - "Electron relationships are derived from inert syntax; runtime registration, reachability, defaults, and enforcement remain unproven.", + expect(limitations).toContainEqual( + expect.stringMatching( + /inert syntax.*runtime registration.*reachability.*defaults.*enforcement.*unproven/iu, + ), ); expect(electronSemantics).toBe("recovered"); }); diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts index d172856c8b..e6be118839 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts @@ -20,7 +20,7 @@ import { expectOutputError, expectOwnerError, expectPreemptedCancellation, -} from "../ownerObservationExpectation.fixture.js"; +} from "../../../tests/fixtures/ownerObservationExpectation.js"; const observeCancelled = async (partial: unknown) => { const reason = { requested: "stop before runtime observation completed" }; diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index d9819c750b..1ff7f2932e 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -215,8 +215,8 @@ it("projects closure and direct interprocedural flow without execution", () => { "returns-to-call", ] as const) expect(relations.has(expected)).toBe(true); - expect(graph.limitations).toContain( - "The semantic graph contains static syntax observations and conservative relationship candidates; it does not claim runtime execution.", + expect(graph.limitations).toContainEqual( + expect.stringMatching(/static.*syntax.*does not claim runtime execution/iu), ); const parameter = graph.nodes.find( ({ kind, label }) => kind === "parameter" && label === "value", diff --git a/src/application/native/NativeFunctionMatchingService.test.ts b/src/application/native/NativeFunctionMatchingService.test.ts index f8be36848a..73c476cd52 100644 --- a/src/application/native/NativeFunctionMatchingService.test.ts +++ b/src/application/native/NativeFunctionMatchingService.test.ts @@ -114,8 +114,10 @@ it("keeps symbol origin unknown when supplied metadata describes a different sam const member = report.matches .flatMap((match) => match.left) .find((value) => value.address === "0x10"); - expect(member?.limitations).toContain( - "Symbol origin unavailable; exact-name matching is unavailable. Independent complete body bytes remain eligible.", + expect(member?.limitations).toContainEqual( + expect.stringMatching( + /symbol origin unavailable.*exact-name matching is unavailable.*complete body bytes remain eligible/iu, + ), ); expect(member?.evidence_links).toContain(alias.evidence_id); }); @@ -171,8 +173,10 @@ it("preserves contradictory symbol origins from inventory/name facts or multiple withBytes ? "identical-bytes" : null, ); const member = report.matches[0]!.left[0]!; - expect(member.limitations).toContain( - "Conflicting generated and named symbol-origin observations; exact-name matching is unavailable. Independent complete body bytes remain eligible.", + expect(member.limitations).toContainEqual( + expect.stringMatching( + /conflicting generated and named symbol-origin.*exact-name matching is unavailable.*complete body bytes remain eligible/iu, + ), ); expect(member.limitations.join(" ")).not.toContain( "Symbol origin unavailable", diff --git a/src/application/process/ProcessCli.ts b/src/application/process/ProcessCli.ts index cf7d35f0ee..8b62ab2eea 100644 --- a/src/application/process/ProcessCli.ts +++ b/src/application/process/ProcessCli.ts @@ -265,7 +265,7 @@ const cliAnalysisError = (error: AnalysisError): ProcessCliErrorOutput => ({ * This catch includes target execution, and the projection stays non-retryable. * Tell the user what happened; do not ask them to run the command again. */ -export const PROCESS_UNEXPECTED_FAILURE_REMEDIATION = +const PROCESS_UNEXPECTED_FAILURE_REMEDIATION = "Do not repeat this command. Tell the user the process command could not complete and ask them to check the input files, then run `rea doctor`."; /** Preserve typed failures and local diagnostics at the process CLI boundary. */ diff --git a/src/artifacts/ArtifactOperationFailure.test.ts b/src/artifacts/ArtifactOperationFailure.test.ts index 60440bdef5..f64b0f4c5c 100644 --- a/src/artifacts/ArtifactOperationFailure.test.ts +++ b/src/artifacts/ArtifactOperationFailure.test.ts @@ -43,5 +43,9 @@ it("keeps producer-owned recovery through cleanup wrapping and public projection expect(projected.message).toBe(userMessage); expect(projected.remediation.action).toBe(userMessage); expect(projected.details?.detail).toBe("Exact command diagnostic"); - expect(projected.details?.cleanup).toBeDefined(); + expect(projected.details).toMatchObject({ + cleanup: "incomplete", + cleanup_reason: "Owned staging cleanup needs retry", + resources: ["owned staging"], + }); }); diff --git a/src/artifacts/ArtifactPaths.test.ts b/src/artifacts/ArtifactPaths.test.ts index 6a203ee234..82a481c476 100644 --- a/src/artifacts/ArtifactPaths.test.ts +++ b/src/artifacts/ArtifactPaths.test.ts @@ -9,9 +9,6 @@ import { normalizeArtifactPath, } from "./ArtifactPaths.js"; -const NOTE = - "a case-insensitive destination cannot store both. This comparison is not a filesystem observation."; - it.each(["drive", "escaped", "normalization", "prefix", "collision"])( "bounds path failure diagnostics at the owning boundary: %s", (kind) => { @@ -66,6 +63,18 @@ const annotate = (paths: readonly string[]) => { }; }; +const expectCollision = ( + limitations: string[] | undefined, + ...facts: string[] +) => { + expect(limitations).toHaveLength(1); + const warning = limitations?.[0] ?? ""; + for (const fact of facts) expect(warning).toContain(fact); + expect(warning).toMatch(/collid.*English.*case folding/iu); + expect(warning).toMatch(/case.insensitive destination.*cannot store both/iu); + expect(warning).toMatch(/not a filesystem observation/iu); +}; + describe("English Unicode case collisions", () => { it("retains repeated same-kind entries while rejecting file-directory aliasing", () => { const registry = new ArtifactPathRegistry(); @@ -79,12 +88,8 @@ describe("English Unicode case collisions", () => { it("names the other spelling of a colliding file", () => { const { limitation, byPath } = annotate([".", "Main.js", "main.js", "x"]); expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION); - expect(byPath.get("Main.js")).toEqual([ - `Logical path Main.js collides under English (en-US) Unicode case folding with main.js; ${NOTE}`, - ]); - expect(byPath.get("main.js")).toEqual([ - `Logical path main.js collides under English (en-US) Unicode case folding with Main.js; ${NOTE}`, - ]); + expectCollision(byPath.get("Main.js"), "Main.js", "main.js"); + expectCollision(byPath.get("main.js"), "main.js", "Main.js"); expect(byPath.get("x")).toEqual([]); }); @@ -95,19 +100,18 @@ describe("English Unicode case collisions", () => { "res/foo", "res/foo/b.txt", ]); - expect(byPath.get("res/Foo")).toEqual([ - `Logical path res/Foo collides under English (en-US) Unicode case folding with res/foo; ${NOTE}`, - ]); - expect(byPath.get("res/foo/b.txt")).toEqual([ - `Logical path res/foo/b.txt is under res/foo, which collides under English (en-US) Unicode case folding with res/Foo; ${NOTE}`, - ]); + expectCollision(byPath.get("res/Foo"), "res/Foo", "res/foo"); + expectCollision( + byPath.get("res/foo/b.txt"), + "res/foo/b.txt", + "under res/foo", + "res/Foo", + ); }); it("counts further spellings, each of which is its own occurrence", () => { const { byPath } = annotate(["A.txt", "a.txt", "a.TXT"]); - expect(byPath.get("a.txt")).toEqual([ - `Logical path a.txt collides under English (en-US) Unicode case folding with A.txt and 1 other spelling; ${NOTE}`, - ]); + expectCollision(byPath.get("a.txt"), "a.txt", "A.txt", "1 other spelling"); }); it("retains every spelling in a high-cardinality folded sibling group", () => { @@ -123,9 +127,12 @@ describe("English Unicode case collisions", () => { } expect(limitation).toBe(ENGLISH_UNICODE_CASE_INVENTORY_LIMITATION); expect(byPath.size).toBe(count); - expect(byPath.get(first)).toEqual([ - `Logical path ${first} collides under English (en-US) Unicode case folding with ${second} and ${String(count - 2)} other spellings; ${NOTE}`, - ]); + expectCollision( + byPath.get(first), + first, + second, + `${String(count - 2)} other spellings`, + ); expect(byPath.get(original)?.[0]).toContain( `with ${first} and ${String(count - 2)} other spellings;`, ); diff --git a/src/artifacts/ArtifactResourceScope.test.ts b/src/artifacts/ArtifactResourceScope.test.ts index 37c6efb6f2..821c91cafb 100644 --- a/src/artifacts/ArtifactResourceScope.test.ts +++ b/src/artifacts/ArtifactResourceScope.test.ts @@ -42,7 +42,7 @@ it("waits for admitted operations and shares concurrent close", async () => { expect(scope.close()).toBe(close); await expect(scope.run(async () => undefined)).rejects.toMatchObject({ reason: "unavailable", - message: "Artifact resource scope is closed", + message: expect.stringContaining("closed"), }); let closed = false; void close.then(() => { diff --git a/src/artifacts/ArtifactSourceResolution.test.ts b/src/artifacts/ArtifactSourceResolution.test.ts index abd68f45aa..f124893f63 100644 --- a/src/artifacts/ArtifactSourceResolution.test.ts +++ b/src/artifacts/ArtifactSourceResolution.test.ts @@ -168,8 +168,11 @@ it.skipIf(process.platform === "win32")( code: "EACCES", }); - await expect( - readStableArtifact(selected, 1024, undefined, { + const rejected: unknown = await readStableArtifact( + selected, + 1024, + undefined, + { lstat: async (path) => { if (path === selected) return selectedStats; throw failure; @@ -178,11 +181,11 @@ it.skipIf(process.platform === "win32")( open: async () => { throw new Error("A rejected symlink must not be opened"); }, - }), - ).rejects.toMatchObject({ - reason: "path", - resolvedPath: undefined, - message: `Expected a regular file without a symlink: ${selected}`, - }); + }, + ).catch((cause: unknown) => cause); + expect(rejected).toMatchObject({ reason: "path", resolvedPath: undefined }); + if (!(rejected instanceof Error)) throw new Error("Expected path failure"); + expect(rejected.message).toContain(selected); + expect(rejected.message).toContain("symlink"); }, ); diff --git a/src/artifacts/AsarHeader.test.ts b/src/artifacts/AsarHeader.test.ts index 1bfaea23d0..1f6ae354e6 100644 --- a/src/artifacts/AsarHeader.test.ts +++ b/src/artifacts/AsarHeader.test.ts @@ -114,6 +114,6 @@ it("decodes admitted JSON across short reads without consuming padding", async ( ), ).rejects.toMatchObject({ reason: "format", - message: "Truncated ASAR JSON header", + message: expect.stringMatching(/truncated.*header/iu), }); }); diff --git a/src/artifacts/ChangeTimeBaseline.test.ts b/src/artifacts/ChangeTimeBaseline.test.ts index 2c6948fb20..49e5baf624 100644 --- a/src/artifacts/ChangeTimeBaseline.test.ts +++ b/src/artifacts/ChangeTimeBaseline.test.ts @@ -2,10 +2,7 @@ import type { Stats } from "node:fs"; import { expect, it } from "vitest"; -import { - changeTimeMayStillDrift, - settleChangeTimeBaseline, -} from "../filesystem/ChangeTimeBaseline.js"; +import { settleChangeTimeBaseline } from "../filesystem/ChangeTimeBaseline.js"; const DRIFT_MS = 2_000; @@ -85,13 +82,20 @@ it("exits on the window closing rather than on a repeated value", async () => { expect(seams.sleeps.length).toBe(2); }); -it("settles an unreadable ChangeTime rather than trusting it", () => { - expect( - changeTimeMayStillDrift(statsAt(Number.NaN), { platform: "win32" }), - ).toBe(true); - expect( - changeTimeMayStillDrift(statsAt(Number.NaN), { platform: "linux" }), - ).toBe(false); +it("re-observes an unreadable Windows ChangeTime but does not wait on other hosts", async () => { + const windows = scriptedRead([Number.NaN, 1_000]); + const seams = advancingSeams(5_000, 100); + const settled = await settleChangeTimeBaseline(windows.read, seams); + expect(settled.ctimeMs).toBe(1_000); + expect(windows.reads()).toBe(2); + expect(seams.sleeps).toHaveLength(1); + + const linux = scriptedRead([Number.NaN, 1_000]); + const unreadable = await settleChangeTimeBaseline(linux.read, { + platform: "linux", + }); + expect(unreadable.ctimeMs).toBeNaN(); + expect(linux.reads()).toBe(1); }); it("gives up settling instead of stalling admission forever", async () => { diff --git a/src/artifacts/DfuSeArtifactReader.test.ts b/src/artifacts/DfuSeArtifactReader.test.ts index 6240e2c31a..92cb624b74 100644 --- a/src/artifacts/DfuSeArtifactReader.test.ts +++ b/src/artifacts/DfuSeArtifactReader.test.ts @@ -152,8 +152,8 @@ it("refuses a suffix CRC mismatch under the default integrity policy", async () trust: "observed-untrusted", }), ]); - expect(recorded.limitations).toContain( - "1 integrity contradiction(s) were recorded; mismatched content is observed-untrusted.", + expect(recorded.limitations).toContainEqual( + expect.stringMatching(/1.*integrity.*observed-untrusted/iu), ); expect(recorded.occurrences).toHaveLength(4); }); @@ -184,8 +184,8 @@ it("extracts elements and carries a root contradiction into the extraction", asy expect(extracted.integrity_contradictions).toEqual([ expect.objectContaining({ logical_path: ".", algorithm: "dfu-crc32" }), ]); - expect(extracted.limitations).toContain( - "The container contradicts its declared integrity metadata; every extracted file is observed-untrusted.", + expect(extracted.limitations).toContainEqual( + expect.stringMatching(/container.*integrity.*observed-untrusted/iu), ); expect(extracted.limitations.join("\n")).not.toContain( "extracted file(s) contradict", diff --git a/src/artifacts/EspFlashImageArtifactReader.test.ts b/src/artifacts/EspFlashImageArtifactReader.test.ts index 87b6778446..0bbb0b50af 100644 --- a/src/artifacts/EspFlashImageArtifactReader.test.ts +++ b/src/artifacts/EspFlashImageArtifactReader.test.ts @@ -169,8 +169,8 @@ describe("ESP flash layout inventory", () => { expect(root(snapshot)).toMatchObject({ partition_table: { flash_address: 0xa000, method: "scan" }, }); - expect(snapshot.limitations).toContain( - "The partition table at 0xa000 was found by scanning 0x1000-aligned flash sectors for a table whose MD5 verifies; the dump does not record the bootloader's configured table offset.", + expect(snapshot.limitations).toContainEqual( + expect.stringMatching(/0xa000.*0x1000.*MD5.*does not record/iu), ); }); }); @@ -254,11 +254,18 @@ describe("ESP flash layout coverage and selection", () => { facts: { status: "erased" }, }); // Only the erased app slot holds bytes the bootloader does not read. - expect( - snapshot.limitations.filter((text) => text.includes("finds no app")), - ).toEqual([ - `partitions/ota_1.bin begins with erased flash, so ESP-IDF's bootloader finds no app in it, but ${String(indices.length)} of its bytes from offset 0x${start.toString(16)} to 0x${end.toString(16)} are not erased flash; they are not read as an image.`, - ]); + const erasedSlotWarnings = snapshot.limitations.filter((text) => + /bootloader.*no app/iu.test(text), + ); + expect(erasedSlotWarnings).toHaveLength(1); + const warning = erasedSlotWarnings[0] ?? ""; + expect(warning).toContain("partitions/ota_1.bin"); + expect(warning).toContain(String(indices.length)); + expect(warning).toContain(`0x${start.toString(16)}`); + expect(warning).toContain(`0x${end.toString(16)}`); + expect(warning).toMatch( + /erased flash.*bootloader.*no app.*not erased.*not read as an image/iu, + ); // A partition erased throughout has no written bytes. expect(row("partitions/nvs.bin")).toMatchObject({ written_offset: null, diff --git a/src/artifacts/MachOSliceArtifactReader.test.ts b/src/artifacts/MachOSliceArtifactReader.test.ts index 036ff4dc1b..6c8caf5945 100644 --- a/src/artifacts/MachOSliceArtifactReader.test.ts +++ b/src/artifacts/MachOSliceArtifactReader.test.ts @@ -324,8 +324,9 @@ describe("MachOSliceArtifactReader", () => { reader.entries()[Symbol.asyncIterator]().next(), ).rejects.toMatchObject({ reason: "format", - message: - "lipo did not produce readable architecture records: lipo output contained no architectures", + message: expect.stringMatching( + /lipo.*readable architecture records.*no architectures/i, + ), cause: new TypeError("lipo output contained no architectures"), }); }); diff --git a/src/artifacts/NativeDmgArtifactReader.ts b/src/artifacts/NativeDmgArtifactReader.ts index b5b5f931ef..90a1d01305 100644 --- a/src/artifacts/NativeDmgArtifactReader.ts +++ b/src/artifacts/NativeDmgArtifactReader.ts @@ -32,7 +32,7 @@ import { } from "./NativeDmgOwnership.js"; const HDIUTIL_COMMAND_TIMEOUT_MS = 120_000; /** Conditional recovery guidance; a path match never grants attachment ownership. */ -export const DMG_EXISTING_ATTACHMENT_RECOVERY = +const DMG_EXISTING_ATTACHMENT_RECOVERY = "hdiutil refused a private read-only DMG attachment after listing an existing attachment of this image path. If it is still attached, ask its owner to release it when safe, then retry. REA does not borrow or detach existing attachments."; const DMG_CHECKSUM_ABSENT_LIMITATION = "hdiutil verify found no stored checksum in this disk image, so its integrity was not verified; the root SHA-256 identifies the inventoried bytes."; diff --git a/src/artifacts/NativeDmgProtocol.test.ts b/src/artifacts/NativeDmgProtocol.test.ts index e57132a987..f7bbb9e1e5 100644 --- a/src/artifacts/NativeDmgProtocol.test.ts +++ b/src/artifacts/NativeDmgProtocol.test.ts @@ -116,16 +116,15 @@ it("retains malformed successful hdiutil inventory output as a typed producer pa expect(calls).toEqual(["verify", "info"]); expect(failure).toMatchObject({ reason: "format", - message: "hdiutil info -plist returned malformed image inventory", + message: expect.stringMatching(/hdiutil.*info.*inventory/iu), cause: { name: "ZodError" }, }); + if (!(failure instanceof Error)) + throw new Error("Expected inventory failure"); expect( projectAnalysisError(translateArtifactFailure("inspect_artifact", failure)), ).toMatchObject({ - details: { - reason: "format", - detail: "hdiutil info -plist returned malformed image inventory", - }, + details: { reason: "format", detail: failure.message }, }); }); diff --git a/src/artifacts/NativeDmgRootObservation.test.ts b/src/artifacts/NativeDmgRootObservation.test.ts index 37bed96dc7..17bdf00a2c 100644 --- a/src/artifacts/NativeDmgRootObservation.test.ts +++ b/src/artifacts/NativeDmgRootObservation.test.ts @@ -7,13 +7,10 @@ import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory import { scanCanonicalArtifactInventory } from "../../tests/fixtures/artifactInventory.js"; import { NativeDmgArtifactReader, - DMG_EXISTING_ATTACHMENT_RECOVERY, type NativeDmgHost, } from "./NativeDmgArtifactReader.js"; import { translateArtifactFailure } from "./ArtifactOperationFailure.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { analysisErrorUserMessage } from "../domain/analysisErrorPresentation.js"; -import { ArtifactOperationError } from "../domain/artifactOperationError.js"; it.each(["same", "other", "missing-path", "no-path", "empty-group"] as const)( "retains DMG root facts and limits existing-attachment guidance to a resolved image path: %s", @@ -78,7 +75,6 @@ it.each(["same", "other", "missing-path", "no-path", "empty-group"] as const)( } catch (cause: unknown) { failure = cause; } - expect(failure).toBeDefined(); const projected = projectAnalysisError( translateArtifactFailure("inspect_artifact", failure), ); @@ -109,16 +105,15 @@ it.each(["same", "other", "missing-path", "no-path", "empty-group"] as const)( expect.objectContaining({ kind: "artifact-inventory" }), ); if (listed === "same") { - expect(projected.message).toBe(DMG_EXISTING_ATTACHMENT_RECOVERY); - expect(projected.remediation.action).toBe( - DMG_EXISTING_ATTACHMENT_RECOVERY, + expect(projected.message).toMatch( + /hdiutil.*existing attachment.*owner.*release.*retry.*does not borrow or detach/iu, ); + expect(projected.remediation.action).toBe(projected.message); } else { - expect(projected.message).toBe( - analysisErrorUserMessage( - new ArtifactOperationError("inspect_artifact", "unavailable"), - ), + expect(projected.message).toMatch( + /unavailable.*availability reason.*before retrying/iu, ); + expect(projected.message).not.toMatch(/existing attachment|detach/iu); } expect(calls).not.toContain("detach"); }, diff --git a/src/artifacts/NodeSeaArtifactReader.test.ts b/src/artifacts/NodeSeaArtifactReader.test.ts index b084e084b3..ae8987b89d 100644 --- a/src/artifacts/NodeSeaArtifactReader.test.ts +++ b/src/artifacts/NodeSeaArtifactReader.test.ts @@ -12,19 +12,10 @@ import { expect, it, vi } from "vitest"; import * as artifactPaths from "./ArtifactPaths.js"; import { NodeSeaArtifactReader } from "./NodeSeaArtifactReader.js"; +import { artifactFormatDescriptor } from "./artifactFormats.js"; import { ArtifactResourceScope } from "./ArtifactResourceScope.js"; import { extractArtifact } from "./extraction/ArtifactExtraction.js"; -import { - NODE_SEA_BAD_MAGIC, - NODE_SEA_DUPLICATE_BLOB, - NODE_SEA_ELF_NOTES_REPEATED, - NODE_SEA_FUSE_CONFLICT, - NODE_SEA_FUSE_ABSENT, - NODE_SEA_FUSE_UNSET, - NODE_SEA_MAGIC, - NODE_SEA_TRUNCATED, - NODE_SEA_UNKNOWN_FLAGS, -} from "./nodeSea/NodeSeaBlob.js"; +import { NODE_SEA_MAGIC } from "./nodeSea/NodeSeaBlob.js"; import { NODE_SEA_FUSE_HASH, buildNodeSeaBlob, @@ -68,10 +59,14 @@ it.each([ const snapshot = await scanCanonicalArtifactInventory(path); expect(snapshot.manifest.root_format).toBe("node-sea"); expect(snapshot.coverage.status).toBe("complete"); - expect(snapshot.limitations).toContain( - "Node SEA inventory establishes the injected blob, flags, fuse sentinel, main script, optional code cache, snapshot, and assets. V8 snapshots and code cache are not deserialized, and the application is not executed.", + const limitation = artifactFormatDescriptor("node-sea")?.readerLimitation; + if (limitation === undefined) throw new Error("Missing Node SEA limitation"); + expect(snapshot.limitations).toContain(limitation); + expect(limitation).toMatch(/snapshots.*code cache.*not deserialized/iu); + expect(limitation).toMatch(/application.*not executed/iu); + expect(snapshot.limitations).not.toContainEqual( + expect.stringMatching(/NODE_SEA_FUSE sentinel.*unset/iu), ); - expect(snapshot.limitations).not.toContain(NODE_SEA_FUSE_UNSET); const root = snapshot.occurrences.find((row) => row.logical_path === "."); expect(root).toMatchObject({ artifact_kind: "container" }); expect(root?.format_metadata).toMatchObject({ @@ -221,22 +216,26 @@ it.each([ "reports a %s fuse as a contradiction", async (_label, fuse, state, sentinel) => { const reason = - state === "absent" ? NODE_SEA_FUSE_ABSENT : NODE_SEA_FUSE_UNSET; + state === "absent" + ? /no NODE_SEA_FUSE sentinel.*observed/iu + : /NODE_SEA_FUSE sentinel.*unset/iu; const snapshot = await scanCanonicalArtifactInventory( await writeCarrier( "fuse", nodeSeaElf({ ...(fuse === undefined ? {} : { fuse }), main: script }), ), ); - expect( - snapshot.occurrences.find((row) => row.logical_path === ".") - ?.format_metadata, - ).toMatchObject({ + const metadata = snapshot.occurrences.find( + (row) => row.logical_path === ".", + )?.format_metadata; + expect(metadata).toMatchObject({ fuse_state: state, fuse_sentinel: sentinel, - fuse_contradiction: reason, + fuse_contradiction: expect.stringMatching(reason), }); - expect(snapshot.limitations).toContain(reason); + const contradiction = (metadata as { fuse_contradiction: string }) + .fuse_contradiction; + expect(snapshot.limitations).toContain(contradiction); expect(snapshot.coverage.status).toBe("complete"); }, ); @@ -245,15 +244,17 @@ it("reports disagreeing fuse sentinels", async () => { const snapshot = await scanCanonicalArtifactInventory( await writeCarrier("conflict", nodeSeaPe({ fuse: "conflicting" })), ); - expect( - snapshot.occurrences.find((row) => row.logical_path === ".") - ?.format_metadata, - ).toMatchObject({ + const metadata = snapshot.occurrences.find( + (row) => row.logical_path === ".", + )?.format_metadata; + expect(metadata).toMatchObject({ fuse_state: "conflicting", fuse_sentinel: null, - fuse_contradiction: NODE_SEA_FUSE_CONFLICT, + fuse_contradiction: expect.stringMatching(/fuse sentinels disagree/iu), }); - expect(snapshot.limitations).toContain(NODE_SEA_FUSE_CONFLICT); + const contradiction = (metadata as { fuse_contradiction: string }) + .fuse_contradiction; + expect(snapshot.limitations).toContain(contradiction); }); it("leaves a Mach-O, ELF, or PE without a SEA slot in its native format", async () => { @@ -354,12 +355,18 @@ it("fails closed when the located blob is truncated, has a bad magic, or unknown nodeSeaElf({ blob: Buffer.from([0x20, 0xda, 0x43]) }), ), ), - ).rejects.toMatchObject({ reason: "format", message: NODE_SEA_TRUNCATED }); + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/Node SEA.*truncated/iu), + }); await expect( scanCanonicalArtifactInventory( await writeCarrier("bad-magic", nodeSeaMachO({ blob: Buffer.alloc(16) })), ), - ).rejects.toMatchObject({ reason: "format", message: NODE_SEA_BAD_MAGIC }); + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/magic.*not.*0x143da20/iu), + }); const unknown = Buffer.alloc(9); unknown.writeUInt32LE(NODE_SEA_MAGIC, 0); unknown.writeUInt32LE(1 << 5, 4); @@ -370,7 +377,7 @@ it("fails closed when the located blob is truncated, has a bad magic, or unknown ), ).rejects.toMatchObject({ reason: "format", - message: NODE_SEA_UNKNOWN_FLAGS, + message: expect.stringMatching(/flags.*unknown bits/iu), }); }); @@ -386,7 +393,7 @@ it("fails closed when two different blobs are declared", async () => { ), ).rejects.toMatchObject({ reason: "format", - message: NODE_SEA_DUPLICATE_BLOB, + message: expect.stringMatching(/Node SEA blob.*declared more than once/iu), }); await expect( scanCanonicalArtifactInventory( @@ -397,7 +404,10 @@ it("fails closed when two different blobs are declared", async () => { }), ), ), - ).rejects.toMatchObject({ message: NODE_SEA_DUPLICATE_BLOB }); + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/Node SEA blob.*declared more than once/iu), + }); }); it("keeps one blob when the same bytes are declared twice", async () => { @@ -473,7 +483,9 @@ it("reads repeated PT_NOTE declarations at most once per note the file holds", a ), ).rejects.toMatchObject({ reason: "format", - message: NODE_SEA_ELF_NOTES_REPEATED, + message: expect.stringMatching( + /ELF note tables.*more notes than the file holds/iu, + ), }); }); diff --git a/src/artifacts/ZipArtifactReader.test.ts b/src/artifacts/ZipArtifactReader.test.ts index f629a0a390..523edb25ad 100644 --- a/src/artifacts/ZipArtifactReader.test.ts +++ b/src/artifacts/ZipArtifactReader.test.ts @@ -579,7 +579,7 @@ it("refuses to open ZIP symlink entries even when unixMode is absent", async () throw new Error(`Missing ZIP symlink ${logicalPath}`); await expect(reader.open(entry)).rejects.toMatchObject({ reason: "format", - message: "ZIP entry is not a regular file", + message: expect.stringContaining("regular file"), }); } } finally { @@ -839,7 +839,7 @@ it("joins an admitted ZIP enumeration before reader close completes", async () = release.resolve(); await expect(next).rejects.toMatchObject({ reason: "unavailable", - message: "ZIP reader is closed", + message: expect.stringContaining("closed"), }); await closing; const completedReads = reads.mock.calls.length; diff --git a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts index 2069fd18e2..8e64174828 100644 --- a/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogAnalysis.test.ts @@ -146,8 +146,10 @@ describe("Apple asset catalog application workflow", () => { next_offset: null, truncated: false, }); - expect(result.limitations).toContain( - "No compiled Assets.car catalog was found among the app bundle's regular files (symlinks are not followed); any resource keys are reported as unmatched.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /Assets\.car.*symlinks.*not followed.*unmatched/iu, + ), ); }, ); @@ -224,8 +226,10 @@ it.skipIf(process.platform === "win32")( throw new Error("No catalog is present"); }, }); - expect(result.limitations).toContain( - "Interface Builder resource-key joins may be incomplete because the decoded object inventory is incomplete.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /resource-key joins.*incomplete.*object inventory.*incomplete/iu, + ), ); }, ); diff --git a/src/artifacts/apple/AppleAssetCatalogResourceCoverage.test.ts b/src/artifacts/apple/AppleAssetCatalogResourceCoverage.test.ts index e0c61395d5..c6cd31a09a 100644 --- a/src/artifacts/apple/AppleAssetCatalogResourceCoverage.test.ts +++ b/src/artifacts/apple/AppleAssetCatalogResourceCoverage.test.ts @@ -48,20 +48,24 @@ for (const symlinks of [0, 1, 65]) examined: 2, omitted: 1 + symlinks, }); - expect(report.limitations).toContain( - "1 Interface Builder archive(s) omitted because unsupported uid exact range.", + expect(report.limitations).toContainEqual( + expect.stringMatching(/1.*omitted.*unsupported uid exact range/iu), ); if (symlinks > 0) - expect(report.limitations).toContain( - `${symlinks} Interface Builder archive(s) omitted because symlink not followed.`, + expect(report.limitations).toContainEqual( + expect.stringMatching( + new RegExp(`${symlinks}.*omitted.*symlink not followed`, "iu"), + ), ); if (symlinks < 64) - expect(report.limitations).toContain( - "Unsupported.nib: omitted because unsupported uid exact range.", + expect(report.limitations).toContainEqual( + expect.stringMatching( + /Unsupported\.nib.*omitted.*unsupported uid exact range/iu, + ), ); else - expect(report.limitations).toContain( - "2 additional omitted archive path(s) and their reasons were not retained; reporting is bounded to 64 paths.", + expect(report.limitations).toContainEqual( + expect.stringMatching(/2.*omitted.*not retained.*64 paths/iu), ); const result = await analyzeAppleAssetCatalogs({ bundlePath: bundle, diff --git a/src/artifacts/apple/KeyedArchiveReader.test.ts b/src/artifacts/apple/KeyedArchiveReader.test.ts index ec90701fe7..69f3e124e3 100644 --- a/src/artifacts/apple/KeyedArchiveReader.test.ts +++ b/src/artifacts/apple/KeyedArchiveReader.test.ts @@ -154,11 +154,7 @@ describe("keyed archive scalar projection", () => { offset: 0, limit: 2, }), - ).toThrow( - expect.objectContaining({ - message: "binary plist date object is truncated", - }), - ); + ).toThrow(/date.*truncated/iu); }); it("keeps overlapping date precision unknown without changing its sibling object", () => { @@ -628,6 +624,10 @@ describe("binary keyed archive dates", () => { Buffer.compare(await readFile(join(root, "archive.plist")), bytes), ).toBe(0); }); +}); + +describe("binary keyed archive invalid dates", () => { + const selection = { offset: 0, limit: 10 }; it.each( [0x30, 0x31, 0x32, 0x34].map( @@ -640,11 +640,17 @@ describe("binary keyed archive dates", () => { copy[offset] = marker; }); const original = Buffer.from(bytes); - expect(() => decodeKeyedArchiveBytes(bytes, selection)).toThrow( - new TypeError( - `binary plist date marker 0x${marker.toString(16)} is invalid`, - ), - ); + let decodeFailure: unknown; + try { + decodeKeyedArchiveBytes(bytes, selection); + } catch (cause: unknown) { + decodeFailure = cause; + } + expect(decodeFailure).toBeInstanceOf(TypeError); + if (!(decodeFailure instanceof Error)) + throw new Error("Expected invalid date marker to fail"); + expect(decodeFailure.message).toContain(`0x${marker.toString(16)}`); + expect(decodeFailure.message).toMatch(/date marker.*invalid/iu); expect(Buffer.compare(bytes, original)).toBe(0); if (marker === 0x30) expect(createHash("sha256").update(bytes).digest("hex")).toBe( @@ -662,7 +668,7 @@ describe("binary keyed archive dates", () => { ).rejects.toMatchObject({ name: "ArtifactReaderFailure", reason: "format", - message: `Cannot decode selected Foundation keyed archive: binary plist date marker 0x${marker.toString(16)} is invalid`, + message: expect.stringContaining(decodeFailure.message), }); expect(Buffer.compare(await readFile(path), bytes)).toBe(0); }, @@ -817,16 +823,16 @@ describe("binary plist date object region", () => { bytes.writeBigUInt64BE(BigInt(offsetTable), trailer + 24); expect(() => estimateBinaryPlistExpansion(bytes, 1024 * 1024, { bounds: "strict" }), - ).toThrow(new TypeError("binary plist date object is truncated")); + ).toThrow(/date.*truncated/iu); // Decoder bounds extend other objects to the end of the file. The date // payload still has to end at the offset table. The keyed-archive reader // uses those bounds and must refuse the same payload. expect(() => estimateBinaryPlistExpansion(bytes, 1024 * 1024, { bounds: "decoder" }), - ).toThrow(new TypeError("binary plist date object is truncated")); + ).toThrow(/date.*truncated/iu); expect(() => decodeKeyedArchiveBytes(bytes, { offset: 0, limit: 1 }), - ).toThrow(new TypeError("binary plist date object is truncated")); + ).toThrow(/date.*truncated/iu); }); it("refuses a decoder-bounds date whose marker sits past the offset table", () => { @@ -845,10 +851,10 @@ describe("binary plist date object region", () => { bytes.writeBigUInt64BE(8n, trailer + 24); expect(() => estimateBinaryPlistExpansion(bytes, 1024 * 1024, { bounds: "decoder" }), - ).toThrow(new TypeError("binary plist date object is truncated")); + ).toThrow(/date.*truncated/iu); expect(() => decodeKeyedArchiveBytes(bytes, { offset: 0, limit: 1 }), - ).toThrow(new TypeError("binary plist date object is truncated")); + ).toThrow(/date.*truncated/iu); }); }); diff --git a/src/artifacts/fit/FitStructure.test.ts b/src/artifacts/fit/FitStructure.test.ts index e3cb20646f..9024f3b111 100644 --- a/src/artifacts/fit/FitStructure.test.ts +++ b/src/artifacts/fit/FitStructure.test.ts @@ -176,10 +176,13 @@ describe("FIT image facts", () => { }), ).images; expect(image?.metadata).toMatchObject({ load: null, arch: null }); - expect(image?.limitations).toEqual([ - "/images/kernel: arch is not a NUL-terminated UTF-8 string", - "/images/kernel: load is not a 1- or 2-cell address", - ]); + expect(image?.limitations).toHaveLength(2); + expect(image?.limitations).toEqual( + expect.arrayContaining([ + expect.stringMatching(/\/images\/kernel.*arch.*NUL.*UTF-8/iu), + expect.stringMatching(/\/images\/kernel.*load.*1- or 2-cell/iu), + ]), + ); }); }); @@ -275,8 +278,9 @@ describe("FIT image data ranges", () => { ), ).toMatchObject({ reason: "format", - message: - "FIT is malformed: /images/chained: image-data target /images/shared has its own image-data", + message: expect.stringMatching( + /\/images\/chained.*image-data.*\/images\/shared/iu, + ), }); }); @@ -293,8 +297,9 @@ describe("FIT image data ranges", () => { ), ).toMatchObject({ reason: "format", - message: - "FIT is malformed: image data overlaps: /images/shared and the FIT devicetree", + message: expect.stringMatching( + /overlaps.*\/images\/shared.*devicetree/iu, + ), }); }); @@ -325,7 +330,7 @@ describe("FIT image data ranges", () => { ])("fails %s", (_name, file, message) => { expect(failureOf(file)).toMatchObject({ reason: "format", - message: `FIT is malformed: ${message}`, + message: expect.stringContaining(message), }); }); }); @@ -395,8 +400,9 @@ describe("FIT hashes", () => { ), ).toMatchObject({ reason: "integrity", - message: - "FIT image kernel hash-1 sha256 value is 31 bytes; sha256 values are 32 bytes", + message: expect.stringMatching( + /kernel.*hash-1.*sha256.*31 bytes.*32 bytes/iu, + ), }); }); }); @@ -567,7 +573,7 @@ describe("FIT fixed limits", () => { it("fails a structure without /images", () => { expect(failureOf(devicetreeFixture())).toMatchObject({ reason: "format", - message: "FIT is malformed: /images node is missing", + message: expect.stringMatching(/\/images.*missing/iu), }); }); }); diff --git a/src/artifacts/nodeSea/NodeSeaBlob.test.ts b/src/artifacts/nodeSea/NodeSeaBlob.test.ts index a2f79ead7e..12da93a39e 100644 --- a/src/artifacts/nodeSea/NodeSeaBlob.test.ts +++ b/src/artifacts/nodeSea/NodeSeaBlob.test.ts @@ -4,20 +4,8 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../ArtifactReader.js"; import { - NODE_SEA_ASSET_COUNT, - NODE_SEA_ASSET_NAME_BYTES, - NODE_SEA_BAD_MAGIC, - NODE_SEA_EMPTY_CODE_PATH, - NODE_SEA_FUSE_CONFLICT, - NODE_SEA_FUSE_ABSENT, - NODE_SEA_FUSE_UNSET, NODE_SEA_MAGIC, - NODE_SEA_TRAILING_BYTES, - NODE_SEA_TRUNCATED, - NODE_SEA_UNKNOWN_EXTENSION, - NODE_SEA_UNKNOWN_FLAGS, decodeNodeSeaBlob, - nodeSeaAssetCollision, observeNodeSeaFuseMatches, } from "./NodeSeaBlob.js"; import { buildNodeSeaBlob } from "./NodeSea.fixture.js"; @@ -31,44 +19,59 @@ const header = (flags: number, extension = 1): Buffer => { }; it("rejects a blob that ends before the magic", async () => { - await expect(decodeNodeSeaBlob(Buffer.from([0x20, 0xda]))).rejects.toThrow( - NODE_SEA_TRUNCATED, - ); + await expect( + decodeNodeSeaBlob(Buffer.from([0x20, 0xda])), + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/Node SEA.*truncated/iu), + }); }); it("rejects a blob whose magic is not 0x143da20", async () => { - await expect(decodeNodeSeaBlob(Buffer.alloc(9))).rejects.toThrow( - NODE_SEA_BAD_MAGIC, - ); + await expect(decodeNodeSeaBlob(Buffer.alloc(9))).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/magic.*not.*0x143da20/iu), + }); }); it("rejects a header that ends before the extension byte", async () => { - await expect(decodeNodeSeaBlob(header(0).subarray(0, 8))).rejects.toThrow( - NODE_SEA_TRUNCATED, - ); + await expect( + decodeNodeSeaBlob(header(0).subarray(0, 8)), + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/Node SEA.*truncated/iu), + }); }); it("rejects unknown flag bits", async () => { - await expect(decodeNodeSeaBlob(header(1 << 5))).rejects.toThrow( - NODE_SEA_UNKNOWN_FLAGS, - ); + await expect(decodeNodeSeaBlob(header(1 << 5))).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/flags.*unknown bits/iu), + }); }); it("rejects an unknown exec argv extension", async () => { - await expect(decodeNodeSeaBlob(header(0, 9))).rejects.toThrow( - NODE_SEA_UNKNOWN_EXTENSION, - ); + await expect(decodeNodeSeaBlob(header(0, 9))).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/exec argv extension.*unknown/iu), + }); }); it("rejects an empty code path", async () => { await expect( decodeNodeSeaBlob(buildNodeSeaBlob({ codePath: "" })), - ).rejects.toThrow(NODE_SEA_EMPTY_CODE_PATH); + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/code path.*empty/iu), + }); }); it("rejects a truncated length-prefixed field", async () => { const bytes = Buffer.concat([header(0), Buffer.alloc(4)]); - await expect(decodeNodeSeaBlob(bytes)).rejects.toThrow(NODE_SEA_TRUNCATED); + await expect(decodeNodeSeaBlob(bytes)).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/Node SEA.*truncated/iu), + }); }); it("rejects colliding asset names", async () => { @@ -81,7 +84,10 @@ it("rejects colliding asset names", async () => { ], }), ), - ).rejects.toThrow(nodeSeaAssetCollision("shared")); + ).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/Node SEA asset name collides.*shared/iu), + }); }); it("retains an empty asset key as a raw Node SEA key", async () => { @@ -91,6 +97,43 @@ it("retains an empty asset key as a raw Node SEA key", async () => { expect(decoded.assets).toMatchObject([{ name: "", range: { length: 5 } }]); }); +it.each([ + { + field: "code path", + build: (value: string) => buildNodeSeaBlob({ codePath: value }), + read: (blob: Awaited>) => + blob.codePath, + }, + { + field: "asset name", + build: (value: string) => + buildNodeSeaBlob({ assets: [{ name: value, body: "" }] }), + read: (blob: Awaited>) => + blob.assets[0]?.name, + }, + { + field: "exec argument", + build: (value: string) => buildNodeSeaBlob({ execArgv: [value] }), + read: (blob: Awaited>) => + blob.execArgv[0], + }, +])( + "rejects invalid UTF-8 in $field while preserving a literal replacement character", + async ({ build, read }) => { + const valid = await decodeNodeSeaBlob(build("\ufffd")); + expect(read(valid)).toBe("\ufffd"); + + const malformed = build("fixture-marker"); + const offset = malformed.indexOf("fixture-marker"); + expect(offset).toBeGreaterThanOrEqual(0); + malformed[offset] = 0xff; + await expect(decodeNodeSeaBlob(malformed)).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/not valid UTF-8/iu), + }); + }, +); + const limitOf = async (bytes: Buffer): Promise => { try { await decodeNodeSeaBlob(bytes); @@ -110,7 +153,9 @@ it("refuses an asset table over its count bound before creating records", async const failure = await limitOf(bytes); expect(failure).toMatchObject({ reason: "limit", - message: NODE_SEA_ASSET_COUNT, + message: expect.stringMatching( + /asset count.*100000.*no runtime override/iu, + ), }); }); @@ -126,17 +171,52 @@ it("refuses asset names over the decoded name budget", async () => { ); expect(failure).toMatchObject({ reason: "limit", - message: NODE_SEA_ASSET_NAME_BYTES, + message: expect.stringMatching( + /asset names.*8388608.*no runtime override/iu, + ), }); }); +it.each([ + { + label: "count", + accepted: () => Array.from({ length: 4096 }, () => "x"), + args: () => Array.from({ length: 4097 }, () => "x"), + boundary: "capacity", + warning: /exec argv count.*4096.*no runtime override/iu, + }, + { + label: "text", + accepted: () => ["x".repeat(1024 * 1024)], + args: () => ["x".repeat(1024 * 1024 + 1)], + boundary: "memory", + warning: /exec argv text.*1048576.*no runtime override/iu, + }, +])( + "admits exec argument $label at its bound and refuses more", + async ({ accepted, args, boundary, warning }) => { + const atLimit = accepted(); + expect( + (await decodeNodeSeaBlob(buildNodeSeaBlob({ execArgv: atLimit }))) + .execArgv, + ).toEqual(atLimit); + const failure = await limitOf(buildNodeSeaBlob({ execArgv: args() })); + expect(failure).toMatchObject({ + reason: "limit", + boundary, + message: expect.stringMatching(warning), + }); + }, +); + it("rejects non-zero trailing bytes and keeps zero padding", async () => { const padded = buildNodeSeaBlob({ trailing: Buffer.alloc(8) }); expect((await decodeNodeSeaBlob(padded)).codePath).toBe("sea.js"); const dirty = Buffer.concat([padded, Buffer.from([1])]); - await expect(decodeNodeSeaBlob(dirty)).rejects.toThrow( - NODE_SEA_TRAILING_BYTES, - ); + await expect(decodeNodeSeaBlob(dirty)).rejects.toMatchObject({ + reason: "format", + message: expect.stringMatching(/trailing bytes/iu), + }); }); it("decodes flags, the main script, code cache, assets, and exec argv", async () => { @@ -186,13 +266,15 @@ it("reports an absent, unset, set, or conflicting fuse sentinel", () => { expect(observeNodeSeaFuseMatches([])).toMatchObject({ fuseState: "absent", fuseSentinel: null, - fuseContradiction: NODE_SEA_FUSE_ABSENT, + fuseContradiction: expect.stringMatching( + /no NODE_SEA_FUSE sentinel.*observed/iu, + ), }); const unset = `NODE_SEA_FUSE_${"a".repeat(32)}:0`; expect(observeNodeSeaFuseMatches([unset, unset])).toMatchObject({ fuseState: "unset", fuseSentinel: unset, - fuseContradiction: NODE_SEA_FUSE_UNSET, + fuseContradiction: expect.stringMatching(/NODE_SEA_FUSE sentinel.*unset/iu), }); const set = `NODE_SEA_FUSE_${"b".repeat(32)}:1`; expect(observeNodeSeaFuseMatches([set])).toMatchObject({ @@ -203,7 +285,7 @@ it("reports an absent, unset, set, or conflicting fuse sentinel", () => { expect(observeNodeSeaFuseMatches([unset, set])).toMatchObject({ fuseState: "conflicting", fuseSentinel: null, - fuseContradiction: NODE_SEA_FUSE_CONFLICT, + fuseContradiction: expect.stringMatching(/fuse sentinels disagree/iu), }); }); diff --git a/src/artifacts/nodeSea/NodeSeaBlob.ts b/src/artifacts/nodeSea/NodeSeaBlob.ts index e63da49fd9..4b176c0450 100644 --- a/src/artifacts/nodeSea/NodeSeaBlob.ts +++ b/src/artifacts/nodeSea/NodeSeaBlob.ts @@ -30,13 +30,11 @@ const MAX_EXEC_ARGV = 4096; const MAX_EXEC_ARGV_TEXT_BYTES = 1024 * 1024; const NO_OVERRIDE = "This release has no runtime override."; -export const NODE_SEA_BAD_MAGIC = "Node SEA blob magic is not 0x143da20"; +const NODE_SEA_BAD_MAGIC = "Node SEA blob magic is not 0x143da20"; export const NODE_SEA_TRUNCATED = "Node SEA blob is truncated"; -export const NODE_SEA_UNKNOWN_FLAGS = - "Node SEA blob flags contain unknown bits"; -export const NODE_SEA_UNKNOWN_EXTENSION = - "Node SEA exec argv extension is unknown"; -export const NODE_SEA_TRAILING_BYTES = "Node SEA blob has trailing bytes"; +const NODE_SEA_UNKNOWN_FLAGS = "Node SEA blob flags contain unknown bits"; +const NODE_SEA_UNKNOWN_EXTENSION = "Node SEA exec argv extension is unknown"; +const NODE_SEA_TRAILING_BYTES = "Node SEA blob has trailing bytes"; export const NODE_SEA_DUPLICATE_BLOB = "Node SEA blob is declared more than once"; export const NODE_SEA_ELF_DESCRIPTOR_TRUNCATED = @@ -49,22 +47,19 @@ export const NODE_SEA_MACHO_COMMANDS_TRUNCATED = "Node SEA Mach-O load commands are truncated"; export const NODE_SEA_MACHO_SEGMENT_TRUNCATED = "Node SEA Mach-O segment table is truncated"; -export const NODE_SEA_FUSE_ABSENT = +const NODE_SEA_FUSE_ABSENT = "Node SEA blob is present but no NODE_SEA_FUSE sentinel was observed"; -export const NODE_SEA_FUSE_UNSET = +const NODE_SEA_FUSE_UNSET = "Node SEA blob is present but the NODE_SEA_FUSE sentinel is unset"; -export const NODE_SEA_FUSE_CONFLICT = "Node SEA fuse sentinels disagree"; -export const NODE_SEA_EMPTY_CODE_PATH = "Node SEA code path is empty"; -export const NODE_SEA_INVALID_UTF8 = "Node SEA string is not valid UTF-8"; -export const NODE_SEA_ASSET_COUNT = `Node SEA asset count exceeds the ${String(MAX_ASSET_COUNT)}-asset bound. ${NO_OVERRIDE}`; -export const NODE_SEA_ASSET_NAME_BYTES = `Node SEA asset names exceed the ${String(MAX_ASSET_NAME_BYTES)}-byte name budget. ${NO_OVERRIDE}`; -export const NODE_SEA_EXEC_ARGV_COUNT = `Node SEA exec argv count exceeds the ${String(MAX_EXEC_ARGV)}-argument bound. ${NO_OVERRIDE}`; -export const NODE_SEA_EXEC_ARGV_TEXT = `Node SEA exec argv text exceeds the ${String(MAX_EXEC_ARGV_TEXT_BYTES)}-byte bound. ${NO_OVERRIDE}`; +const NODE_SEA_FUSE_CONFLICT = "Node SEA fuse sentinels disagree"; +const NODE_SEA_EMPTY_CODE_PATH = "Node SEA code path is empty"; +const NODE_SEA_INVALID_UTF8 = "Node SEA string is not valid UTF-8"; +const NODE_SEA_ASSET_COUNT = `Node SEA asset count exceeds the ${String(MAX_ASSET_COUNT)}-asset bound. ${NO_OVERRIDE}`; +const NODE_SEA_ASSET_NAME_BYTES = `Node SEA asset names exceed the ${String(MAX_ASSET_NAME_BYTES)}-byte name budget. ${NO_OVERRIDE}`; +const NODE_SEA_EXEC_ARGV_COUNT = `Node SEA exec argv count exceeds the ${String(MAX_EXEC_ARGV)}-argument bound. ${NO_OVERRIDE}`; +const NODE_SEA_EXEC_ARGV_TEXT = `Node SEA exec argv text exceeds the ${String(MAX_EXEC_ARGV_TEXT_BYTES)}-byte bound. ${NO_OVERRIDE}`; export const NODE_SEA_BLOB_TOO_LARGE = `Node SEA blob exceeds the ${String(NODE_SEA_MAX_BLOB_BYTES)}-byte in-memory bound`; -export const nodeSeaAssetCollision = (name: string): string => - `Node SEA asset name collides: ${name}`; - const FUSE_PREFIX = "NODE_SEA_FUSE_"; export interface NodeSeaByteRange { @@ -276,7 +271,8 @@ class BlobCursor { assertArtifactReadActive(signal); } const name = this.text(true, NODE_SEA_INVALID_UTF8, charge); - if (seen.has(name)) throw formatFailure(nodeSeaAssetCollision(name)); + if (seen.has(name)) + throw formatFailure(`Node SEA asset name collides: ${name}`); seen.add(name); assets.push({ name, range: this.byteRange() }); } diff --git a/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts index 2944dab724..2971fa992f 100644 --- a/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerAdmission.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; import { withOwnedRegularFile } from "../../filesystem/OwnedRegularFile.js"; +import type { OwnedFileHandle } from "../../filesystem/OwnedFileHandle.js"; import { ArtifactEmbeddedFormatProbes } from "../ArtifactEmbeddedFormatProbes.js"; import { ArtifactResourceScope } from "../ArtifactResourceScope.js"; import { inventoryArtifact } from "../inventory/ArtifactInventory.js"; @@ -61,6 +62,7 @@ it.each([ }), ); const resourceScope = new ArtifactResourceScope(); + let rootOwner: OwnedFileHandle | undefined; try { const classified = await classifyAndHashRootForInventory( path, @@ -68,6 +70,7 @@ it.each([ await lstat(path), { resourceScope }, ); + rootOwner = classified.rootSource?.owner; const admission = classified.readerAdmission; if ( admission === undefined || @@ -95,8 +98,23 @@ it.each([ await reader.close(); } } finally { - await resourceScope.close(); + try { + if (rootOwner !== undefined) { + const cleanup = await resourceScope.release({ + kind: "file-handle", + resource: `root artifact descriptor for ${path}`, + handle: rootOwner, + }); + if (cleanup.kind === "failed") + throw resourceScope.cleanupFailure({ kind: "completed" }, [ + cleanup, + ]); + } + } finally { + await resourceScope.close(); + } } + expect(rootOwner?.handle.fd).toBe(-1); }, ); @@ -314,7 +332,7 @@ it("does not cache an incomplete tail read as an absent cookie and can retry", a try { await expect(probes.pyInstaller()).rejects.toMatchObject({ reason: "format", - message: "CArchive tail is truncated", + message: expect.stringMatching(/tail.*truncated/iu), }); expect(calls).toBe(2); expect(Object.hasOwn(probes.completedFacts(), "pyInstaller")).toBe(false); diff --git a/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts b/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts index 46ea79c0ce..57a17d5700 100644 --- a/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts +++ b/src/artifacts/pyinstaller/PyInstallerFormatBoundaries.test.ts @@ -463,7 +463,6 @@ it.each(["empty", "span"])( : parsed.value.kind === "opaque" ? parsed.value.limitation : undefined; - expect(message).toBeDefined(); expect(message?.length).toBeLessThan(256); expect(message).toContain("table index 0"); }); diff --git a/src/browser/PngVisualDiff.test.ts b/src/browser/PngVisualDiff.test.ts index ee819cbdfc..92b1d8a9a5 100644 --- a/src/browser/PngVisualDiff.test.ts +++ b/src/browser/PngVisualDiff.test.ts @@ -140,8 +140,10 @@ describe("PNG visual diff", () => { }), ); expect(result).toMatchObject({ status: "identical", changed_pixels: 0 }); - expect(result.limitations).toContain( - "PNG tRNS transparency is accepted only as a six-byte RGB color key; tRNS on RGBA or with another length is rejected.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /tRNS.*only.*six-byte RGB.*RGBA.*another length.*rejected/iu, + ), ); }); diff --git a/src/browser/history/HarCapture.test.ts b/src/browser/history/HarCapture.test.ts index 19e7b68aa3..e1323bdefa 100644 --- a/src/browser/history/HarCapture.test.ts +++ b/src/browser/history/HarCapture.test.ts @@ -794,9 +794,13 @@ it.each(["[2001:db8::1]", "[::1]", "[::ffff:192.0.2.1]"])( _extension: { serverIPAddress: address }, }, }); - expect(decoded.records[0]?.limitations).toContain( - "This capture declares Playwright 1.58.2 and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", + const limitation = decoded.records[0]?.limitations.find((line) => + line.includes("Playwright 1.58.2"), ); + expect(limitation).toMatch(/bracketed IPv6.*serverIPAddress/iu); + expect(limitation).toMatch(/original spellings.*retained/iu); + expect(limitation).toMatch(/brackets.*only.*valid IPv6.*validation copy/iu); + expect(limitation).toMatch(/producer declarations.*not authenticated/iu); expect( decoded.records[1]?.limitations.some((limitation) => limitation.includes("bracketed IPv6 serverIPAddress"), @@ -825,9 +829,15 @@ it.each(["[2620:0:860:ed1a::1]", "[::1]"])( expect(decoded.records[0]).toMatchObject({ reported: { serverIPAddress: address }, }); - expect(decoded.records[0]?.limitations).toContain( - "This capture declares Chromium DevTools (WebInspector 537.36) and reports bracketed IPv6 serverIPAddress. Original spellings are retained; brackets were removed only from valid IPv6 values in the validation copy. Producer declarations are not authenticated.", + const limitation = decoded.records[0]?.limitations.find((line) => + line.includes("WebInspector 537.36"), ); + expect(limitation).toMatch( + /Chromium DevTools.*bracketed IPv6.*serverIPAddress/iu, + ); + expect(limitation).toMatch(/original spellings.*retained/iu); + expect(limitation).toMatch(/brackets.*only.*valid IPv6.*validation copy/iu); + expect(limitation).toMatch(/producer declarations.*not authenticated/iu); expect( decoded.records[1]?.limitations.some((limitation) => limitation.includes("bracketed IPv6 serverIPAddress"), diff --git a/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.test.ts b/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.test.ts index 010ace91e7..de5292aafe 100644 --- a/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.test.ts +++ b/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.test.ts @@ -51,11 +51,11 @@ it("imports real v10 recorder text with per-stream identity and resource presenc expect( report.events.find((row) => row.method === "click")?.selector, ).toContain("Load value"); - expect( - report.events - .flatMap((row) => row.resource_references) - .every((row) => row.state === "present-unread"), - ).toBe(true); + const references = report.events.flatMap((row) => row.resource_references); + expect(references).toHaveLength(8); + expect(references.every((row) => row.state === "present-unread")).toBe( + true, + ); expect((await readFile(fixture.path)).equals(bytes)).toBe(true); } finally { await resources.close(); @@ -128,10 +128,14 @@ it("keeps missing resource references explicit without turning page exhaustion i if (!result.ok) throw result.error; expect(result.value.summary.unavailable_references).toBe(8); expect(result.value.source_coverage.interpretation).toBe("partial"); + const references = result.value.events.flatMap( + (row) => row.resource_references, + ); + expect(references).toHaveLength(8); expect( - result.value.events - .flatMap((row) => row.resource_references) - .every((row) => row.state === "missing" && row.matching_members === 0), + references.every( + (row) => row.state === "missing" && row.matching_members === 0, + ), ).toBe(true); } finally { await resources.close(); @@ -197,6 +201,7 @@ it.each([ if (!result.ok) throw result.error; expect(result.value.events).toHaveLength(events); expect(result.value.source_coverage.interpretation).toBe("partial"); + expect(result.value.streams.length).toBeGreaterThan(0); expect( result.value.streams.every((stream) => stream.state === "unknown"), ).toBe(true); diff --git a/src/browser/history/protobuf/ProtobufBodyTrailer.test.ts b/src/browser/history/protobuf/ProtobufBodyTrailer.test.ts index 9f41755773..8afa7fbffb 100644 --- a/src/browser/history/protobuf/ProtobufBodyTrailer.test.ts +++ b/src/browser/history/protobuf/ProtobufBodyTrailer.test.ts @@ -128,8 +128,9 @@ it.each(["grpc-web", "grpc-web-text"] as const)( status: "partial", examined_bytes: trailer.length, error: { - reason: - "gRPC-web trailer field contains opaque non-UTF-8 bytes; that field was omitted", + reason: expect.stringMatching( + /gRPC-web trailer.*non-UTF-8 bytes.*field was omitted/i, + ), }, trailers: [ { name: "grpc-status", value: "0" }, diff --git a/src/cli/jsonLines.ts b/src/cli/jsonLines.ts index 3911476f60..aae88418e1 100644 --- a/src/cli/jsonLines.ts +++ b/src/cli/jsonLines.ts @@ -41,7 +41,7 @@ const RECORD_BOUNDARY_PATHS = [ ] as const; /** Recovery for every JSON Lines selection refusal; the analysis result itself is valid. */ -export const JSON_LINES_SELECTION_REMEDIATION = +const JSON_LINES_SELECTION_REMEDIATION = "Use --format json for the complete document, or select one record collection with --filter-output."; interface Collection { diff --git a/src/cli/options.ts b/src/cli/options.ts index 6bf24bf36b..70ce833289 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -179,7 +179,7 @@ export const snapshotSelectionOption = z * Explicit interpretation for headerless native executable formats. Flash * dumps take their offsets on the artifact commands instead. */ -export const formatSelectionOption = executableFormatHintSchema +const formatSelectionOption = executableFormatHintSchema .exclude(["esp-flash-image"]) .optional() .describe( diff --git a/src/config.test.ts b/src/config.test.ts index ff9072a3b6..9ea612f1ee 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -440,18 +440,22 @@ describe("configuration failure diagnostics", () => { const result = parseConfig({ [setting]: value }); expect(result.ok).toBe(false); if (result.ok) return; - const constraint = `${setting} must be absolute`; - expect(projectAnalysisError(result.error)).toMatchObject({ + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ code: "configuration_invalid", category: "invalid_input", - message: `REA configuration is invalid: ${constraint}.`, - remediation: { action: expect.stringContaining(`Correct ${setting} `) }, - details: { settings: [{ setting, constraint }] }, + message: expect.stringContaining(setting), + remediation: { action: expect.stringContaining(setting) }, + details: { + settings: [ + { setting, constraint: expect.stringMatching(/absolute/iu) }, + ], + }, }); + expect(projected.message).toMatch(/absolute/iu); + expect(projected.remediation.action).toMatch(/correct/iu); // The rejected value itself is not echoed. - expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain( - value, - ); + expect(JSON.stringify(projected)).not.toContain(value); }, ); diff --git a/src/config/asarHeaderAdmission.ts b/src/config/asarHeaderAdmission.ts index 6f2e88dde3..52ba8bcedd 100644 --- a/src/config/asarHeaderAdmission.ts +++ b/src/config/asarHeaderAdmission.ts @@ -20,7 +20,7 @@ export const DEFAULT_ASAR_HEADER_ADMISSION: AsarHeaderAdmission = { source: "default", }; -export const asarMaxHeaderBytesConstraint = `${ASAR_HEADER_ADMISSION_SETTING} must be a positive decimal integer of at most ${String(MAXIMUM_ASAR_MAX_HEADER_BYTES)} bytes (256 MiB); the default is ${String(DEFAULT_ASAR_MAX_HEADER_BYTES)} bytes (16 MiB).`; +const asarMaxHeaderBytesConstraint = `${ASAR_HEADER_ADMISSION_SETTING} must be a positive decimal integer of at most ${String(MAXIMUM_ASAR_MAX_HEADER_BYTES)} bytes (256 MiB); the default is ${String(DEFAULT_ASAR_MAX_HEADER_BYTES)} bytes (16 MiB).`; /** Optional decimal byte count for the ASAR header/index admission. */ export const asarMaxHeaderBytesSchema = z diff --git a/src/config/bridgeMaxRequestBytes.test.ts b/src/config/bridgeMaxRequestBytes.test.ts index 7a1abb6718..02a6811487 100644 --- a/src/config/bridgeMaxRequestBytes.test.ts +++ b/src/config/bridgeMaxRequestBytes.test.ts @@ -5,7 +5,6 @@ import { parseEnvironment } from "./environment.js"; import { parseConfig } from "./parseConfig.js"; import { BRIDGE_MAX_REQUEST_BYTES_SETTING, - bridgeMaxRequestBytesConstraint, DEFAULT_BRIDGE_MAX_REQUEST, DEFAULT_BRIDGE_MAX_REQUEST_BYTES, MAXIMUM_BRIDGE_MAX_REQUEST_BYTES, @@ -119,10 +118,16 @@ it.each([ const parsed = parseBridgeMaxRequestBytes(value); expect(parsed.ok).toBe(false); if (parsed.ok) return; + expect(parsed.error.message).toContain(BRIDGE_MAX_REQUEST_BYTES_SETTING); + expect(parsed.error.message).toMatch(/positive decimal integer/i); + expect(parsed.error.message).toMatch(/7464 to 536870887 bytes/i); + expect(parsed.error.message).toMatch(/default.*16777216 bytes/i); + expect(parsed.error.message).toMatch(/symbol.name/i); + expect(parsed.error.message).toMatch(/framing/i); expect(parsed.error.settings).toEqual([ { setting: BRIDGE_MAX_REQUEST_BYTES_SETTING, - constraint: bridgeMaxRequestBytesConstraint, + constraint: parsed.error.message, }, ]); expect(projectAnalysisError(parsed.error).code).toBe("configuration_invalid"); @@ -134,12 +139,12 @@ it.each([ expect(startup.error._tag).toBe("ConfigurationError"); expect(startup.error.settings).toContainEqual({ setting: BRIDGE_MAX_REQUEST_BYTES_SETTING, - constraint: bridgeMaxRequestBytesConstraint, + constraint: parsed.error.message, }); if (doctor.ok) throw new Error(`Doctor accepted ${JSON.stringify(value)}`); expect(doctor.error._tag).toBe("ConfigurationError"); expect(doctor.error.settings).toContainEqual({ setting: BRIDGE_MAX_REQUEST_BYTES_SETTING, - constraint: bridgeMaxRequestBytesConstraint, + constraint: parsed.error.message, }); }); diff --git a/src/config/bridgeMaxRequestBytes.ts b/src/config/bridgeMaxRequestBytes.ts index 7d9674622d..7eecd7f1d4 100644 --- a/src/config/bridgeMaxRequestBytes.ts +++ b/src/config/bridgeMaxRequestBytes.ts @@ -62,7 +62,7 @@ export const DEFAULT_BRIDGE_MAX_REQUEST: BridgeMaxRequestBytes = { */ export type BridgePeer = "Hopper" | "Cutter" | "Ghidra"; -export const bridgeMaxRequestBytesConstraint = `${BRIDGE_MAX_REQUEST_BYTES_SETTING} must be a positive decimal integer from ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} to ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)} bytes; the default is ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)} bytes (16 MiB). A value below ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} bytes cannot carry the largest admitted symbol-name command, and a larger value is refused so the encoded payload plus its framing LF stays one string under Node's single-string ceiling.`; +const bridgeMaxRequestBytesConstraint = `${BRIDGE_MAX_REQUEST_BYTES_SETTING} must be a positive decimal integer from ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} to ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)} bytes; the default is ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)} bytes (16 MiB). A value below ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} bytes cannot carry the largest admitted symbol-name command, and a larger value is refused so the encoded payload plus its framing LF stays one string under Node's single-string ceiling.`; /** Optional decimal byte count for the Hopper and Cutter request payload ceiling. */ export const bridgeMaxRequestBytesSchema = positiveDecimalIntegerSchema( diff --git a/src/config/captureDecoderHeap.test.ts b/src/config/captureDecoderHeap.test.ts index ecb47ee55d..a0b6206db8 100644 --- a/src/config/captureDecoderHeap.test.ts +++ b/src/config/captureDecoderHeap.test.ts @@ -19,7 +19,6 @@ import { parseEnvironment } from "./environment.js"; import { parseConfig } from "./parseConfig.js"; import { CAPTURE_DECODER_OLD_GENERATION_SETTING, - captureDecoderHeapConstraint, DEFAULT_CAPTURE_DECODER_HEAP, parseCaptureDecoderHeap, } from "./captureDecoderHeap.js"; @@ -105,6 +104,18 @@ it("uses a valid REA_CAPTURE_DECODER_OLD_GENERATION_MIB override", async () => { ); expect(projected.remediation.action).toContain("update_analysis_settings"); expect(projected.remediation.action).toMatch(/restart REA/iu); + expect(projected.remediation.action).toContain( + "capture.decoder_old_generation_mib", + ); + expect(projected.remediation.action).toMatch(/without a REA restart/iu); + expect(projected.remediation.action).toMatch( + /removes inherited NODE_OPTIONS/iu, + ); + expect(projected.remediation.action).toMatch(/Bun.*V8 flags are ignored/iu); + expect(projected.remediation.action).toMatch(/Windows.*512 MiB reserve/iu); + expect(projected.remediation.action).toMatch( + /Other Bun hosts enforce no decoder heap limit/iu, + ); if (process.versions.bun === undefined) expect(projected.details).toMatchObject({ limit: { @@ -231,20 +242,26 @@ it.each([ const parsed = parseCaptureDecoderHeap(value); if (parsed.ok) throw new Error(`Accepted ${JSON.stringify(value)}`); expect(parsed.error).toBeInstanceOf(ConfigurationError); + expect(parsed.error.message).toContain( + CAPTURE_DECODER_OLD_GENERATION_SETTING, + ); + expect(parsed.error.message).toMatch(/decimal integer/i); + expect(parsed.error.message).toMatch(/8 to 16384 MiB/i); + expect(parsed.error.message).toMatch(/default.*192 MiB/i); + expect(parsed.error.message).toMatch(/semi-space.*abort.*Node.*startup/i); expect(parsed.error.settings).toEqual([ { setting: CAPTURE_DECODER_OLD_GENERATION_SETTING, - constraint: captureDecoderHeapConstraint, + constraint: parsed.error.message, }, ]); - expect(parsed.error.message).toBe(captureDecoderHeapConstraint); const startup = parseEnvironment({ REA_CAPTURE_DECODER_OLD_GENERATION_MIB: value, }); if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`); expect(startup.error.settings).toContainEqual({ setting: CAPTURE_DECODER_OLD_GENERATION_SETTING, - constraint: captureDecoderHeapConstraint, + constraint: parsed.error.message, }); const config = parseConfig({ REA_CAPTURE_DECODER_OLD_GENERATION_MIB: value, diff --git a/src/config/captureDecoderHeap.ts b/src/config/captureDecoderHeap.ts index 31abfb6025..ffd1e2b9dc 100644 --- a/src/config/captureDecoderHeap.ts +++ b/src/config/captureDecoderHeap.ts @@ -22,7 +22,7 @@ export const DEFAULT_CAPTURE_DECODER_HEAP: CaptureDecoderHeap = { source: "default", }; -export const captureDecoderHeapConstraint = `${CAPTURE_DECODER_OLD_GENERATION_SETTING} must be a decimal integer from ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} to ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB; the default is ${String(DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB. An old generation below the decoder's ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} MiB semi-space aborts Node during startup instead of enforcing a limit, and a larger value is refused.`; +const captureDecoderHeapConstraint = `${CAPTURE_DECODER_OLD_GENERATION_SETTING} must be a decimal integer from ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} to ${String(MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB; the default is ${String(DEFAULT_CAPTURE_DECODER_OLD_GENERATION_MIB)} MiB. An old generation below the decoder's ${String(CAPTURE_DECODER_SEMI_SPACE_MIB)} MiB semi-space aborts Node during startup instead of enforcing a limit, and a larger value is refused.`; /** Optional decimal mebibyte count for the HAR/SAZ/qlog old-generation heap. */ export const captureDecoderOldGenerationMiBSchema = diff --git a/src/config/debuggerSessionLimits.test.ts b/src/config/debuggerSessionLimits.test.ts index 6dad453fd6..32d11a91fc 100644 --- a/src/config/debuggerSessionLimits.test.ts +++ b/src/config/debuggerSessionLimits.test.ts @@ -21,10 +21,6 @@ import { DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, DEBUGGER_MAX_PENDING_COMMANDS_SETTING, DEBUGGER_MAX_SESSIONS_SETTING, - debuggerCommandDeadlineConstraint, - debuggerMaxCommandOutputBytesConstraint, - debuggerMaxPendingCommandsConstraint, - debuggerMaxSessionsConstraint, parseDebuggerSessionLimits, resolveDebuggerSessionLimits, } from "./debuggerSessionLimits.js"; @@ -93,30 +89,59 @@ it("accepts one in-range override for each shared debugger cap", () => { }); it.each([ - [DEBUGGER_COMMAND_DEADLINE_SETTING, "0", debuggerCommandDeadlineConstraint], - [DEBUGGER_MAX_SESSIONS_SETTING, "65", debuggerMaxSessionsConstraint], + [ + DEBUGGER_COMMAND_DEADLINE_SETTING, + "0", + MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + 120_000, + "ms", + ], + [ + DEBUGGER_MAX_SESSIONS_SETTING, + "65", + MINIMUM_DEBUGGER_MAX_SESSIONS, + MAXIMUM_DEBUGGER_MAX_SESSIONS, + 8, + "sessions", + ], [ DEBUGGER_MAX_PENDING_COMMANDS_SETTING, "00", - debuggerMaxPendingCommandsConstraint, + MINIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + MAXIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + 16, + "commands", ], [ DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, String(MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES + 1), - debuggerMaxCommandOutputBytesConstraint, + MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + 16 * 1024 * 1024, + "bytes", ], ])( "rejects invalid %s at the setting boundary", - (setting, value, constraint) => { + (setting, value, minimum, maximum, defaultValue, unit) => { const parsed = parseDebuggerSessionLimits({ [setting]: value }); if (parsed.ok) throw new Error(`${setting} was accepted`); expect(parsed.error).toBeInstanceOf(ConfigurationError); - expect(parsed.error.settings).toEqual([{ setting, constraint }]); + expect(parsed.error.settings).toHaveLength(1); + const detail = parsed.error.settings[0]; + if (!detail) throw new Error("missing setting guidance"); + expect(detail.setting).toBe(setting); + expect(detail.constraint).toContain(setting); + expect(detail.constraint).toMatch(/positive decimal integer/i); + expect(detail.constraint).toContain( + `from ${minimum} to ${maximum} ${unit}`, + ); + expect(detail.constraint).toContain(`default is ${defaultValue} ${unit}`); const startup = parseEnvironment({ [setting]: value }); if (startup.ok) throw new Error(`${setting} was accepted at startup`); expect(projectAnalysisError(startup.error)).toMatchObject({ code: "configuration_invalid", - details: { settings: [{ setting, constraint }] }, + details: { settings: [detail] }, }); }, ); @@ -229,6 +254,10 @@ it("refuses an invalid GDB deadline before a session starts", () => { environment: { [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0" }, }), ).toThrow(ConfigurationError); + const parsed = parseDebuggerSessionLimits({ + [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0", + }); + if (parsed.ok) throw new Error("invalid deadline was accepted"); try { new GdbSessionManager({ environment: { [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0" }, @@ -238,12 +267,7 @@ it("refuses an invalid GDB deadline before a session starts", () => { expect(projectAnalysisError(error)).toMatchObject({ code: "configuration_invalid", details: { - settings: [ - { - setting: DEBUGGER_COMMAND_DEADLINE_SETTING, - constraint: debuggerCommandDeadlineConstraint, - }, - ], + settings: parsed.error.settings, }, }); } diff --git a/src/config/debuggerSessionLimits.ts b/src/config/debuggerSessionLimits.ts index 363aec979a..9a360822b6 100644 --- a/src/config/debuggerSessionLimits.ts +++ b/src/config/debuggerSessionLimits.ts @@ -34,7 +34,7 @@ export const DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING = export interface DebuggerAdmissionLimit { readonly setting: string; readonly value: number; - readonly source: "default" | "setting" | "runtime"; + readonly source: "default" | "setting"; readonly defaultValue: number; readonly minimum: number; readonly maximum: number; @@ -96,16 +96,16 @@ const constraintFor = (admission: DebuggerAdmissionLimit): string => : "" }`; -export const debuggerCommandDeadlineConstraint = constraintFor( +const debuggerCommandDeadlineConstraint = constraintFor( DEFAULT_DEBUGGER_COMMAND_DEADLINE, ); -export const debuggerMaxSessionsConstraint = constraintFor( +const debuggerMaxSessionsConstraint = constraintFor( DEFAULT_DEBUGGER_MAX_SESSIONS_LIMIT, ); -export const debuggerMaxPendingCommandsConstraint = constraintFor( +const debuggerMaxPendingCommandsConstraint = constraintFor( DEFAULT_DEBUGGER_MAX_PENDING_COMMANDS_LIMIT, ); -export const debuggerMaxCommandOutputBytesConstraint = constraintFor( +const debuggerMaxCommandOutputBytesConstraint = constraintFor( DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT, ); @@ -243,10 +243,3 @@ export const debuggerControlReplyOutput: DebuggerAdmissionLimit = { maximum: DEBUGGER_CONTROL_REPLY_BYTES, unit: "bytes", }; - -/** Test and caller seams replace the launch value and record that source. */ -export const applyDebuggerAdmissionOverride = ( - admission: DebuggerAdmissionLimit, - value: number | undefined, -): DebuggerAdmissionLimit => - value === undefined ? admission : { ...admission, value, source: "runtime" }; diff --git a/src/config/ghidraExistingProject.ts b/src/config/ghidraExistingProject.ts index 387f6c48e3..407b2b8f3d 100644 --- a/src/config/ghidraExistingProject.ts +++ b/src/config/ghidraExistingProject.ts @@ -2,7 +2,7 @@ import { isAbsolute, basename } from "node:path"; import { z } from "zod"; /** Recovery for a packed Program selected where REA requires a project. */ -export const GHIDRA_PACKED_PROGRAM_RECOVERY = +const GHIDRA_PACKED_PROGRAM_RECOVERY = "A .gzf is a packed program, not a .gpr project. Import it into a new project with Ghidra analyzeHeadless -import -noanalysis; select its .gpr and, if needed, set REA_GHIDRA_PROGRAM_NAME to the imported program name."; /** A single expanded local project marker; archives must be restored first. */ diff --git a/src/config/nativeMatchingAllowance.test.ts b/src/config/nativeMatchingAllowance.test.ts index 182adae74f..cb296ee11f 100644 --- a/src/config/nativeMatchingAllowance.test.ts +++ b/src/config/nativeMatchingAllowance.test.ts @@ -6,7 +6,6 @@ import { runtimeNativeMatchingAllowance } from "./runtimeAnalysisSettings.js"; import { DEFAULT_NATIVE_MATCHING_ALLOWANCE, NATIVE_MATCHING_ALLOWANCE_SETTING, - nativeMatchingRepresentationBytesConstraint, parseNativeMatchingAllowance, } from "./nativeMatchingAllowance.js"; import { launchAnalysisSetting } from "./analysisSettings.js"; @@ -56,9 +55,17 @@ it("refuses invalid launch values instead of falling back to the default", () => expect(parsed.ok).toBe(false); if (parsed.ok) continue; expect(parsed.error).toBeInstanceOf(ConfigurationError); - expect(parsed.error.message).toBe( - nativeMatchingRepresentationBytesConstraint, - ); + expect(parsed.error.message).toContain(NATIVE_MATCHING_ALLOWANCE_SETTING); + expect(parsed.error.message).toMatch(/positive decimal integer/i); + expect(parsed.error.message).toContain("1073741824"); + expect(parsed.error.message).toContain("100663296"); + expect(parsed.error.message).toMatch(/bytes/i); + expect(parsed.error.settings).toEqual([ + { + setting: NATIVE_MATCHING_ALLOWANCE_SETTING, + constraint: parsed.error.message, + }, + ]); expect( launchAnalysisSetting("native.matching_representation_bytes", environment) .ok, diff --git a/src/config/nativeMatchingAllowance.ts b/src/config/nativeMatchingAllowance.ts index 8a0d96a7d0..ed04590a97 100644 --- a/src/config/nativeMatchingAllowance.ts +++ b/src/config/nativeMatchingAllowance.ts @@ -21,7 +21,7 @@ export const DEFAULT_NATIVE_MATCHING_ALLOWANCE: NativeMatchingAllowance = { source: "default", }; -export const nativeMatchingRepresentationBytesConstraint = `${NATIVE_MATCHING_ALLOWANCE_SETTING} must be a positive decimal integer of at most ${String(MAXIMUM_NATIVE_MATCHING_REPRESENTATION_BYTES)} bytes (1 GiB); the default is ${String(DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES)} bytes (96 MiB).`; +const nativeMatchingRepresentationBytesConstraint = `${NATIVE_MATCHING_ALLOWANCE_SETTING} must be a positive decimal integer of at most ${String(MAXIMUM_NATIVE_MATCHING_REPRESENTATION_BYTES)} bytes (1 GiB); the default is ${String(DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES)} bytes (96 MiB).`; /** Optional decimal byte count for the native function-matching allowance. */ const nativeMatchingRepresentationBytesSchema = z diff --git a/src/config/regexMatchingDeadline.test.ts b/src/config/regexMatchingDeadline.test.ts index b9042a0e78..168fd2a8c9 100644 --- a/src/config/regexMatchingDeadline.test.ts +++ b/src/config/regexMatchingDeadline.test.ts @@ -12,7 +12,6 @@ import { DEFAULT_REGEX_MATCHING_DEADLINE, parseRegexMatchingDeadline, REGEX_MATCHING_DEADLINE_SETTING, - regexMatchingDeadlineConstraint, regexMatchingDeadlineRecovery, } from "./regexMatchingDeadline.js"; import { MAXIMUM_REGEX_MATCHING_DEADLINE_MS } from "../domain/analysisResourceLimits.js"; @@ -59,6 +58,18 @@ it("keeps the 5000 ms deadline when the setting is unset", () => { }, }); expect(projected.remediation.action).toContain(recovery); + expect(projected.remediation.action).toContain( + REGEX_MATCHING_DEADLINE_SETTING, + ); + expect(projected.remediation.action).toContain("5000 ms"); + expect(projected.remediation.action).toContain("source default"); + expect(projected.remediation.action).toContain("update_analysis_settings"); + expect(projected.remediation.action).toContain("regex.matching_deadline_ms"); + expect(projected.remediation.action).toMatch(/restart REA/i); + expect(projected.remediation.action).toMatch(/without a REA restart/i); + expect(projected.remediation.action).toMatch( + /heap does not raise this deadline/i, + ); }); it("uses a valid REA_REGEX_MATCHING_DEADLINE_MS override", () => { @@ -123,18 +134,22 @@ it.each([ const parsed = parseRegexMatchingDeadline(value); if (parsed.ok) throw new Error(`Accepted ${JSON.stringify(value)}`); expect(parsed.error).toBeInstanceOf(ConfigurationError); + expect(parsed.error.message).toContain(REGEX_MATCHING_DEADLINE_SETTING); + expect(parsed.error.message).toMatch(/positive decimal integer/i); + expect(parsed.error.message).toMatch(/1 to 2147483647 ms/i); + expect(parsed.error.message).toMatch(/default.*5000 ms/i); + expect(parsed.error.message).toMatch(/Node.*1 ms/i); expect(parsed.error.settings).toEqual([ { setting: "REA_REGEX_MATCHING_DEADLINE_MS", - constraint: regexMatchingDeadlineConstraint, + constraint: parsed.error.message, }, ]); - expect(parsed.error.message).toBe(regexMatchingDeadlineConstraint); const startup = parseEnvironment({ REA_REGEX_MATCHING_DEADLINE_MS: value }); if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`); expect(startup.error.settings).toContainEqual({ setting: REGEX_MATCHING_DEADLINE_SETTING, - constraint: regexMatchingDeadlineConstraint, + constraint: parsed.error.message, }); const config = parseConfig({ REA_REGEX_MATCHING_DEADLINE_MS: value }); if (config.ok) diff --git a/src/config/regexMatchingDeadline.ts b/src/config/regexMatchingDeadline.ts index 650748040b..720ce68b16 100644 --- a/src/config/regexMatchingDeadline.ts +++ b/src/config/regexMatchingDeadline.ts @@ -20,7 +20,7 @@ export const DEFAULT_REGEX_MATCHING_DEADLINE: RegexMatchingDeadline = { source: "default", }; -export const regexMatchingDeadlineConstraint = `${REGEX_MATCHING_DEADLINE_SETTING} must be a positive decimal integer from 1 to ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)} ms; the default is ${String(DEFAULT_REGEX_MATCHING_DEADLINE_MS)} ms. Node schedules a delay above that maximum as 1 ms, so a larger value is refused.`; +const regexMatchingDeadlineConstraint = `${REGEX_MATCHING_DEADLINE_SETTING} must be a positive decimal integer from 1 to ${String(MAXIMUM_REGEX_MATCHING_DEADLINE_MS)} ms; the default is ${String(DEFAULT_REGEX_MATCHING_DEADLINE_MS)} ms. Node schedules a delay above that maximum as 1 ms, so a larger value is refused.`; /** Optional decimal millisecond count for the supervised regex matching deadline. */ export const regexMatchingDeadlineMsSchema = positiveDecimalIntegerSchema( diff --git a/src/config/regexWorkerHeapRequest.test.ts b/src/config/regexWorkerHeapRequest.test.ts index a5796572c9..e6f9756abc 100644 --- a/src/config/regexWorkerHeapRequest.test.ts +++ b/src/config/regexWorkerHeapRequest.test.ts @@ -12,7 +12,6 @@ import { DEFAULT_REGEX_WORKER_HEAP_REQUEST, parseRegexWorkerHeapRequest, REGEX_WORKER_HEAP_REQUEST_SETTING, - regexWorkerHeapRequestConstraint, regexWorkerHeapRequestRecovery, } from "./regexWorkerHeapRequest.js"; import { @@ -66,6 +65,20 @@ it("keeps the 128 MiB request when the setting is unset", () => { }, }); expect(projected.remediation.action).toContain(recovery); + expect(projected.remediation.action).toContain( + REGEX_WORKER_HEAP_REQUEST_SETTING, + ); + expect(projected.remediation.action).toContain("128 MiB"); + expect(projected.remediation.action).toContain("source default"); + expect(projected.remediation.action).toContain("update_analysis_settings"); + expect(projected.remediation.action).toContain( + "regex.worker_heap_request_mib", + ); + expect(projected.remediation.action).toContain("--max-old-space-size"); + expect(projected.remediation.action).toContain("NODE_OPTIONS"); + expect(projected.remediation.action).toMatch(/restart REA/i); + expect(projected.remediation.action).toMatch(/without a REA restart/i); + expect(projected.remediation.action).toMatch(/overrides this request/i); }); it("uses a valid REA_REGEX_WORKER_HEAP_REQUEST_MIB override", () => { @@ -140,20 +153,24 @@ it.each([ const parsed = parseRegexWorkerHeapRequest(value); if (parsed.ok) throw new Error(`Accepted ${JSON.stringify(value)}`); expect(parsed.error).toBeInstanceOf(ConfigurationError); + expect(parsed.error.message).toContain(REGEX_WORKER_HEAP_REQUEST_SETTING); + expect(parsed.error.message).toMatch(/decimal integer/i); + expect(parsed.error.message).toMatch(/7 to 16384 MiB/i); + expect(parsed.error.message).toMatch(/default.*128 MiB/i); + expect(parsed.error.message).toMatch(/abort.*worker.*startup/i); expect(parsed.error.settings).toEqual([ { setting: "REA_REGEX_WORKER_HEAP_REQUEST_MIB", - constraint: regexWorkerHeapRequestConstraint, + constraint: parsed.error.message, }, ]); - expect(parsed.error.message).toBe(regexWorkerHeapRequestConstraint); const startup = parseEnvironment({ REA_REGEX_WORKER_HEAP_REQUEST_MIB: value, }); if (startup.ok) throw new Error(`Startup accepted ${JSON.stringify(value)}`); expect(startup.error.settings).toContainEqual({ setting: REGEX_WORKER_HEAP_REQUEST_SETTING, - constraint: regexWorkerHeapRequestConstraint, + constraint: parsed.error.message, }); const config = parseConfig({ REA_REGEX_WORKER_HEAP_REQUEST_MIB: value }); if (config.ok) diff --git a/src/config/regexWorkerHeapRequest.ts b/src/config/regexWorkerHeapRequest.ts index 3e8a4d35de..1d4a8dbc83 100644 --- a/src/config/regexWorkerHeapRequest.ts +++ b/src/config/regexWorkerHeapRequest.ts @@ -22,7 +22,7 @@ export const DEFAULT_REGEX_WORKER_HEAP_REQUEST: RegexWorkerHeapRequest = { source: "default", }; -export const regexWorkerHeapRequestConstraint = `${REGEX_WORKER_HEAP_REQUEST_SETTING} must be a decimal integer from ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} to ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB; the default is ${String(DEFAULT_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB. A request below ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB aborts the worker during startup, and a larger value is refused.`; +const regexWorkerHeapRequestConstraint = `${REGEX_WORKER_HEAP_REQUEST_SETTING} must be a decimal integer from ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} to ${String(MAXIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB; the default is ${String(DEFAULT_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB. A request below ${String(MINIMUM_REGEX_WORKER_HEAP_REQUEST_MIB)} MiB aborts the worker during startup, and a larger value is refused.`; /** Optional decimal mebibyte count for the regex worker old-generation request. */ export const regexWorkerHeapRequestMiBSchema = positiveDecimalIntegerSchema( diff --git a/src/config/windowsChangeTimeSettle.test.ts b/src/config/windowsChangeTimeSettle.test.ts index d9faf6e069..a5656f9682 100644 --- a/src/config/windowsChangeTimeSettle.test.ts +++ b/src/config/windowsChangeTimeSettle.test.ts @@ -8,8 +8,6 @@ import { WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, resolveWindowsChangeTimeSettle, - windowsChangeTimeSettingFailure, - windowsChangeTimeSettingMessage, } from "./windowsChangeTimeSettle.js"; const requiredFacts = ( @@ -94,22 +92,12 @@ it.each([ [WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, "0"], [WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, "1001"], ] as const)("rejects %s=%j", (bound, value) => { - const failure = windowsChangeTimeSettingFailure(bound, value); - expect(failure).toEqual({ - setting: bound.setting, - currentValue: value, - source: "setting", - defaultValue: bound.defaultValue, - floor: bound.floor, - maximum: bound.maximum, - unit: bound.unit, - restartRequired: true, - }); - const message = windowsChangeTimeSettingMessage(failure); - requiredFacts(message, failure); const resolved = resolveWindowsChangeTimeSettle({ [bound.setting]: value }); if (resolved.ok) throw new Error(`Accepted ${bound.setting}=${value}`); expect(resolved.error).toBeInstanceOf(ConfigurationError); + const message = resolved.error.settings[0]?.constraint; + if (message === undefined) throw new Error("Missing configuration guidance"); + requiredFacts(message, { ...bound, currentValue: value, source: "setting" }); expect(resolved.error.settings).toEqual([ { setting: bound.setting, constraint: message }, ]); diff --git a/src/config/windowsChangeTimeSettle.ts b/src/config/windowsChangeTimeSettle.ts index 72134c6156..b5d83dd1cd 100644 --- a/src/config/windowsChangeTimeSettle.ts +++ b/src/config/windowsChangeTimeSettle.ts @@ -68,7 +68,7 @@ export interface WindowsChangeTimeSettleLimits { readonly maxPollsSource: "default" | "setting"; } -export const windowsChangeTimeSettingFailure = ( +const windowsChangeTimeSettingFailure = ( bound: WindowsChangeTimeBound, currentValue: string, ): WindowsChangeTimeSettingFailure => ({ @@ -83,7 +83,7 @@ export const windowsChangeTimeSettingFailure = ( }); /** Name the rejected value, its source, the allowed range, and the required restart. */ -export const windowsChangeTimeSettingMessage = ( +const windowsChangeTimeSettingMessage = ( failure: WindowsChangeTimeSettingFailure, ): string => `The effective ${failure.setting} value is ${failure.currentValue} ${failure.unit} (source ${failure.source}; default ${String(failure.defaultValue)} ${failure.unit}, floor ${String(failure.floor)} ${failure.unit}, maximum ${String(failure.maximum)} ${failure.unit}). Set ${failure.setting} in the environment that starts REA to a positive decimal integer from ${String(failure.floor)} to ${String(failure.maximum)}, then restart REA.`; diff --git a/src/contracts/workflowInputContracts.test.ts b/src/contracts/workflowInputContracts.test.ts index 87f74d3363..54f870b549 100644 --- a/src/contracts/workflowInputContracts.test.ts +++ b/src/contracts/workflowInputContracts.test.ts @@ -76,8 +76,9 @@ describe("workflow input contracts", () => { issues: [ { path: ["native_observations", 0], - message: - "Native observation Evidence must be distinct from managed boundary Evidence", + message: expect.stringMatching( + /native observation Evidence.*distinct.*managed boundary Evidence/i, + ), }, ], }, @@ -90,7 +91,9 @@ describe("workflow input contracts", () => { issues: [ { path: ["native_observations", 1], - message: "Native observation Evidence IDs must be unique", + message: expect.stringMatching( + /native observation Evidence IDs.*unique/i, + ), }, ], }, @@ -103,17 +106,21 @@ describe("workflow input contracts", () => { issues: [ { path: ["native_observations", 0], - message: - "Native observation Evidence must be distinct from managed boundary Evidence", + message: expect.stringMatching( + /native observation Evidence.*distinct.*managed boundary Evidence/i, + ), }, { path: ["native_observations", 1], - message: - "Native observation Evidence must be distinct from managed boundary Evidence", + message: expect.stringMatching( + /native observation Evidence.*distinct.*managed boundary Evidence/i, + ), }, { path: ["native_observations", 1], - message: "Native observation Evidence IDs must be unique", + message: expect.stringMatching( + /native observation Evidence IDs.*unique/i, + ), }, ], }, @@ -131,14 +138,18 @@ describe("workflow input contracts", () => { native_observations: observations, }; const contract = toolContract("verify_managed_native_boundaries"); + let originalDiagnostics: unknown; for (const schema of [ contract.inputSchema, toolInputSchemaWithMetadata(contract), ]) { const result = await schema["~standard"].validate(input); - expect( - result.issues?.map(({ path, message }) => ({ path, message })) ?? [], - ).toEqual(issues); + const diagnostics = + result.issues?.map(({ path, message }) => ({ path, message })) ?? []; + expect(diagnostics).toEqual(issues); + if (originalDiagnostics === undefined) + originalDiagnostics = diagnostics; + else expect(diagnostics).toEqual(originalDiagnostics); if (issues.length === 0) expect(result).toEqual({ value: input }); } }, diff --git a/src/desktop/DesktopSessionRequests.test.ts b/src/desktop/DesktopSessionRequests.test.ts index 71389923e6..1163f2d2c4 100644 --- a/src/desktop/DesktopSessionRequests.test.ts +++ b/src/desktop/DesktopSessionRequests.test.ts @@ -4,40 +4,7 @@ import { setImmediate as tick } from "node:timers/promises"; import { describe, expect, it } from "vitest"; -import { - SessionRequestQueue, - parseSessionRequest, -} from "./DesktopSessionRequests.js"; - -describe("parseSessionRequest", () => { - it("reads stop, unnamed and named checkpoints", () => { - expect(parseSessionRequest(" stop ")).toEqual({ kind: "stop" }); - for (const line of ["", " ", "checkpoint", "checkpoint "]) - expect(parseSessionRequest(line)).toEqual({ - kind: "checkpoint", - name: undefined, - }); - expect(parseSessionRequest("checkpoint saved file")).toEqual({ - kind: "checkpoint", - name: "saved file", - }); - expect(parseSessionRequest("typed")).toEqual({ - kind: "checkpoint", - name: "typed", - }); - }); - - it("keeps a name that only starts with the keyword", () => { - expect(parseSessionRequest("checkpoint-2")).toEqual({ - kind: "checkpoint", - name: "checkpoint-2", - }); - expect(parseSessionRequest("checkpoints")).toEqual({ - kind: "checkpoint", - name: "checkpoints", - }); - }); -}); +import { SessionRequestQueue } from "./DesktopSessionRequests.js"; describe("SessionRequestQueue", () => { it("queues lines in order and treats the end of input as stop", async () => { @@ -46,13 +13,22 @@ describe("SessionRequestQueue", () => { createInterface({ input, terminal: false }), ); - input.end("checkpoint typed\n\n"); + input.end( + " stop \n\n \ncheckpoint\ncheckpoint \ncheckpoint saved file\ntyped\ncheckpoint-2\ncheckpoints\n", + ); await tick(); await tick(); - expect([queue.next(), queue.next(), queue.next(), queue.next()]).toEqual([ - { kind: "checkpoint", name: "typed" }, + expect(Array.from({ length: 11 }, () => queue.next())).toEqual([ + { kind: "stop" }, { kind: "checkpoint", name: undefined }, + { kind: "checkpoint", name: undefined }, + { kind: "checkpoint", name: undefined }, + { kind: "checkpoint", name: undefined }, + { kind: "checkpoint", name: "saved file" }, + { kind: "checkpoint", name: "typed" }, + { kind: "checkpoint", name: "checkpoint-2" }, + { kind: "checkpoint", name: "checkpoints" }, { kind: "stop" }, undefined, ]); diff --git a/src/desktop/DesktopSessionRequests.ts b/src/desktop/DesktopSessionRequests.ts index 06200da45f..a891aa69b9 100644 --- a/src/desktop/DesktopSessionRequests.ts +++ b/src/desktop/DesktopSessionRequests.ts @@ -9,7 +9,7 @@ const QUEUE_RESUME = 4; * `stop` ends the session. An empty line, `checkpoint` or `checkpoint ` * requests a checkpoint; any other line names one. */ -export const parseSessionRequest = (line: string): SessionRequest => { +const parseSessionRequest = (line: string): SessionRequest => { const text = line.trim(); if (text === "stop") return { kind: "stop" }; const name = text.replace(/^checkpoint(?:\s+|$)/u, ""); diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 85e5079c4f..047dbee61d 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -1207,6 +1207,10 @@ describe("analysis error projection: operational diagnostics", () => { expect(exited.remediation.action).toBe( analysisErrorRemediationAction(exitedError), ); + expect(exited.message).toMatch( + /Hopper exited.*search_strings.*exit code 7/iu, + ); + expect(exited.remediation.action).toMatch(/close_binary.*open.*retry/iu); const unknownError = new HopperProcessError(null, { operation: "search_strings", @@ -1225,6 +1229,12 @@ describe("analysis error projection: operational diagnostics", () => { expect(unknown.remediation.action).toBe( analysisErrorRemediationAction(unknownError), ); + expect(unknown.message).toMatch( + /could not determine.*busy, exited, or unreachable/iu, + ); + expect(unknown.remediation.action).toMatch( + /provider_operation_health.*before retrying or restarting/iu, + ); const unreachableError = new HopperProcessError(null, { operation: "search_strings", @@ -1242,6 +1252,12 @@ describe("analysis error projection: operational diagnostics", () => { message: analysisErrorUserMessage(unreachableError), remediation: { action: analysisErrorRemediationAction(unreachableError) }, }); + expect(unreachable.message).toMatch( + /unreachable.*process exit was not observed/iu, + ); + expect(unreachable.remediation.action).toMatch( + /close_binary.*Evidence and Unknown history.*export_evidence_bundle/iu, + ); }); }); @@ -1415,8 +1431,8 @@ it("preserves ordinary configuration recovery and projects a producer-owned sess remediationAction: "Repair the running session, then retry.", }), ); - expect(original.remediation.action).toBe( - "Correct REA_EXAMPLE in the environment that starts REA (your shell or the MCP client's registration), then rerun.", + expect(original.remediation.action).toMatch( + /correct.*REA_EXAMPLE.*environment.*shell.*MCP.*registration.*rerun/iu, ); expect(repairing).toEqual({ ...original, diff --git a/src/domain/analysisView/artifactInventoryView.test.ts b/src/domain/analysisView/artifactInventoryView.test.ts index 1eba5cfe22..8026478519 100644 --- a/src/domain/analysisView/artifactInventoryView.test.ts +++ b/src/domain/analysisView/artifactInventoryView.test.ts @@ -125,7 +125,7 @@ it.each(["node_count", "occurrence_count", "edge_count"] as const)( { path: ["source"], reason: "invalid_value", - message: "Artifact inventory members exceed their manifest counts", + message: expect.stringMatching(/members.*manifest.*counts/iu), }, ], }, diff --git a/src/domain/analysisView/environmentManifestView.test.ts b/src/domain/analysisView/environmentManifestView.test.ts index 806c1db38e..35931bc676 100644 --- a/src/domain/analysisView/environmentManifestView.test.ts +++ b/src/domain/analysisView/environmentManifestView.test.ts @@ -260,8 +260,10 @@ it("templates uid literals, opaque tokens and identifier-like siblings when ever properties: { overwrite: "boolean", title: "string" }, }, }); - expect(projected.limitations).toContain( - "Opt-in template rules after-uid-literal, opaque-token, sibling-variance merge recorded literals by shape; merged parameters may conflate distinct resources.", + expect(projected.limitations).toContainEqual( + expect.stringMatching( + /after-uid-literal.*opaque-token.*sibling-variance.*may conflate distinct resources/iu, + ), ); }); @@ -824,8 +826,10 @@ it("selects the origin with the most JSON-like records and names it from the hos status: "complete-within-view", total: 0, }); - expect(empty.limitations).toContain( - "No retained record had a parseable origin; the manifest names no primary origin.", + expect(empty.limitations).toContainEqual( + expect.stringMatching( + /no retained record.*parseable origin.*no primary origin/iu, + ), ); }); @@ -1002,8 +1006,10 @@ it("does not compare routes beyond the sibling-variance segment limit and says s `/api/items/b2/${tail}`, "/api/things/{thing_id}", ]); - expect(projected.limitations).toContain( - "Routes with more than 32 segments are not compared by sibling-variance; they keep their grouped templates.", + expect(projected.limitations).toContainEqual( + expect.stringMatching( + /32 segments.*not compared.*sibling-variance.*grouped templates/iu, + ), ); }); @@ -1399,8 +1405,10 @@ it("shares no format-rule position and compares no route beyond the segment limi "PATCH /api/teams/t3", "GET /api/teams/{numeric}", ]); - expect(projected.limitations).toContain( - "Routes with more than 32 segments are not compared by prefix-variance; they keep their grouped templates.", + expect(projected.limitations).toContainEqual( + expect.stringMatching( + /32 segments.*not compared.*prefix-variance.*grouped templates/iu, + ), ); }); diff --git a/src/domain/analysisView/environmentStateScenarioTiming.test.ts b/src/domain/analysisView/environmentStateScenarioTiming.test.ts index 5fe2d701e8..8295ce53d5 100644 --- a/src/domain/analysisView/environmentStateScenarioTiming.test.ts +++ b/src/domain/analysisView/environmentStateScenarioTiming.test.ts @@ -149,8 +149,10 @@ it("falls back to the observed time with no duration when a request never ended }); expect(records.records[0]?.reported).not.toHaveProperty("time"); expect(records.records[1]?.reported).not.toHaveProperty("time"); - expect(records.limitations).toContain( - "2 scenario request(s) had no unique reported duration; their duration is omitted and does not establish zero-length execution.", + expect(records.limitations).toContainEqual( + expect.stringMatching( + /2 scenario request.*no unique reported duration.*omitted.*does not establish zero-length execution/iu, + ), ); expect(records.records[2]?.reported).toMatchObject({ startedDateTime: "2026-10-17T18:00:02.000Z", diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index f85149622c..7a068d9426 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -1217,8 +1217,10 @@ it("ends every type's state at a write whose URL was redacted, and says so", () expect(state.entities).toMatchObject([ { fields: { title: "Granite Ledger" }, record_ordinals: [0] }, ]); - expect(state.limitations).toContain( - "1 of the recorded writes had a redacted URL; each ends the initial state of every type, since its target is unknown.", + expect(state.limitations).toContainEqual( + expect.stringMatching( + /1.*writes.*redacted URL.*ends the initial state of every type.*target is unknown/iu, + ), ); }); @@ -1508,8 +1510,10 @@ it.each([ ); expect(capture).toEqual(before); if (redactedWrite) - expect(state.limitations).toContain( - "1 of the recorded writes had a redacted URL; each ends the initial state of every type, since its target is unknown.", + expect(state.limitations).toContainEqual( + expect.stringMatching( + /1 .*writes.*redacted URL.*ends the initial state of every type.*target is unknown/iu, + ), ); }, ); diff --git a/src/domain/analysisView/machoView.test.ts b/src/domain/analysisView/machoView.test.ts index 5e918f08aa..57f6d79885 100644 --- a/src/domain/analysisView/machoView.test.ts +++ b/src/domain/analysisView/machoView.test.ts @@ -398,7 +398,9 @@ describe("Mach-O analysis views", () => { exhausted: true, }); }); +}); +describe("Mach-O view uncertainty", () => { it("keeps encryption unknown for Evidence retained before it was reported", () => { const { encryption: _omitted, ...legacy } = report; const projected = projectAnalysisView(parent(legacy), { kind: "summary" }); @@ -411,8 +413,10 @@ describe("Mach-O analysis views", () => { }, unknowns: [expect.stringContaining("exhaustive imports, encryption;")], }); - expect(projected.value.limitations).toContain( - "This inspect_macho Evidence predates encryption reporting; slice encryption status is unknown.", + expect(projected.value.limitations).toContainEqual( + expect.stringMatching( + /inspect_macho Evidence.*predates encryption reporting.*slice encryption status is unknown/iu, + ), ); }); diff --git a/src/domain/analysisView/scenarioCaptureRecords.test.ts b/src/domain/analysisView/scenarioCaptureRecords.test.ts index 25d9dfdbbf..b0cf3f96f5 100644 --- a/src/domain/analysisView/scenarioCaptureRecords.test.ts +++ b/src/domain/analysisView/scenarioCaptureRecords.test.ts @@ -166,8 +166,10 @@ describe("scenarioCaptureRecords", () => { bodySize: -1, }, }); - expect(manifestLimitations(projected)).toContain( - "1 of 7 scenario transactions with a usable request line retained no response body; tools citing them replay only the recorded status and content type, either of which may be absent.", + expect(manifestLimitations(projected)).toContainEqual( + expect.stringMatching( + /1 of 7.*usable request line.*no response body.*status and content type.*may be absent/iu, + ), ); }); diff --git a/src/domain/apkSignature/apkSignatureReport.test.ts b/src/domain/apkSignature/apkSignatureReport.test.ts index 9cf9abc9f0..d4ff281558 100644 --- a/src/domain/apkSignature/apkSignatureReport.test.ts +++ b/src/domain/apkSignature/apkSignatureReport.test.ts @@ -268,10 +268,17 @@ describe("deriveApkPosture v1 comparison", () => { it("explains unknown identities on both sides without repeating reasons", () => { const result = v1([null], [null, null]); expect(result.posture.v1_signers_match_block_signers).toBeNull(); - expect(result.notes).toEqual([ - "v1_signers_match_block_signers is null: one or more v1 signer identities are unknown; inspect the v1 signer records for details.", - "v1_signers_match_block_signers is null: one or more v2 signer identities are unknown; inspect the block signer records for details.", - ]); + expect(result.notes).toHaveLength(2); + expect(result.notes).toContainEqual( + expect.stringMatching( + /v1_signers_match_block_signers.*null.*v1 signer identities.*unknown.*inspect.*v1 signer records/iu, + ), + ); + expect(result.notes).toContainEqual( + expect.stringMatching( + /v1_signers_match_block_signers.*null.*v2 signer identities.*unknown.*inspect.*block signer records/iu, + ), + ); expect(v1(["aa"], ["aa"]).notes).toEqual([]); }); diff --git a/src/domain/apple/appleApplication.test.ts b/src/domain/apple/appleApplication.test.ts index b8f2852d9b..5ce6153e0b 100644 --- a/src/domain/apple/appleApplication.test.ts +++ b/src/domain/apple/appleApplication.test.ts @@ -492,8 +492,8 @@ describe("Apple bundle layouts and containers", () => { status: "partial", inventory_complete: true, }); - expect(result.limitations).toContain( - "The DMG's contents were not inventoried on this host; bundle absence is unknown.", + expect(result.limitations).toContainEqual( + expect.stringMatching(/DMG.*not inventoried.*absence is unknown/i), ); }); @@ -691,8 +691,8 @@ describe("macOS application scope", () => { [source, target].every((path) => path.startsWith("Fixture.app/")), ), ).toBe(true); - expect(result.limitations).toContain( - "4 inventoried entries outside every application root are not attributed to the application.", + expect(result.limitations).toContainEqual( + expect.stringMatching(/4 inventoried entries outside.*not attributed/i), ); }); diff --git a/src/domain/apple/keyedArchive.test.ts b/src/domain/apple/keyedArchive.test.ts index 227909e688..b50ba3938c 100644 --- a/src/domain/apple/keyedArchive.test.ts +++ b/src/domain/apple/keyedArchive.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { MAX_PLIST_DEPTH, plistNestingLimitMessage } from "./plistValue.js"; +import { MAX_PLIST_DEPTH } from "./plistValue.js"; import { projectKeyedArchive } from "./keyedArchive.js"; import { UnknownPlistScalar } from "./unknownPlistScalar.js"; @@ -46,7 +46,7 @@ describe("projectKeyedArchive input bound", () => { offset: 0, limit: 1, }), - ).toThrow(plistNestingLimitMessage()); + ).toThrow(/128 nesting levels/iu); }); }); diff --git a/src/domain/apple/machoSectionHeader.test.ts b/src/domain/apple/machoSectionHeader.test.ts index 57cbe023e3..f0939e3dd3 100644 --- a/src/domain/apple/machoSectionHeader.test.ts +++ b/src/domain/apple/machoSectionHeader.test.ts @@ -71,7 +71,7 @@ it.each(formats)( ok: false, error: { kind: "malformed", - message: "Mach-O section header is truncated", + message: expect.stringContaining("truncated"), }, }); }, diff --git a/src/domain/apple/machoSegmentHeader.test.ts b/src/domain/apple/machoSegmentHeader.test.ts index c3004128e5..45e4c6ac03 100644 --- a/src/domain/apple/machoSegmentHeader.test.ts +++ b/src/domain/apple/machoSegmentHeader.test.ts @@ -77,7 +77,7 @@ it.each(formats)( ok: false, error: { kind: "malformed", - message: "Mach-O segment header is truncated", + message: expect.stringContaining("truncated"), }, }); }, diff --git a/src/domain/apple/plistValue.ts b/src/domain/apple/plistValue.ts index 421feaff4d..499f0c7177 100644 --- a/src/domain/apple/plistValue.ts +++ b/src/domain/apple/plistValue.ts @@ -19,9 +19,7 @@ const jsonScalarSchema = z.union([ export const MAX_PLIST_DEPTH = 128; /** Agent-visible refusal for nesting past the JSON projection allowance. */ -export const plistNestingLimitMessage = ( - maximumDepth = MAX_PLIST_DEPTH, -): string => +const plistNestingLimitMessage = (maximumDepth = MAX_PLIST_DEPTH): string => `Plist exceeds ${String(maximumDepth)} nesting levels. This release has no runtime override for this ${String(maximumDepth)}-level nesting allowance.`; /** A source-value depth refusal, distinct from malformed decoder output. */ diff --git a/src/domain/artifactInventoryEvidence.test.ts b/src/domain/artifactInventoryEvidence.test.ts index 748c32412c..1f49a7264a 100644 --- a/src/domain/artifactInventoryEvidence.test.ts +++ b/src/domain/artifactInventoryEvidence.test.ts @@ -92,8 +92,9 @@ it("rejects an inspection carrier whose Evidence subject disagrees with its nest } catch (cause: unknown) { expect(cause).toBeInstanceOf(EvidenceIntegrityError); expect(cause).toMatchObject({ - message: - "Artifact inspection Evidence subject does not match its root manifest", + message: expect.stringMatching( + /Evidence subject.*does not match.*root manifest/i, + ), cause: expect.any(ArtifactInventoryValidationError), }); } diff --git a/src/domain/browserScenarioDiff.test.ts b/src/domain/browserScenarioDiff.test.ts index 3aa244a3cd..0033f017e2 100644 --- a/src/domain/browserScenarioDiff.test.ts +++ b/src/domain/browserScenarioDiff.test.ts @@ -308,8 +308,8 @@ it("reports a shared unknown redirect predecessor only once", () => { for (const diff of diffs) { expect(diff.status).toBe("unknown"); expect(diff.before_sha256).toBe(diff.after_sha256); - expect(diff.reason).toBe( - "Redirect predecessor evidence is missing; network transaction identity remains unknown.", + expect(diff.reason).toMatch( + /redirect predecessor evidence.*missing.*network transaction identity.*unknown/iu, ); } }); diff --git a/src/domain/bundleComparison.test.ts b/src/domain/bundleComparison.test.ts index 406389608d..24ec3251e0 100644 --- a/src/domain/bundleComparison.test.ts +++ b/src/domain/bundleComparison.test.ts @@ -139,8 +139,10 @@ describe("bundle comparison", () => { ]), }); expect(first.changes).toHaveLength(3); - expect(first.limitations).toContain( - "EvidenceBundle has no inventory completeness marker; one-sided membership remains unknown.", + expect(first.limitations).toContainEqual( + expect.stringMatching( + /no inventory completeness marker.*membership remains unknown/i, + ), ); }); @@ -260,9 +262,11 @@ describe("bundle comparison history", () => { expect.objectContaining({ classification: "unknown", conclusion_kind: "unresolved_branch", - limitations: [ - "EvidenceBundle has no inventory completeness marker; one-sided history membership remains unknown.", - ], + limitations: expect.arrayContaining([ + expect.stringMatching( + /no inventory completeness marker.*history membership remains unknown/i, + ), + ]), }), ); }); diff --git a/src/domain/canonicalDigest.test.ts b/src/domain/canonicalDigest.test.ts index 649e8b0fdf..d15be905fe 100644 --- a/src/domain/canonicalDigest.test.ts +++ b/src/domain/canonicalDigest.test.ts @@ -59,21 +59,17 @@ describe("incremental canonical digest", () => { [Number.NaN, "nan"], [Number.POSITIVE_INFINITY, "infinity"], [Number.NEGATIVE_INFINITY, "infinity"], + [cyclic, "circular"], + [recursiveJson, "circular"], ] as const) { - expect(() => digestCanonicalValue(value)).toThrow(CanonicalDigestError); + let failure: unknown; try { digestCanonicalValue(value); } catch (cause) { - expect(cause).toMatchObject({ _tag: "CanonicalDigestError", code }); - } - } - for (const value of [cyclic, recursiveJson]) { - expect(() => digestCanonicalValue(value)).toThrow(CanonicalDigestError); - try { - digestCanonicalValue(value); - } catch (cause) { - expect(cause).toMatchObject({ code: "circular" }); + failure = cause; } + expect(failure).toBeInstanceOf(CanonicalDigestError); + expect(failure).toMatchObject({ _tag: "CanonicalDigestError", code }); } expect(() => digestCanonicalValue(1n)).toThrow(); }); diff --git a/src/domain/canonicalJsonDigestSteps.test.ts b/src/domain/canonicalJsonDigestSteps.test.ts index c51d1a296a..e02e0dac8d 100644 --- a/src/domain/canonicalJsonDigestSteps.test.ts +++ b/src/domain/canonicalJsonDigestSteps.test.ts @@ -82,21 +82,18 @@ describe("cooperative canonical JSON digest", () => { const cycle: { [key: string]: JsonValue } = {}; cycle.self = cycle; expect(() => complete(canonicalJsonDigestSteps(sparse))).toThrow(); - expect(() => complete(canonicalJsonDigestSteps(Number.NaN))).toThrow( - CanonicalDigestError, - ); - expect(() => complete(canonicalJsonDigestSteps(cycle))).toThrow( - CanonicalDigestError, - ); - try { - complete(canonicalJsonDigestSteps(Number.NaN)); - } catch (cause) { - expect(cause).toMatchObject({ code: "nan" }); - } - try { - complete(canonicalJsonDigestSteps(cycle)); - } catch (cause) { - expect(cause).toMatchObject({ code: "circular" }); + for (const [value, code] of [ + [Number.NaN, "nan"], + [cycle, "circular"], + ] as const) { + let failure: unknown; + try { + complete(canonicalJsonDigestSteps(value)); + } catch (cause) { + failure = cause; + } + expect(failure).toBeInstanceOf(CanonicalDigestError); + expect(failure).toMatchObject({ code }); } }); }); diff --git a/src/domain/desktop/appExportInstall.test.ts b/src/domain/desktop/appExportInstall.test.ts index 3b494032e3..2f4de7bbb3 100644 --- a/src/domain/desktop/appExportInstall.test.ts +++ b/src/domain/desktop/appExportInstall.test.ts @@ -272,10 +272,12 @@ describe("exportInstall with the program in another package", () => { ok: false, error: { field: "capture", - reason: - "The desktop entry belongs to vim-common, but the computer cannot install the program it runs: no package owns /home/ada/.local/bin/vim.", - remediation: - "Capture again with the packaged program first on PATH, or with --executable naming it.", + reason: expect.stringMatching( + /vim-common.*cannot install.*no package owns \/home\/ada\/\.local\/bin\/vim/i, + ), + remediation: expect.stringMatching( + /capture again.*PATH.*--executable/i, + ), }, }); }); @@ -337,8 +339,9 @@ describe("exportInstall with repositories the export leaves out", () => { expect(refused).toEqual({ ok: false, error: expect.objectContaining({ - reason: - "The desktop entry belongs to typora, but the program it runs belongs to typora-bin, which the computer cannot install: only apt repositories the export leaves out offer it.", + reason: expect.stringMatching( + /typora.*typora-bin.*cannot install.*apt repositories.*leaves out/i, + ), }), }); }); @@ -413,9 +416,12 @@ describe("exportInstall sources", () => { keySha256: "a".repeat(64), }, ]); - expect(install.value.skipped).toEqual([ - "https://apt.example.com/repo stable extra: apt takes one key per URI and suite, and apt-keys/typora.gpg is declared for them", - ]); + expect(install.value.skipped).toHaveLength(1); + expect(install.value.skipped[0]).toContain( + "https://apt.example.com/repo stable extra", + ); + expect(install.value.skipped[0]).toContain("apt-keys/typora.gpg"); + expect(install.value.skipped[0]).toMatch(/one key per URI and suite/iu); expect(install.value.architectures).toEqual(["amd64"]); }); @@ -459,9 +465,12 @@ describe("exportInstall sources", () => { ); expect(install.ok && install.value.architectures).toEqual(["arm64"]); - expect(install.ok && install.value.skipped).toEqual([ - "https://amd64-only.example.com ./: it serves only amd64, not the capture host's arm64", - ]); + if (!install.ok) throw new Error(install.error.reason); + expect(install.value.skipped).toHaveLength(1); + expect(install.value.skipped[0]).toContain( + "https://amd64-only.example.com ./", + ); + expect(install.value.skipped[0]).toMatch(/only amd64.*not.*arm64/iu); expect( exportInstall(typora([repository()]), "typora", "riscv64"), ).toMatchObject({ @@ -833,8 +842,9 @@ describe("exportInstall refusals for a package file", () => { refusedWith(varicad({ package_file: undefined }), VARICAD_URL), ).toEqual({ field: "capture", - reason: - "--package-url was given, but the capture recorded no package file for varicad2026-en.", + reason: expect.stringMatching( + /--package-url.*no package file.*varicad2026-en/i, + ), remediation: expect.stringContaining("Capture again with --package-file"), }); expect( @@ -872,9 +882,10 @@ describe("exportInstall refusals for a package file", () => { expect(archive).toEqual({ field: "package_url", - reason: - "--package-url is not needed: the computer installs varicad2026-en from the Ubuntu 24.04 archive by name.", - remediation: "Export without --package-url.", + reason: expect.stringMatching( + /--package-url.*not needed.*varicad2026-en.*Ubuntu 24\.04 archive/i, + ), + remediation: expect.stringMatching(/export without --package-url/i), }); expect(vendor.reason).toContain( "from the vendor apt repository https://downloads.typora.io/linux.", @@ -887,8 +898,9 @@ describe("exportInstall refusals for a package file", () => { ])("refuses --package-url %s, which agentenv does not download", (url) => { expect(refusedWith(varicad(), url)).toMatchObject({ field: "package_url", - reason: - "--package-url does not start with http:// or https:// in lowercase, the only URLs the computer downloads", + reason: expect.stringMatching( + /--package-url.*http:\/\/.*https:\/\/.*lowercase/i, + ), }); }); diff --git a/src/domain/elfDynamicEntry.test.ts b/src/domain/elfDynamicEntry.test.ts index 8b194bdf65..327b3f438c 100644 --- a/src/domain/elfDynamicEntry.test.ts +++ b/src/domain/elfDynamicEntry.test.ts @@ -51,7 +51,10 @@ it.each(formats)( ), ).toEqual({ ok: false, - error: { kind: "malformed", message: "ELF dynamic entry is truncated" }, + error: { + kind: "malformed", + message: expect.stringContaining("truncated"), + }, }); }, ); diff --git a/src/domain/elfHashSymbolCount.test.ts b/src/domain/elfHashSymbolCount.test.ts index 77df9bd8ac..43834fcfa3 100644 --- a/src/domain/elfHashSymbolCount.test.ts +++ b/src/domain/elfHashSymbolCount.test.ts @@ -31,11 +31,14 @@ it("reads nchain from a complete DT_HASH table", () => { expect(readElfHashSymbolCount(words([1]))).toEqual( err({ kind: "malformed", - message: "DT_HASH header is outside the image", + message: expect.stringMatching(/DT_HASH header.*outside/iu), }), ); expect(readElfHashSymbolCount(words([1, 3, 0]))).toEqual( - err({ kind: "malformed", message: "DT_HASH table is outside the image" }), + err({ + kind: "malformed", + message: expect.stringMatching(/DT_HASH table.*outside/iu), + }), ); expect(readElfHashSymbolCount(words([1, 9]), 8)).toEqual( err({ kind: "budget", label: "DT_HASH symbol table", count: 9 }), @@ -54,16 +57,16 @@ it("follows the last DT_GNU_HASH chain to its end", () => { }); it("rejects DT_GNU_HASH tables that do not describe a chain", () => { - const malformed = (values: readonly number[], message: string) => + const malformed = (values: readonly number[], reason: RegExp) => expect(finish(readElfGnuHashSymbolCount(words(values), 8))).toEqual( - err({ kind: "malformed", message }), + err({ kind: "malformed", message: expect.stringMatching(reason) }), ); - malformed([1, 2, 1], "DT_GNU_HASH header is outside the image"); - malformed([0, 2, 1, 0], "DT_GNU_HASH buckets are malformed"); - malformed([1, 2, 0, 0], "DT_GNU_HASH buckets are malformed"); - malformed([1, 2, 1, 0, 0, 0], "DT_GNU_HASH buckets is outside the image"); - malformed(gnuTable([1], [1]), "DT_GNU_HASH bucket precedes its first symbol"); - malformed(gnuTable([2], [0]), "DT_GNU_HASH chain is outside the image"); + malformed([1, 2, 1], /header.*outside/iu); + malformed([0, 2, 1, 0], /buckets.*malformed/iu); + malformed([1, 2, 0, 0], /buckets.*malformed/iu); + malformed([1, 2, 1, 0, 0, 0], /buckets.*outside/iu); + malformed(gnuTable([1], [1]), /bucket.*precedes.*symbol/iu); + malformed(gnuTable([2], [0]), /chain.*outside/iu); }); it("applies a caller budget before walking each table", () => { diff --git a/src/domain/elfProgramHeader.test.ts b/src/domain/elfProgramHeader.test.ts index acb35aae88..7f519d1b93 100644 --- a/src/domain/elfProgramHeader.test.ts +++ b/src/domain/elfProgramHeader.test.ts @@ -83,7 +83,7 @@ it.each(formats)( ok: false, error: { kind: "malformed", - message: "ELF program header is truncated", + message: expect.stringContaining("truncated"), }, }); }, diff --git a/src/domain/elfRawTableDeclarations.test.ts b/src/domain/elfRawTableDeclarations.test.ts index f1775340de..e0a6854d70 100644 --- a/src/domain/elfRawTableDeclarations.test.ts +++ b/src/domain/elfRawTableDeclarations.test.ts @@ -61,7 +61,7 @@ it.each(formats)( ok: false, error: { kind: "malformed", - message: "ELF table declarations are truncated", + message: expect.stringContaining("truncated"), }, }); }, diff --git a/src/domain/elfRelocation.test.ts b/src/domain/elfRelocation.test.ts index 39dd281333..58a19119ee 100644 --- a/src/domain/elfRelocation.test.ts +++ b/src/domain/elfRelocation.test.ts @@ -67,7 +67,7 @@ it.each(formats)( ok: false, error: { kind: "malformed", - message: "ELF relocation entry is truncated", + message: expect.stringContaining("truncated"), }, }; expect(readElfRelocationEntry(input, bits, littleEndian, "rela")).toEqual( diff --git a/src/domain/elfSectionHeader.test.ts b/src/domain/elfSectionHeader.test.ts index 9f65da31d6..b0d657096d 100644 --- a/src/domain/elfSectionHeader.test.ts +++ b/src/domain/elfSectionHeader.test.ts @@ -84,7 +84,7 @@ it.each(formats)( ok: false, error: { kind: "malformed", - message: "ELF section header is truncated", + message: expect.stringContaining("truncated"), }, }); }, diff --git a/src/domain/elfSymbol.test.ts b/src/domain/elfSymbol.test.ts index e6573b15b3..9b72d3c1ba 100644 --- a/src/domain/elfSymbol.test.ts +++ b/src/domain/elfSymbol.test.ts @@ -58,7 +58,10 @@ it.each(formats)( readElfSymbol(backing.subarray(7, 7 + observed), bits, littleEndian), ).toEqual({ ok: false, - error: { kind: "malformed", message: "ELF symbol record is truncated" }, + error: { + kind: "malformed", + message: expect.stringContaining("truncated"), + }, }); }, ); diff --git a/src/domain/firmware/dfuse.test.ts b/src/domain/firmware/dfuse.test.ts index b174392654..f47a20bc0a 100644 --- a/src/domain/firmware/dfuse.test.ts +++ b/src/domain/firmware/dfuse.test.ts @@ -190,9 +190,13 @@ it("names overlapping elements within one alternate setting only", () => { }), ); expect(decoded.metadata.overlapping_element_count).toBe(2); - expect(decoded.limitations).toEqual([ - "2 DfuSe element(s) overlap an earlier element of the same alternate setting; each stays a separate entry and no bytes were merged: target 0 element 1 (0x08000002-0x08000003) overlaps target 0 element 0 (0x08000000-0x08000003) in alternate setting 0; target 2 element 0 (0x08000003-0x08000004) overlaps target 0 element 0 (0x08000000-0x08000003) in alternate setting 0.", - ]); + expect(decoded.limitations).toHaveLength(1); + const limitation = decoded.limitations[0]; + expect(limitation).toMatch(/overlap/iu); + expect(limitation).toMatch(/no bytes.*merged/iu); + expect(limitation).toMatch(/target 0 element 1[^;]*0x08000002-0x08000003/iu); + expect(limitation).toMatch(/target 0 element 0[^;]*0x08000000-0x08000003/iu); + expect(limitation).toMatch(/target 2 element 0[^;]*0x08000003-0x08000004/iu); }); it("accepts boundary declarations beside the malformed ones", () => { @@ -228,9 +232,15 @@ it("accepts boundary declarations beside the malformed ones", () => { element_count: 2, empty_element_count: 1, }); - expect(decoded.limitations).toEqual([ - "DfuSe declarations without bytes have no entry: target 0 element 1 at 0x20000000 (alternate setting 4) holds no bytes; target 1 (alternate setting 5) declares no elements.", - ]); + expect(decoded.limitations).toHaveLength(1); + const limitation = decoded.limitations[0]; + expect(limitation).toMatch(/no entry/iu); + expect(limitation).toMatch( + /target 0 element 1[^;]*0x20000000[^;]*alternate setting 4/iu, + ); + expect(limitation).toMatch( + /target 1[^;]*alternate setting 5[^;]*no elements/iu, + ); }); it("recognizes a file only by the DfuSe prefix together with the DFU suffix", () => { @@ -400,7 +410,17 @@ it("counts empty elements past the entry cap and names only the first four", () empty_element_count: 65_537, data_bytes: 0, }); - expect(decoded.value.limitations).toEqual([ - "DfuSe declarations without bytes have no entry: target 0 element 0 at 0x00000000 (alternate setting 0) holds no bytes; target 0 element 1 at 0x00000002 (alternate setting 0) holds no bytes; target 0 element 2 at 0x00000004 (alternate setting 0) holds no bytes; target 0 element 3 at 0x00000006 (alternate setting 0) holds no bytes; and 65534 more.", - ]); + expect(decoded.value.limitations).toHaveLength(1); + const limitation = decoded.value.limitations[0]; + expect(limitation).toMatch(/no entry/iu); + for (const [index, address] of [ + [0, "0x00000000"], + [1, "0x00000002"], + [2, "0x00000004"], + [3, "0x00000006"], + ] as const) + expect(limitation).toMatch( + new RegExp(`element ${index}[^;]*${address}`, "iu"), + ); + expect(limitation).toContain("65534 more"); }); diff --git a/src/domain/firmware/espFlashLayout.test.ts b/src/domain/firmware/espFlashLayout.test.ts index 330b3f5b40..e9cdec8dd6 100644 --- a/src/domain/firmware/espFlashLayout.test.ts +++ b/src/domain/firmware/espFlashLayout.test.ts @@ -179,9 +179,9 @@ describe("ESP partition tables", () => { expect(boot.otaSlots.get(0)?.label).toBe("again"); expect(boot.otaAppCount).toBe(3); expect(boot.limitations).toEqual([ - "The table declares otadata 2 times (partitions #1, #7); ESP-IDF's bootloader uses the last.", - "The table declares factory 2 times (partitions #3, #6); ESP-IDF's bootloader uses the last.", - "The table declares ota_0 2 times (partitions #4, #8); ESP-IDF's bootloader uses the last.", + expect.stringMatching(/otadata.*2.*#1.*#7.*bootloader.*last/iu), + expect.stringMatching(/factory.*2.*#3.*#6.*bootloader.*last/iu), + expect.stringMatching(/ota_0.*2.*#4.*#8.*bootloader.*last/iu), ]); }); }); diff --git a/src/domain/firmware/espFlashPlan.test.ts b/src/domain/firmware/espFlashPlan.test.ts index c73317421e..7565e8457b 100644 --- a/src/domain/firmware/espFlashPlan.test.ts +++ b/src/domain/firmware/espFlashPlan.test.ts @@ -168,13 +168,17 @@ describe("ESP flash layout plans", () => { ["partitions/gone.bin", 0xf_f000, 0], ]); expect(plan.limitations).toEqual([ - "Partition early at 0x0 starts before the end of the partition table at 0x9000; gen_esp32part.py rejects such a table.", - "Partition early at 0x0 begins before the dump's first byte at flash 0x1000; it is not read.", - "Partition cut at 0x30000-0x40000 is truncated: the dump holds 36864 of its 65536 bytes.", - "Partition gone at 0x100000-0x101000 lies outside the dump, which ends at flash 0x39000.", + expect.stringMatching( + /early.*0x0.*before.*table.*0x9000.*gen_esp32part/iu, + ), + expect.stringMatching(/early.*0x0.*before.*0x1000.*not read/iu), + expect.stringMatching(/cut.*0x30000-0x40000.*truncated.*36864.*65536/iu), + expect.stringMatching(/gone.*0x100000-0x101000.*outside.*0x39000/iu), ]); }); +}); +describe("ESP flash layout overlaps", () => { it("reports every region inside a longer one", () => { const plan = planEspFlashLayout( input({ @@ -186,8 +190,12 @@ describe("ESP flash layout plans", () => { }), ); expect(plan.limitations).toEqual([ - "partitions/b.bin at 0xa000 overlaps partitions/a.bin, which ends at 0x11000.", - "partitions/c.bin at 0xc000 overlaps partitions/a.bin, which ends at 0x11000.", + expect.stringMatching( + /partitions\/b\.bin.*0xa000.*overlaps.*partitions\/a\.bin.*0x11000/iu, + ), + expect.stringMatching( + /partitions\/c\.bin.*0xc000.*overlaps.*partitions\/a\.bin.*0x11000/iu, + ), ]); }); }); @@ -284,8 +292,10 @@ describe("ESP flash layout otadata", () => { expect(plan.otadata?.records.map(({ fileOffset }) => fileOffset)).toEqual([ 0x4_0000, 0x4_1000, ]); - expect(plan.limitations).toContain( - "The table declares otadata 2 times (partitions #1, #6); ESP-IDF's bootloader uses the last.", + expect(plan.limitations).toContainEqual( + expect.stringMatching( + /otadata 2 times.*#1.*#6.*bootloader uses the last/iu, + ), ); }); @@ -297,8 +307,10 @@ describe("ESP flash layout otadata", () => { expect(plan.otadata?.records).toEqual([ { index: 0, fileOffset: 0xd000, available: true }, ]); - expect(plan.limitations).toContain( - "The otadata partition otadata is 0x1000 bytes, smaller than the two 4 KiB sectors ESP-IDF's bootloader reads; the bootloader resets without selecting an app.", + expect(plan.limitations).toContainEqual( + expect.stringMatching( + /otadata.*0x1000 bytes.*smaller than.*two 4 KiB sectors.*bootloader resets without selecting an app/iu, + ), ); const facts = finishEspFlashLayout(plan, { otadataRecords: [otaRecord({ seq: 1 })], @@ -342,8 +354,10 @@ describe("ESP flash layout otadata", () => { { index: 0, fileOffset: 0xd000, available: true }, { index: 1, fileOffset: 0xe000, available: false }, ]); - expect(plan.limitations).toContain( - "otadata record 1 of partition otadata at flash 0xe000 is outside the dump; the OTA selection is unknown.", + expect(plan.limitations).toContainEqual( + expect.stringMatching( + /otadata record 1.*partition otadata.*0xe000.*outside the dump.*OTA selection is unknown/iu, + ), ); const facts = finishEspFlashLayout(plan, { otadataRecords: [otaRecord({ seq: 3 }), undefined], diff --git a/src/domain/firmware/hexRecords.test.ts b/src/domain/firmware/hexRecords.test.ts index de67cbdccd..ec327092e9 100644 --- a/src/domain/firmware/hexRecords.test.ts +++ b/src/domain/firmware/hexRecords.test.ts @@ -213,7 +213,7 @@ describe("Intel HEX lines, termination and limits", () => { expect(decoded.regions).toHaveLength(2); expect(decoded.metadata.termination).toBe("absent"); expect(decoded.limitations).toEqual([ - "Intel HEX has no termination record; the file may be truncated.", + expect.stringMatching(/no termination record.*may be truncated/iu), ]); }); @@ -240,7 +240,7 @@ describe("Intel HEX lines, termination and limits", () => { ok: false, error: { kind: "format", - message: "line 1: line exceeds the 521-character Intel HEX maximum", + message: expect.stringMatching(/line 1.*521.*Intel HEX/iu), }, }); }); diff --git a/src/domain/firmware/uf2.test.ts b/src/domain/firmware/uf2.test.ts index 4fa3c39f2b..63b83be2e7 100644 --- a/src/domain/firmware/uf2.test.ts +++ b/src/domain/firmware/uf2.test.ts @@ -314,7 +314,9 @@ it("leaves coverage unknown when one copy of reused numbering lost a block", () ambiguous_numbering_group_count: 1, }); expect(decoded.limitations).toEqual([ - "UF2 numbering cannot show that every copy of a block sequence is complete: family 0xc47e5767 (ESP32S3) with 3 numbered blocks has some numbers at 1 address(es) and others at 2.", + expect.stringMatching( + /numbering.*complete.*0xc47e5767.*ESP32S3.*3.*1 address.*2/iu, + ), ]); } }); @@ -352,8 +354,10 @@ it("excludes not-main-flash and file-container blocks from regions", () => { family_count: 0, missing_block_count: 1, }); - expect(decoded.limitations).toContain( - "1 UF2 block(s) are flagged not main flash (flag 0x1); their 3 payload bytes are in no region.", + expect(decoded.limitations).toContainEqual( + expect.stringMatching( + /1 UF2 block.*not main flash.*0x1.*3 payload bytes.*no region/iu, + ), ); const file = uf2Block({ address: 0, @@ -371,8 +375,10 @@ it("excludes not-main-flash and file-container blocks from regions", () => { missing_block_count: 0, region_count: 1, }); - expect(contained.limitations).toContain( - "1 UF2 block(s) belong to file containers (flag 0x1000); their files were not reconstructed.", + expect(contained.limitations).toContainEqual( + expect.stringMatching( + /1 UF2 block.*file containers.*0x1000.*not reconstructed/iu, + ), ); }); @@ -470,7 +476,9 @@ it("counts MD5 and extension-tag flags as declared, unverified facts", () => { }); expect(decoded.complete).toBe(true); expect(decoded.limitations).toEqual([ - "1 UF2 block(s) declare an MD5 flash region (flag 0x4000) and 1 declare extension tags (flag 0x8000); both are counted, not decoded or verified.", + expect.stringMatching( + /1.*MD5.*0x4000.*1.*extension.*0x8000.*not decoded or verified/iu, + ), ]); }); diff --git a/src/domain/fixedAllowanceRemediation.test.ts b/src/domain/fixedAllowanceRemediation.test.ts index fd430ad46d..f892dc1f9d 100644 --- a/src/domain/fixedAllowanceRemediation.test.ts +++ b/src/domain/fixedAllowanceRemediation.test.ts @@ -6,7 +6,6 @@ import { import { projectAnalysisError } from "./analysisErrorProjection.js"; import { fixedAllowanceOptions, - fixedAllowanceRemediation, type FixedAllowanceKind, } from "./fixedAllowanceRemediation.js"; @@ -28,6 +27,21 @@ const forbidden = { work: [/worker CPU limits and observed signal/u], } as const; +const required = { + parser: + /worker memory limits.*host memory do not raise.*complete input.*unchanged request.*fails/iu, + count: /fixed count.*waiting.*does not raise.*unchanged request.*fails/iu, + bytes: + /fixed byte allowance.*waiting.*does not raise.*unchanged request.*fails/iu, + report: + /does not leave a completed report or Evidence record.*CLI JSON does not raise.*unchanged request.*fails/iu, + input: /complete artifact.*evidence-reply.*does not admit.*do not truncate/iu, + capture: + /fixed capture allowance.*host memory do not raise.*unchanged request.*fails/iu, + live: /fixed capture allowance.*host memory do not raise.*live output may change/iu, + work: /fixed work allowance.*CPU time do not raise.*complete artifact.*unchanged request.*fails/iu, +} as const; + describe("resource constraint recovery shape", () => { it("keeps a fixed allowance and a remediation action from co-occurring", () => { const allowance = new AnalysisResourceConstraintError( @@ -106,31 +120,10 @@ describe("fixed allowance recovery", () => { ); const projected = projectAnalysisError(error); expect(projected.retryable).toBe(false); - expect(projected.remediation.action).toBe( - fixedAllowanceRemediation(allowance, kind), - ); expect(projected.remediation.action).toContain(allowance); + expect(projected.remediation.action).toMatch(required[kind]); for (const pattern of forbidden[kind]) expect(projected.remediation.action).not.toMatch(pattern); - if (kind === "parser") { - expect(projected.remediation.action).toMatch( - /worker memory limits.*host memory do not raise/iu, - ); - expect(projected.remediation.action).toMatch( - /complete input.*allowance/iu, - ); - expect(projected.remediation.action).toMatch( - /unchanged request.*fails/iu, - ); - } - if (kind === "live") - expect(projected.remediation.action).toContain( - "live output may change between requests", - ); - if (kind === "capture" || kind === "count") - expect(projected.remediation.action).toMatch( - /unchanged request.*fails/iu, - ); }, ); }); diff --git a/src/domain/flutter/flutterBuildAnalysis.ts b/src/domain/flutter/flutterBuildAnalysis.ts index 0bd925fbab..b7d93af1cb 100644 --- a/src/domain/flutter/flutterBuildAnalysis.ts +++ b/src/domain/flutter/flutterBuildAnalysis.ts @@ -28,7 +28,7 @@ const ABI_SELECTOR_MAX_CHARACTERS = * budget as JSON Schema maxLength, including every trailing line terminator. */ /** Shared refusal for an ABI label that is not a lib/ directory name. */ -export const ABI_SELECTOR_MESSAGE = `ABI must be the exact lib/ directory label: 1–${String(ABI_SELECTOR_MAX_CHARACTERS)} Unicode characters without '/'.`; +const ABI_SELECTOR_MESSAGE = `ABI must be the exact lib/ directory label: 1–${String(ABI_SELECTOR_MAX_CHARACTERS)} Unicode characters without '/'.`; const abiSelectorSchema = z .string() .min(1, ABI_SELECTOR_MESSAGE) diff --git a/src/domain/functionComparison.test.ts b/src/domain/functionComparison.test.ts index 9947f6a7e5..4b76fe85f9 100644 --- a/src/domain/functionComparison.test.ts +++ b/src/domain/functionComparison.test.ts @@ -1538,8 +1538,9 @@ it.each([ { path: ["status"], code: "custom", - message: - "Function comparison status contradicts its dimensions or function match.", + message: expect.stringMatching( + /status contradicts.*dimensions or function match/i, + ), }, ]); } diff --git a/src/domain/inputIssueProjection.test.ts b/src/domain/inputIssueProjection.test.ts index 35411deec0..c547232e3b 100644 --- a/src/domain/inputIssueProjection.test.ts +++ b/src/domain/inputIssueProjection.test.ts @@ -11,7 +11,6 @@ import { processScenarioSchema } from "./process/processScenario.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; import { APPLICATION_TOOL_CONTRACTS } from "../contracts/applicationToolContracts.js"; import { ENHANCED_TOOL_CONTRACTS } from "../contracts/enhancedToolContracts.js"; -import { knownArgumentMistakeGuidance } from "./inputArgumentGuidance.js"; const projectFlattenedIssueGroups = ( ...args: Parameters @@ -163,8 +162,6 @@ describe("input issue argument guidance", () => { "procedure_pseudo_code", "procedure_assembly", ] as const) { - const guidance = knownArgumentMistakeGuidance(op, "address"); - expect(guidance).toBeDefined(); const parsed = officialContract(op).inputSchema.safeParse(input); if (parsed.success) throw new Error("expected invalid input"); const error = analysisInputErrorFromIssues( @@ -178,19 +175,17 @@ describe("input issue argument guidance", () => { path: ["address"], reason: "unknown_argument", expected: "procedure", - message: guidance!.message, + message: expect.stringMatching(/address.*procedure/i), }, { path: ["count"], reason: "unknown_argument" }, ]); const action = projectAnalysisError(error).remediation.action; - expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toMatch(/procedure.*address/i); expect(action).toContain(op); } const searchInput = { query: "flag" }; for (const op of ["search_strings", "search_procedures"] as const) { - const guidance = knownArgumentMistakeGuidance(op, "query"); - expect(guidance).toBeDefined(); const parsed = officialContract(op).inputSchema.safeParse(searchInput); if (parsed.success) throw new Error("expected invalid input"); const searchError = analysisInputErrorFromIssues( @@ -204,11 +199,11 @@ describe("input issue argument guidance", () => { path: ["query"], reason: "unknown_argument", expected: "pattern", - message: guidance!.message, + message: expect.stringMatching(/query.*pattern/i), }, ]); const action = projectAnalysisError(searchError).remediation.action; - expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toMatch(/pattern.*query/i); expect(action).toContain(op); } }); @@ -216,8 +211,6 @@ describe("input issue argument guidance", () => { it("guides callers for symmetric neighboring-tool argument names", () => { const addressInput = { procedure: "main" }; for (const op of ["resolve_containing_procedure", "read_bytes"] as const) { - const guidance = knownArgumentMistakeGuidance(op, "procedure"); - expect(guidance).toBeDefined(); const parsed = officialContract(op).inputSchema.safeParse(addressInput); if (parsed.success) throw new Error("expected invalid input"); const error = analysisInputErrorFromIssues( @@ -231,16 +224,14 @@ describe("input issue argument guidance", () => { path: ["procedure"], reason: "unknown_argument", expected: "address", - message: guidance!.message, + message: expect.stringMatching(/procedure.*address/i), }, ]); const action = projectAnalysisError(error).remediation.action; - expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toMatch(/address.*procedure/i); expect(action).toContain(op); } - const xrefsGuidance = knownArgumentMistakeGuidance("xrefs", "procedure"); - expect(xrefsGuidance).toBeDefined(); const xrefsParsed = officialContract("xrefs").inputSchema.safeParse(addressInput); if (xrefsParsed.success) throw new Error("expected invalid input"); @@ -254,11 +245,11 @@ describe("input issue argument guidance", () => { path: ["procedure"], reason: "unknown_argument", expected: "address", - message: xrefsGuidance!.message, + message: expect.stringMatching(/procedure.*address/i), }, ]); const xrefsAction = projectAnalysisError(xrefsError).remediation.action; - expect(xrefsAction.startsWith(xrefsGuidance!.remediation)).toBe(true); + expect(xrefsAction).toMatch(/address.*procedure/i); expect(xrefsAction).toContain("xrefs"); const queryInput = { pattern: "flag" }; @@ -273,8 +264,6 @@ describe("input issue argument guidance", () => { }, ]; for (const { op, contract } of queryCases) { - const guidance = knownArgumentMistakeGuidance(op, "pattern"); - expect(guidance).toBeDefined(); const parsed = contract.inputSchema.safeParse(queryInput); if (parsed.success) throw new Error("expected invalid input"); const error = analysisInputErrorFromIssues( @@ -288,11 +277,11 @@ describe("input issue argument guidance", () => { path: ["pattern"], reason: "unknown_argument", expected: "query", - message: guidance!.message, + message: expect.stringMatching(/pattern.*query/i), }, ]); const action = projectAnalysisError(error).remediation.action; - expect(action.startsWith(guidance!.remediation)).toBe(true); + expect(action).toMatch(/query.*pattern/i); expect(action).toContain(op); } }); @@ -318,12 +307,7 @@ describe("input issue argument guidance suppression", () => { { path: ["address"], reason: "unknown_argument" }, ]); const action = projectAnalysisError(error).remediation.action; - const suppressedGuidance = knownArgumentMistakeGuidance( - "annotate_native_function", - "address", - ); - expect(suppressedGuidance).toBeDefined(); - expect(action).not.toContain(suppressedGuidance!.remediation); + expect(action).not.toMatch(/procedure.*instead of.*address/i); expect(action).toContain("annotate_native_function"); const budgeted = projectFlattenedIssueGroups( @@ -347,19 +331,14 @@ describe("input issue argument guidance suppression", () => { parsedMissing.error.issues, inputMissingExpected, ); - const missingGuidance = knownArgumentMistakeGuidance( - "annotate_native_function", - "address", - ); - expect(missingGuidance).toBeDefined(); expect(errorMissing.issues).toContainEqual({ path: ["address"], reason: "unknown_argument", expected: "procedure", - message: missingGuidance!.message, + message: expect.stringMatching(/address.*procedure/i), }); const missingAction = projectAnalysisError(errorMissing).remediation.action; - expect(missingAction.startsWith(missingGuidance!.remediation)).toBe(true); + expect(missingAction).toMatch(/procedure.*address/i); }); }); @@ -454,11 +433,6 @@ describe("bounded input issue projection", () => { ), ).toBeUndefined(); - const guidance = knownArgumentMistakeGuidance( - "read_function_instructions", - "address", - ); - expect(guidance).toBeDefined(); expect( projectFlattenedIssueGroups( [issue], @@ -471,7 +445,7 @@ describe("bounded input issue projection", () => { path: ["address"], reason: "unknown_argument", expected: "procedure", - message: guidance!.message, + message: expect.stringMatching(/address.*procedure/i), }, ]); }); diff --git a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts index 6bad09007f..dce3de89ec 100644 --- a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts +++ b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts @@ -364,8 +364,10 @@ describe("feature trace projection from contextBridge source members", () => { total_seed_matches: 0, }); expect(result.evidence_links).toEqual([sourceEvidenceId]); - expect(result.limitations).toContain( - "No application graph entity matched the literal seed; this is not evidence that the feature is absent.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /no application graph entity matched.*not evidence.*feature is absent/iu, + ), ); }); @@ -396,8 +398,10 @@ describe("feature trace projection from contextBridge source members", () => { }); expect(result.graph?.coverage).toEqual(partialGraph.coverage); expect(result.graph?.nodes).toEqual(partialGraph.nodes); - expect(result.limitations).toContain( - "The source application graph is incomplete; unmatched seeds and frontiers remain unknown.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /source application graph.*incomplete.*unmatched seeds and frontiers.*unknown/iu, + ), ); }); }); diff --git a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts index f0b128ac2e..1230bee860 100644 --- a/src/domain/javascript/javascriptSemanticGraphQuery.test.ts +++ b/src/domain/javascript/javascriptSemanticGraphQuery.test.ts @@ -775,7 +775,7 @@ it("preserves a transferred graph independent of the caller retained copy", () = const invalidGraphCases: { readonly label: string; readonly change: (input: JavaScriptSemanticGraphInput) => void; - readonly message: string; + readonly issuePath: readonly string[]; }[] = [ { label: "node identity and ownership", @@ -790,7 +790,7 @@ const invalidGraphCases: { : node, ); }, - message: "Node identifier is stale", + issuePath: ["nodes", "node_id"], }, { label: "relation endpoints and authority", @@ -798,11 +798,11 @@ const invalidGraphCases: { input.relations = input.relations.map((relation) => ({ ...relation, source_node_id: `jsrg_node_${"f".repeat(64)}`, - target_node_id: `jsrg_node_${"f".repeat(64)}`, + target_node_id: `jsrg_node_${"e".repeat(64)}`, resolution: "candidate", })); }, - message: "Relation endpoints must name semantic nodes", + issuePath: ["relations"], }, { label: "unknown identity and candidates", @@ -812,7 +812,7 @@ const invalidGraphCases: { candidate_node_ids: [`jsrg_node_${"f".repeat(64)}`], })); }, - message: "Unknown frontier candidate node is absent", + issuePath: ["unknowns", "candidate_node_ids"], }, { label: "fingerprint identity and components", @@ -824,7 +824,7 @@ const invalidGraphCases: { components: { ...fingerprint.components, parameter_arity: 42 }, })); }, - message: "Fingerprint component commitment is stale", + issuePath: ["fingerprints", "fingerprint_sha256"], }, { label: "coverage completeness and references", @@ -836,13 +836,13 @@ const invalidGraphCases: { unknown_ids: [`jsrg_unknown_${"f".repeat(64)}`], })); }, - message: "Family retained relation count does not match graph content", + issuePath: ["coverage", "families", "retained_relations"], }, ]; it.each(invalidGraphCases)( "retains full validation diagnostics for $label in owned steps", - ({ change, message }) => { + ({ change, issuePath }) => { const input = semanticFixtureInput(true); change(input); const issues = (action: () => unknown) => { @@ -855,7 +855,15 @@ it.each(invalidGraphCases)( throw new Error("Expected invalid graph to fail validation"); }; const expected = issues(() => createJavaScriptSemanticGraph(input)); - expect(expected.map(({ message }) => message)).toContain(message); + expect( + expected.some(({ path }) => { + const keys = path.filter((part) => typeof part === "string"); + return ( + keys.length === issuePath.length && + keys.every((key, index) => key === issuePath[index]) + ); + }), + ).toBe(true); const steps = sealTransferredJavaScriptSemanticGraphSteps( structuredClone(input), ); diff --git a/src/domain/javascript/sourceToBundleComparison.test.ts b/src/domain/javascript/sourceToBundleComparison.test.ts index a52604541f..de688284b8 100644 --- a/src/domain/javascript/sourceToBundleComparison.test.ts +++ b/src/domain/javascript/sourceToBundleComparison.test.ts @@ -450,8 +450,9 @@ describe("source-map declaration resolution", () => { expectedSource: "webpack://a.js", expectedResolution: { kind: "unresolved", - reason: - "Source reference uses a non-hierarchical URL without a file pathname.", + reason: expect.stringMatching( + /non-hierarchical URL.*without a file pathname/i, + ), }, }, { diff --git a/src/domain/native/elfNoteLayout.test.ts b/src/domain/native/elfNoteLayout.test.ts index 9df61746fa..76738235d4 100644 --- a/src/domain/native/elfNoteLayout.test.ts +++ b/src/domain/native/elfNoteLayout.test.ts @@ -30,7 +30,10 @@ it.each([false, true])( readElfNoteHeader(bytes.subarray(7, 7 + observed), littleEndian), ).toEqual({ ok: false, - error: { kind: "malformed", message: "ELF note header is truncated" }, + error: { + kind: "malformed", + message: expect.stringContaining("truncated"), + }, }); }, ); diff --git a/src/domain/peCoffHeader.test.ts b/src/domain/peCoffHeader.test.ts index 46ef8c2e10..d65c40b872 100644 --- a/src/domain/peCoffHeader.test.ts +++ b/src/domain/peCoffHeader.test.ts @@ -27,7 +27,10 @@ it.each(Array.from({ length: 20 }, (_, length) => length))( const source = Buffer.alloc(40, 0xff); expect(readPeCoffHeader(source.subarray(7, 7 + length))).toEqual({ ok: false, - error: { kind: "malformed", message: "PE COFF header is truncated" }, + error: { + kind: "malformed", + message: expect.stringContaining("truncated"), + }, }); }, ); diff --git a/src/domain/peOptionalHeader.test.ts b/src/domain/peOptionalHeader.test.ts index 96e5cdb4d6..94d4b85527 100644 --- a/src/domain/peOptionalHeader.test.ts +++ b/src/domain/peOptionalHeader.test.ts @@ -117,7 +117,7 @@ it.each(formats)("rejects a partial $format directory entry", (format) => { error: { kind: "malformed", reason: "truncated-directory-table", - message: "truncated PE data-directory table", + message: expect.stringMatching(/truncated.*directory/iu), }, }); }); @@ -224,14 +224,17 @@ it("keeps structural refusal ahead of conflicting machine width", () => { bytes.writeUInt32LE(1, 92); expect(readPeOptionalHeader(bytes, 0x8664)).toEqual({ ok: false, - error: { kind: "malformed", message: "truncated PE data-directory table" }, + error: { + kind: "malformed", + message: expect.stringMatching(/truncated.*directory/iu), + }, }); bytes.writeUInt32LE(0, 92); expect(readPeOptionalHeader(bytes, 0x8664)).toEqual({ ok: false, error: { kind: "malformed", - message: "PE optional-header magic does not match its architecture", + message: expect.stringMatching(/magic.*architecture/iu), }, }); }); diff --git a/src/domain/propertyListKeys.test.ts b/src/domain/propertyListKeys.test.ts index ebdd1d30a0..7e1e250aa1 100644 --- a/src/domain/propertyListKeys.test.ts +++ b/src/domain/propertyListKeys.test.ts @@ -1,10 +1,7 @@ import { describe, expect, it } from "vitest"; import { parse } from "plist"; -import { - PlistNestingLimitError, - plistNestingLimitMessage, -} from "./apple/plistValue.js"; +import { PlistNestingLimitError } from "./apple/plistValue.js"; import { parseXmlPropertyList as parseWithNumbers } from "./propertyListKeys.js"; // Numeric source facts have separate source-type and ambiguity controls. @@ -218,22 +215,28 @@ const nestedArrays = (depth: number) => it("rejects XML nesting beyond the projection limit with the limit diagnostic", () => { // Source depth must report the shared limit before recursive value decoding. - const limit = new PlistNestingLimitError(128); - expect(() => parseXmlPropertyList(nestedDictionaries(129))).toThrow(limit); - expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow(limit); - expect(() => parseXmlPropertyList(nestedDictionaries(3000))).toThrow(limit); - expect(() => parseXmlPropertyList(nestedArrays(4000))).toThrow(limit); + expect(() => parseXmlPropertyList(nestedArrays(129))).toThrow( + PlistNestingLimitError, + ); + expect(() => parseXmlPropertyList(nestedDictionaries(3000))).toThrow( + PlistNestingLimitError, + ); + expect(() => parseXmlPropertyList(nestedArrays(4000))).toThrow( + PlistNestingLimitError, + ); + let failure: unknown; try { parseXmlPropertyList(nestedDictionaries(129)); - throw new Error("Expected default nesting refusal"); } catch (cause: unknown) { - expect(cause).toBeInstanceOf(RangeError); - expect(cause).toMatchObject({ - _tag: "PlistNestingLimitError", - maximumDepth: 128, - message: plistNestingLimitMessage(), - }); + failure = cause; } + expect(failure).toBeInstanceOf(PlistNestingLimitError); + expect(failure).toBeInstanceOf(RangeError); + expect(failure).toMatchObject({ + _tag: "PlistNestingLimitError", + maximumDepth: 128, + message: expect.stringMatching(/128 nesting levels.*no runtime override/iu), + }); }); it("decodes XML property lists exactly at the projection nesting limit", () => { @@ -268,9 +271,7 @@ it.each(['"', "'"])( const text = plist( opening.repeat(3000) + "x" + "".repeat(3000), ); - expect(() => parseXmlPropertyList(text)).toThrow( - new PlistNestingLimitError(128), - ); + expect(() => parseXmlPropertyList(text)).toThrow(PlistNestingLimitError); expect( parseXmlPropertyList( plist( @@ -295,9 +296,7 @@ it("ignores complete DOCTYPE internal subsets when checking value depth", () => it("bounds nested plist wrappers before recursive decoding", () => { const text = "".repeat(3000) + "x" + "".repeat(3000); - expect(() => parseXmlPropertyList(text)).toThrow( - new PlistNestingLimitError(128), - ); + expect(() => parseXmlPropertyList(text)).toThrow(PlistNestingLimitError); expect( parseXmlPropertyList(plist("x")).value, ).toEqual(["x"]); @@ -336,7 +335,7 @@ describe("OpenStep plist owned depth", () => { "rejects %s nested OpenStep values with the shared depth limit", (depth) => { expect(() => parseXmlPropertyList(arrays(depth))).toThrow( - plistNestingLimitMessage(), + /128 nesting levels/iu, ); }, ); diff --git a/src/domain/propertyListOpenStep.test.ts b/src/domain/propertyListOpenStep.test.ts index 03c1ef3e2f..6bf1066c24 100644 --- a/src/domain/propertyListOpenStep.test.ts +++ b/src/domain/propertyListOpenStep.test.ts @@ -1,7 +1,6 @@ import { expect, it } from "vitest"; import { parseOpenStep } from "plist"; -import { plistNestingLimitMessage } from "./apple/plistValue.js"; import { decodeOpenStepPropertyList, OpenStepPropertyListError, @@ -105,12 +104,12 @@ it("bounds compound keys and later siblings at their owning parse", () => { const deep = "(".repeat(30_000) + "x" + ")".repeat(30_000); for (const source of [`((x),${deep})`, `{(key)=${deep};}`, `{${deep}=x;}`]) expect(() => decodeOpenStepPropertyList(source)).toThrow( - plistNestingLimitMessage(), + /128 nesting levels/iu, ); // A scalar key identifies its parent's member rather than adding value depth. expect(decodeOpenStepPropertyList("{ key = (); }", 1)).toEqual({ key: [] }); expect(() => decodeOpenStepPropertyList("{ (key) = (); }", 1)).toThrow( - plistNestingLimitMessage(1), + /1 nesting levels/iu, ); }); diff --git a/src/domain/propertyListOpenStepComments.test.ts b/src/domain/propertyListOpenStepComments.test.ts index 851e66d45f..2b0092ebf1 100644 --- a/src/domain/propertyListOpenStepComments.test.ts +++ b/src/domain/propertyListOpenStepComments.test.ts @@ -1,6 +1,5 @@ import { expect, it } from "vitest"; -import { plistNestingLimitMessage } from "./apple/plistValue.js"; import { parseXmlPropertyList } from "./propertyListKeys.js"; it("preserves array values and comment-like string content after leading trivia", () => { @@ -24,7 +23,7 @@ it("preserves own prototype keys through commented OpenStep dispatch", () => { it("retains the source depth limit after a leading comment", () => { expect(() => parseXmlPropertyList(`// header\n${"(".repeat(129)}x${")".repeat(129)}`), - ).toThrow(plistNestingLimitMessage()); + ).toThrow(/128 nesting levels/iu); }); it.each(["// header only", "/* unterminated", "/* header */ not-a-container"])( diff --git a/src/domain/reconstructionVerification.test.ts b/src/domain/reconstructionVerification.test.ts index ddd2bb25e7..52fcd5a486 100644 --- a/src/domain/reconstructionVerification.test.ts +++ b/src/domain/reconstructionVerification.test.ts @@ -349,16 +349,18 @@ it("reports a missing comparison reference as caller input with its claim path", throw new Error("Expected a missing comparison reference to be rejected"); } catch (error: unknown) { expect(error).toBeInstanceOf(AnalysisInputError); + if (!(error instanceof AnalysisInputError)) throw error; expect(error).toMatchObject({ - userMessage: `Missing comparison Evidence ${comparison.evidence_id}`, issues: [ { path: ["claims", 0, "comparison_evidence_id"], reason: "invalid_value", - message: `Missing comparison Evidence ${comparison.evidence_id}`, }, ], }); + expect(error.userMessage).toContain(comparison.evidence_id); + expect(error.userMessage).toMatch(/missing.*comparison/iu); + expect(error.issues[0]?.message).toBe(error.userMessage); } }); diff --git a/src/domain/skillManifest.test.ts b/src/domain/skillManifest.test.ts index 9b374d0864..22a97b54e2 100644 --- a/src/domain/skillManifest.test.ts +++ b/src/domain/skillManifest.test.ts @@ -40,7 +40,7 @@ it("does not read counterfeit metadata from the Markdown body", () => { }); expect(addSkillManifestToolCount(content, 5)).toMatchObject({ ok: false, - error: { message: "Missing authored skill version" }, + error: { message: expect.stringMatching(/missing.*version/iu) }, }); }); diff --git a/src/domain/sourceMapEnvelope.test.ts b/src/domain/sourceMapEnvelope.test.ts index eaa3649738..253e57a413 100644 --- a/src/domain/sourceMapEnvelope.test.ts +++ b/src/domain/sourceMapEnvelope.test.ts @@ -125,8 +125,9 @@ describe("source-map envelope walk", () => { invalid_sections: [ { section_path: [0], - reason: - "Indexed source-map section requires an embedded map or external URL.", + reason: expect.stringMatching( + /source-map section requires.*embedded map or external URL/i, + ), }, ], }); diff --git a/src/domain/webBundleAnalyzer.test.ts b/src/domain/webBundleAnalyzer.test.ts index c1df025881..208852408e 100644 --- a/src/domain/webBundleAnalyzer.test.ts +++ b/src/domain/webBundleAnalyzer.test.ts @@ -548,8 +548,9 @@ describe("web bundle source-map coverage", () => { expect(analysis.completeness.status).toBe("partial"); expect(analysis.unknowns).toContainEqual({ dimension: "source_maps", - reason: - "One or more requested source maps were unavailable or incomplete", + reason: expect.stringMatching( + /requested source maps.*unavailable or incomplete/i, + ), affected_script_keys: [`scr_${"1".repeat(64)}`], }); }); diff --git a/src/dotnet/ManagedMemberInspector.test.ts b/src/dotnet/ManagedMemberInspector.test.ts index 3842ebe585..468696357b 100644 --- a/src/dotnet/ManagedMemberInspector.test.ts +++ b/src/dotnet/ManagedMemberInspector.test.ts @@ -515,8 +515,9 @@ it.each([ code: "invalid-row", scope, offset: blobOffset + operandOffset, - detail: - "TypeDefOrRefOrSpec operand 0x7 is invalid: coded index tag 3 is not defined", + detail: expect.stringMatching( + /TypeDefOrRefOrSpec.*0x7.*tag 3.*not defined/i, + ), }); }, ); @@ -1692,14 +1693,15 @@ describe("managed user string issues", () => { code: "invalid-heap-index", scope: "metadata.#US:0x06000001", offset: null, - detail: "ldstr operand does not name an item inside #US", + detail: expect.stringMatching(/ldstr operand.*does not name.*#US/i), }, { code: "missing-stream", scope: "metadata.#US", offset: null, - detail: - "2 distinct ldstr operand(s) reference #US, but the metadata has no #US stream", + detail: expect.stringMatching( + /2 distinct ldstr.*#US.*no #US stream/i, + ), }, ], }); @@ -2478,8 +2480,9 @@ describe("managed metadata reference bounds", () => { issues: [ expect.objectContaining({ code: "invalid-row", - detail: - "MemberRef parent coded index 0x0 is null, but the Class column must reference a row", + detail: expect.stringMatching( + /MemberRef parent.*0x0.*null.*Class.*reference a row/i, + ), }), ], }); diff --git a/src/dotnet/ManagedNativeBoundaryInspector.test.ts b/src/dotnet/ManagedNativeBoundaryInspector.test.ts index 256e48440b..384d0c4d6d 100644 --- a/src/dotnet/ManagedNativeBoundaryInspector.test.ts +++ b/src/dotnet/ManagedNativeBoundaryInspector.test.ts @@ -166,8 +166,9 @@ describe("managed native boundary coded indexes", () => { issues: [ expect.objectContaining({ code: "invalid-row", - detail: - "ImplMap MemberForwarded coded index 0x0 is null, but the column must reference a Field or MethodDef row", + detail: expect.stringMatching( + /ImplMap MemberForwarded.*0x0.*null.*Field or MethodDef/i, + ), }), ], }); diff --git a/src/filesystem/ChangeTimeBaseline.ts b/src/filesystem/ChangeTimeBaseline.ts index 4f4ac98dec..6b2ad18218 100644 --- a/src/filesystem/ChangeTimeBaseline.ts +++ b/src/filesystem/ChangeTimeBaseline.ts @@ -69,7 +69,7 @@ const changeTimeMs = (stats: Stats): number => * Whether an observed ChangeTime can still move on its own, so recording it now * would create a baseline a later observation is entitled to disagree with. */ -export const changeTimeMayStillDrift = ( +const changeTimeMayStillDrift = ( stats: Stats, seams: ChangeTimeSettleSeams = {}, ): boolean => { diff --git a/src/firmware/FirmwareFiles.ts b/src/firmware/FirmwareFiles.ts index 612062b08d..bf66d6b49d 100644 --- a/src/firmware/FirmwareFiles.ts +++ b/src/firmware/FirmwareFiles.ts @@ -501,7 +501,7 @@ const firmwareStagingLimit = ( * staged file that reaches the producer's limit may be truncated. That limit * sits one byte above every budget unless an inherited ceiling removed the byte. */ -export const firmwareFileSizeLimitReached = ( +const firmwareFileSizeLimitReached = ( operation: string, fileSizeBytes: number, ) => diff --git a/src/flutter/DartElf.test.ts b/src/flutter/DartElf.test.ts index 8ac9c4e911..82c0af451e 100644 --- a/src/flutter/DartElf.test.ts +++ b/src/flutter/DartElf.test.ts @@ -346,7 +346,9 @@ describe("ELF symbol-table ownership", () => { expect((await readElfSymbols(image, [name])).symbols).toEqual([]); }, ); +}); +describe("stripped ELF symbol-table ownership", () => { it("uses DT_HASH's count when strings precede symbols instead of reading following payload", async () => { const image = strippedImageWithSections(); const dynamic = Number(image.readBigUInt64LE(64 + 56 + 8)); @@ -363,8 +365,9 @@ describe("ELF symbol-table ownership", () => { { name: "_kDartVmSnapshotData", offset: 0x400, size: 52 }, ]); expect(result.failures).toContainEqual({ - reason: - "symbol _kDartIsolateSnapshotData is not in the dynamic symbol table", + reason: expect.stringMatching( + /_kDartIsolateSnapshotData.*not in the dynamic symbol table/i, + ), }); image.writeBigUInt64LE(0x60000001n, dynamic + 4 * 16); // No recognized count table. expect( @@ -487,8 +490,9 @@ describe.each(Object.entries(ELF_CLASSES))( symbols: [], failures: [ { - reason: - "Dynamic symbol table section offset disagrees with its PT_LOAD mapping", + reason: expect.stringMatching( + /dynamic symbol table.*section offset.*disagrees.*PT_LOAD/i, + ), }, ], }); diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 9009d2fe5c..cdeff4be34 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -28,7 +28,6 @@ import { type SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; import { - applyDebuggerAdmissionOverride, resolveDebuggerSessionLimits, type DebuggerAdmissionLimit, } from "../config/debuggerSessionLimits.js"; @@ -488,8 +487,8 @@ class GdbProtocolFailureError extends Error { } } -/** @internal Convert an unknown boundary failure without dropping non-Error values. */ -export const gdbErrorMessage = (cause: unknown): string => +/** Convert an unknown boundary failure without dropping non-Error values. */ +const gdbErrorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); interface GdbSession { @@ -586,8 +585,6 @@ export class GdbSessionManager { constructor( options: { readonly environment?: Readonly; - /** @internal Test seam. Production uses REA_DEBUGGER_COMMAND_DEADLINE_MS. */ - readonly commandTimeoutMs?: number; readonly startupTimeoutMs?: number; readonly platform?: NodeJS.Platform; readonly stopProcess?: typeof stopOwnedDebuggerProcess; @@ -600,12 +597,6 @@ export class GdbSessionManager { ) => Promise; /** @internal Read-only process identity observer for lifecycle tests. */ readonly readInferiorProcessStartTime?: ProcessStartTimeReader; - /** @internal Test seam. Production uses REA_DEBUGGER_MAX_SESSIONS. */ - readonly maxSessions?: number; - /** @internal Test seam. Production uses REA_DEBUGGER_MAX_PENDING_COMMANDS. */ - readonly maxPendingCommands?: number; - /** @internal Test seam. Production uses REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES. */ - readonly maxCommandOutputBytes?: number; /** * @internal Test seam for startup and detach MI truncation. * Production uses the fixed control-reply budget. @@ -616,10 +607,7 @@ export class GdbSessionManager { this.#environment = { ...(options.environment ?? process.env) }; this.#command = this.#environment.REA_GDB_COMMAND ?? "gdb"; const limits = resolveDebuggerSessionLimits(this.#environment); - this.#commandDeadline = applyDebuggerAdmissionOverride( - limits.commandDeadline, - options.commandTimeoutMs, - ); + this.#commandDeadline = limits.commandDeadline; this.#commandTimeoutMs = this.#commandDeadline.value; this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS; this.#platform = options.platform ?? process.platform; @@ -628,18 +616,9 @@ export class GdbSessionManager { this.#verifyInferiorOutputSink = options.verifyInferiorOutputSink ?? verifyGdbInferiorOutputSink; this.#readInferiorProcessStartTime = options.readInferiorProcessStartTime; - this.#maxSessions = applyDebuggerAdmissionOverride( - limits.maxSessions, - options.maxSessions, - ); - this.#maxPendingCommands = applyDebuggerAdmissionOverride( - limits.maxPendingCommands, - options.maxPendingCommands, - ); - this.#commandOutput = applyDebuggerAdmissionOverride( - limits.maxCommandOutputBytes, - options.maxCommandOutputBytes, - ); + this.#maxSessions = limits.maxSessions; + this.#maxPendingCommands = limits.maxPendingCommands; + this.#commandOutput = limits.maxCommandOutputBytes; this.#maxCommandOutputBytes = this.#commandOutput.value; this.#controlReplyBytes = options.controlReplyBytes ?? DEBUGGER_CONTROL_REPLY_BYTES; @@ -2464,7 +2443,7 @@ export const commandMayChangeInferiorOutput = (command: string): boolean => { }; /** Await a serialized command turn, preserving the caller's cancellation reason. */ -export const waitForGdbCommandTurn = ( +const waitForGdbCommandTurn = ( previous: Promise, signal?: AbortSignal, ): Promise => { diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts index 99de0bdf65..a843bb8e9f 100644 --- a/src/ghidra/GhidraDoctor.test.ts +++ b/src/ghidra/GhidraDoctor.test.ts @@ -18,7 +18,6 @@ import { inspectSystemGhidraProvider, projectGhidraDoctorInspection, } from "./GhidraDoctor.js"; -import { GHIDRA_RESTRICTED_TEMP_REMEDIATION } from "./GhidraTemporaryDirectory.js"; let inspectionResources = createInspectionResources(); afterEach(async () => { @@ -423,11 +422,16 @@ describe("Windows Ghidra temporary directory check", () => { ok: false, code: "temp_directory_unwritable", classification: "runtime_failure", - remediation: GHIDRA_RESTRICTED_TEMP_REMEDIATION, + remediation: expect.stringMatching( + /TMP and TEMP.*allowed tree.*no dot-prefixed element.*restart REA/iu, + ), details: { directory: "C:\\sandbox\\tmp", system_code: "EPERM" }, }); expect(check?.detail).toContain("C:\\sandbox\\tmp"); expect(check?.detail).not.toMatch(/antivirus|DACL/iu); + expect(check?.remediation).toMatch( + /java_home\.save.*creating that file in advance does not help/iu, + ); }); it("reports a retained probe file whose cleanup doctor retries", async () => { diff --git a/src/ghidra/GhidraLoadImageValues.test.ts b/src/ghidra/GhidraLoadImageValues.test.ts index fd3e57bb12..34254ddf50 100644 --- a/src/ghidra/GhidraLoadImageValues.test.ts +++ b/src/ghidra/GhidraLoadImageValues.test.ts @@ -419,6 +419,20 @@ describe("independent DOS MZ import verification", () => { }); if (!result.ok || result.value.status === "unsupported") throw new Error("Expected measured MZ checks"); + expect(result.value.checks).toEqual( + expect.arrayContaining( + [ + "original_file_sha256", + "modified_file_sha256", + "entry_points", + "relocation_records", + "mapping[0].memory_sha256", + "mapping[1].memory_sha256", + "initialized_file_coverage_end", + "initialized_unbacked_mappings", + ].map((name) => expect.objectContaining({ name, matched: true })), + ), + ); expect(result.value.checks.every((check) => check.matched)).toBe(true); expect(result.value.observations).toEqual(fixture.observation); expect(result.value.limitations.join(" ")).toMatch( diff --git a/src/ghidra/GhidraPeProfile.test.ts b/src/ghidra/GhidraPeProfile.test.ts index b8c869564f..e4e0bd9eb1 100644 --- a/src/ghidra/GhidraPeProfile.test.ts +++ b/src/ghidra/GhidraPeProfile.test.ts @@ -80,7 +80,20 @@ it.each([true, false, null])( const caveats = ghidraPePdbLimitations(target, pdb); expect(caveats).toEqual(ghidraPdbLimitations(pdb)); expect(caveats).toHaveLength(1); - expect(caveats.join(" ")).not.toContain('PDB "public.pdb"'); + const warning = caveats[0] ?? ""; + expect(warning).toContain("public.pdb"); + expect(warning).toContain("list_procedures"); + expect(warning).toMatch(/names and types may (?:still )?be missing/i); + expect(warning).toMatch(/empty name search does not prove/i); + if (loaded === true) { + expect(warning).toMatch(/loaded PDB/i); + expect(warning).toMatch(/not an independent PDB identity/i); + } else if (loaded === false) { + expect(warning).toMatch(/not loaded/i); + } else { + expect(warning).toMatch(/load state unknown/i); + } + expect(warning).not.toContain('PDB "public.pdb"'); }, ); @@ -231,8 +244,12 @@ it.each(["signature", "version"] as const)( it("retains missing-report uncertainty without a declared CodeView record", () => { const { codeView: _, ...withoutReference } = target; - expect(ghidraPePdbLimitations(withoutReference, undefined)).toEqual( - ghidraPdbLimitations(undefined), + const caveats = ghidraPePdbLimitations(withoutReference, undefined); + expect(caveats).toEqual(ghidraPdbLimitations(undefined)); + expect(caveats).toContainEqual( + expect.stringMatching( + /did not report PDB attributes.*coverage is unknown/i, + ), ); }); diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index c334268e5b..e40756c31d 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { GHIDRA_LITERAL_CANDIDATES_LIMITATION } from "./GhidraLiteralSearch.js"; import { - EXISTING_PROJECT_WINDOWS_REMEDIATION, EXISTING_PROJECT_WINDOWS_UNAVAILABLE, existingProjectCapabilities, ghidraEmptyIncomingReferencesLimitation, @@ -68,7 +67,6 @@ import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { parseExecutableHeader } from "../domain/binaryTarget.js"; import { elf, thinMach } from "../domain/binaryTarget.fixture.js"; import { GhidraProvider } from "./GhidraProvider.js"; -import { GHIDRA_RESTRICTED_TEMP_REMEDIATION } from "./GhidraTemporaryDirectory.js"; import type { GhidraTargetSnapshot } from "./GhidraClient.js"; import { GHIDRA_ANNOTATIONS_RETAINED, @@ -4483,8 +4481,11 @@ describe("Ghidra missing project program", () => { expect(projected.message).toContain(path); expect(projected.message).toContain("Win32 error 5"); expect(projected.message).not.toMatch(/antivirus|DACL/iu); - expect(projected.remediation.action).toBe( - GHIDRA_RESTRICTED_TEMP_REMEDIATION, + expect(projected.remediation.action).toMatch( + /TMP and TEMP.*allowed tree.*no dot-prefixed element.*restart REA/iu, + ); + expect(projected.remediation.action).toMatch( + /java_home\.save.*creating that file in advance does not help/iu, ); }); @@ -4494,8 +4495,8 @@ describe("Ghidra missing project program", () => { {}, "java.io.FileNotFoundException: C:\\x\\other.txt (Access is denied)\n", ); - expect(projected.remediation.action).not.toBe( - GHIDRA_RESTRICTED_TEMP_REMEDIATION, + expect(projected.remediation.action).not.toMatch( + /TMP and TEMP.*allowed tree/iu, ); }); }); @@ -6494,11 +6495,17 @@ describe("Windows Ghidra readiness repair", () => { }); it("gives every operation of a Windows existing-project session the same repair", () => { - for (const capability of existingProjectCapabilities("win32")) + const capabilities = existingProjectCapabilities("win32"); + expect(capabilities.length).toBeGreaterThan(0); + for (const capability of capabilities) expect(capability).toMatchObject({ available: false, - reason: EXISTING_PROJECT_WINDOWS_UNAVAILABLE, - remediation: EXISTING_PROJECT_WINDOWS_REMEDIATION, + reason: expect.stringMatching( + /existing Ghidra project.*cannot be opened.*Windows/iu, + ), + remediation: expect.stringMatching( + /unset REA_GHIDRA_PROJECT_PATH.*ephemeral project.*Linux or macOS.*restart REA/iu, + ), }); }); }); diff --git a/src/hopper/HopperMachOImage.test.ts b/src/hopper/HopperMachOImage.test.ts index b8245d50a4..06019786f7 100644 --- a/src/hopper/HopperMachOImage.test.ts +++ b/src/hopper/HopperMachOImage.test.ts @@ -835,8 +835,9 @@ it.each([0, 129])( expect(result.error).toMatchObject({ _tag: "BinaryTargetError", path, - reason: - "FAT architecture table is truncated or exceeds the supported 128 entries", + reason: expect.stringMatching( + /FAT architecture table.*truncated.*128 entries/i, + ), }); }, ); diff --git a/src/hopper/HopperRegexSearch.test.ts b/src/hopper/HopperRegexSearch.test.ts index d1ce0b62aa..61674f8e3f 100644 --- a/src/hopper/HopperRegexSearch.test.ts +++ b/src/hopper/HopperRegexSearch.test.ts @@ -322,8 +322,7 @@ it("returns cancellation when the caller aborts after the regex worker settles", const reason = { request: "stop search" }; regexMatch.afterSettle = () => controller.abort(reason); const search = new HopperRegexSearch({ - callTool: async () => - ok([{ address: "0x1000", value: "fixture" }] as never), + callTool: async () => ok([{ address: "0x1000", value: "fixture" }]), }); try { const result = await search.execute( diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 88d175b391..736d978655 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -392,16 +392,21 @@ describe("native IDA provider observations", () => { if (!dossier.ok) throw dossier.error; expect(dossier.value.relationshipCoverage).toMatchObject({ status: "unknown", - reason: more - ? expect.stringContaining( - "Callees: Upstream callees capped results at 500; additional callees remain unknown.", - ) - : expect.stringMatching(/xrefs.*not establish.*caller/iu), + reason: expect.stringMatching(/xrefs.*not establish.*caller/iu), }); - if (more) + if (more) { + const coverage = direct.value.relationshipCoverage; + if (coverage?.status !== "partial") + throw new Error("Expected partial callee coverage"); + const limitation = coverage.limitations[0]; + if (limitation === undefined) + throw new Error("Expected callee limitation"); + expect(limitation).toMatch(/callees.*500.*unknown/iu); expect(dossier.value.relationshipCoverage).toMatchObject({ - reason: expect.stringMatching(/xrefs.*not establish.*caller/iu), + reason: expect.stringContaining(limitation), }); + expect(dossier.value.limitations).toContain(limitation); + } expect(dossier.value.result).toMatchObject({ callers: [], callees: [{ address: "0x2000", name: "puts" }], @@ -410,10 +415,6 @@ describe("native IDA provider observations", () => { callees: { available: true }, }, }); - if (more) - expect(dossier.value.limitations).toContain( - "Upstream callees capped results at 500; additional callees remain unknown.", - ); } finally { await client.close(); } diff --git a/src/ida/IdaProvider.test.ts b/src/ida/IdaProvider.test.ts index f091495678..4ab84847a1 100644 --- a/src/ida/IdaProvider.test.ts +++ b/src/ida/IdaProvider.test.ts @@ -1,4 +1,3 @@ -import { launchRegexMatchingLimitation } from "../process/RegexMatcher.js"; import { AnalysisSettings } from "../application/AnalysisSettings.js"; import { runtimeAnalysisConfig } from "../config/runtimeAnalysisSettings.js"; import { describe, expect, it, vi } from "vitest"; @@ -416,10 +415,9 @@ it("applies runtime regex budgets through a bound IDA client and recovers in the const capabilities = provider .capabilities() .find(({ operation }) => operation === "search_strings"); - expect(capabilities?.limitations).toContain( - launchRegexMatchingLimitation( - { deadlineMs: 100, source: "runtime" }, - { heapMiB: 256, source: "runtime" }, + expect(capabilities?.limitations).toContainEqual( + expect.stringMatching( + /100 ms matching deadline \(source runtime\).*256 MiB worker heap request \(source runtime\).*update_analysis_settings/iu, ), ); settings.update({ @@ -434,10 +432,9 @@ it("applies runtime regex budgets through a bound IDA client and recovers in the expect.objectContaining({ address: "0x3000", value }), ]); if (recovered.ok) { - expect(recovered.value.limitations).toContain( - launchRegexMatchingLimitation( - { deadlineMs: 10000, source: "runtime" }, - { heapMiB: 256, source: "runtime" }, + expect(recovered.value.limitations).toContainEqual( + expect.stringMatching( + /10000 ms matching deadline \(source runtime\).*256 MiB worker heap request \(source runtime\).*update_analysis_settings/iu, ), ); } diff --git a/src/ida/IdaReferenceCoverage.test.ts b/src/ida/IdaReferenceCoverage.test.ts index 930c3a88a7..18fcf2beb5 100644 --- a/src/ida/IdaReferenceCoverage.test.ts +++ b/src/ida/IdaReferenceCoverage.test.ts @@ -42,8 +42,8 @@ describe("headless IDA reference coverage", () => { expect( execution.limitations, "the diagnostic limitation is still reported", - ).toContain( - "Upstream xrefs_to capped results at 1000 references; additional references remain unknown.", + ).toContainEqual( + expect.stringMatching(/xrefs_to.*cap.*1000.*references.*unknown/iu), ); }); diff --git a/src/javascript/recovery/WakaruReport.test.ts b/src/javascript/recovery/WakaruReport.test.ts index ee3d8d4a9d..c78c3ad614 100644 --- a/src/javascript/recovery/WakaruReport.test.ts +++ b/src/javascript/recovery/WakaruReport.test.ts @@ -68,8 +68,9 @@ it("keeps a malformed provenance report distinct from the fixed report allowance if (!(refusal instanceof ProviderAdapterError)) throw new Error("Expected a wrapped Wakaru report refusal"); const projected = projectAnalysisError(refusal); - expect(projected.message).toBe( - "Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.", + expect(projected.message).toMatch(/rea doctor/iu); + expect(projected.message).toMatch( + /unchanged request.*not expected to succeed/iu, ); expect(projected.remediation.action).toBe(projected.message); expect(JSON.stringify(projected)).not.toContain("report allowance"); diff --git a/src/main/messages.test.ts b/src/main/messages.test.ts index cb36e37a6d..9c3961a51a 100644 --- a/src/main/messages.test.ts +++ b/src/main/messages.test.ts @@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url"; import { expect, it } from "vitest"; -import { PROCESS_UNEXPECTED_FAILURE_REMEDIATION } from "../application/process/ProcessCli.js"; import { MCP_CONNECTION_LOST, MCP_CONNECTION_START_FAILED, @@ -25,7 +24,6 @@ it("publishes the canonical recovery messages in the error catalog", async () => MCP_TRANSPORT_ERROR, MCP_VALIDATION_FAILED, SERVER_START_FAILED, - PROCESS_UNEXPECTED_FAILURE_REMEDIATION, ]) expect(catalog).toContain(message); }); diff --git a/src/mcpDoctor.ts b/src/mcpDoctor.ts index a1a808e6c1..6b1652bc38 100644 --- a/src/mcpDoctor.ts +++ b/src/mcpDoctor.ts @@ -224,7 +224,7 @@ class OwnedStdioClientTransport extends StdioClientTransport { } /** A valid scope can intentionally omit the target-free session request. */ -export const MCP_DOCTOR_SESSION_SCOPE_DETAIL = +const MCP_DOCTOR_SESSION_SCOPE_DETAIL = "binary_session is outside the selected tool families; include the session family for request-flow and identity checks"; /** Doctor expectations derived from the same environment the child inherits. */ @@ -406,7 +406,7 @@ export const runProductionMcpDoctorCli = async ( }; /** Compare exact names while retaining actionable mismatch locations. */ -export const compareInventory = ( +const compareInventory = ( expectedNames: readonly string[], observedNames: readonly string[], ): InventoryComparison => { diff --git a/src/native/AppleDispatchMetadata.test.ts b/src/native/AppleDispatchMetadata.test.ts index 032cfca327..46e1e7b9db 100644 --- a/src/native/AppleDispatchMetadata.test.ts +++ b/src/native/AppleDispatchMetadata.test.ts @@ -766,8 +766,9 @@ describe("Swift conformance type-reference kinds", () => { witness_table: { address: null, file_offset: null }, decode: { status: "partial", - reason: - "Indirect Objective-C class metadata requires runtime lookup", + reason: expect.stringMatching( + /indirect Objective-C class metadata.*runtime lookup/i, + ), }, }, ]); @@ -1477,8 +1478,9 @@ describe("Objective-C category class references without a class name", () => { bytes.writeBigUInt64LE(0n, 0xb48); expect(categoryCoverage(bytes)).toMatchObject({ status: "partial", - reason: - "category_class_unresolved at 0x100000b48: Category FixtureCategory class reference is null", + reason: expect.stringMatching( + /category_class_unresolved.*0x100000b48.*FixtureCategory.*class reference is null/i, + ), }); }); }); diff --git a/src/native/MachoProjectionBudget.test.ts b/src/native/MachoProjectionBudget.test.ts index 683606544b..e2520ab135 100644 --- a/src/native/MachoProjectionBudget.test.ts +++ b/src/native/MachoProjectionBudget.test.ts @@ -77,6 +77,7 @@ it("rejects section expansion before reading or allocating section records", asy maximum: 64 * 1024 * 1024, unit: "projected_bytes", }); + expect(offsets).toEqual(expect.arrayContaining([0, 32])); expect(offsets.every((offset) => offset < 104)).toBe(true); }); diff --git a/src/native/NativeMachoInspection.test.ts b/src/native/NativeMachoInspection.test.ts index cdfc6f2619..03ab7b7f56 100644 --- a/src/native/NativeMachoInspection.test.ts +++ b/src/native/NativeMachoInspection.test.ts @@ -377,8 +377,13 @@ describe("Mach-O UUID reconciliation", () => { ); if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ uuid: null }); - expect(result.value.limitations).toContain( - `dwarfdump --uuid (FEDCBA98-7654-3210-FEDC-BA9876543210) conflicts with binary load commands LC_UUID (${validUuid}); the image UUID is unknown.`, + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + new RegExp( + `dwarfdump.*FEDCBA98-7654-3210-FEDC-BA9876543210.*conflict.*LC_UUID.*${validUuid}.*unknown`, + "iu", + ), + ), ); }); @@ -418,8 +423,13 @@ describe("Mach-O UUID reconciliation", () => { ); if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ uuid: null }); - expect(result.value.limitations).toContain( - `binary load commands returned conflicting LC_UUID values ${JSON.stringify(validUuid)} and ${JSON.stringify("FEDCBA98-7654-3210-FEDC-BA9876543210")}`, + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + new RegExp( + `conflicting LC_UUID.*${validUuid}.*FEDCBA98-7654-3210-FEDC-BA9876543210`, + "iu", + ), + ), ); }); @@ -430,8 +440,8 @@ describe("Mach-O UUID reconciliation", () => { ); if (!result.ok) throw result.error; expect(result.value.result).toMatchObject({ uuid: null }); - expect(result.value.limitations).toContain( - "dwarfdump --uuid returned conflicting UUIDs for selected architecture arm64", + expect(result.value.limitations).toContainEqual( + expect.stringMatching(/dwarfdump --uuid.*conflicting.*arm64/iu), ); }); }); @@ -597,8 +607,8 @@ describe("Mach-O encryption reporting", () => { ], }); expect(encryption.items[0]?.architecture).toBeNull(); - expect(limitations).toContain( - "Encryption status of the slice at offset 49152 is malformed: load commands is truncated.", + expect(limitations).toContainEqual( + expect.stringMatching(/49152.*malformed.*load commands is truncated/iu), ); }); @@ -622,17 +632,18 @@ describe("Mach-O encryption reporting", () => { status: "unread", reason: "the target could not be opened for reading (ENOENT)", }); - expect(encryption).toEqual({ + expect(encryption).toMatchObject({ items: [], total: null, exhaustive: false, - limitations: [ - "Encryption load commands were not read: the target could not be opened for reading (ENOENT)", - ], }); - expect(limitations).toContain( - "Encryption load commands were not read: the target could not be opened for reading (ENOENT)", - ); + expect(encryption.limitations).toEqual([ + expect.stringContaining( + "the target could not be opened for reading (ENOENT)", + ), + ]); + expect(encryption.limitations[0]).toMatch(/load commands.*not read/iu); + expect(limitations).toContain(encryption.limitations[0]); }); }); diff --git a/src/native/NativeUiObservation.test.ts b/src/native/NativeUiObservation.test.ts index 8ecf8ed90b..e37f838a84 100644 --- a/src/native/NativeUiObservation.test.ts +++ b/src/native/NativeUiObservation.test.ts @@ -311,12 +311,12 @@ describe("native UI deadline execution", () => { ); if (!result.ok) throw new Error("expected a completed scenario"); const limitation = result.value.limitations.join("\n"); - expect(limitation).toContain("selected native_ui_deadline_ms of 2500 ms"); - expect(limitation).toContain("omitted means 180000"); - expect(limitation).toContain( - "One wait step stays capped at 180000 ms and that cap has no runtime override.", + expect(limitation).toMatch( + /native_ui_deadline_ms.*2500 ms.*omitted.*180000/iu, + ); + expect(limitation).toMatch( + /wait step.*capped at 180000 ms.*no runtime override/iu, ); - expect(limitation).not.toContain("180-second"); }); it("states the omitted 180000 ms deadline separately from the wait-step cap", async () => { @@ -326,11 +326,12 @@ describe("native UI deadline execution", () => { }); if (!result.ok) throw new Error("expected a completed observation"); const limitation = result.value.limitations.join("\n"); - expect(limitation).toContain("selected native_ui_deadline_ms of 180000 ms"); - expect(limitation).toContain( - "One wait step stays capped at 180000 ms and that cap has no runtime override.", + expect(limitation).toMatch( + /native_ui_deadline_ms.*180000 ms.*omitted.*180000/iu, + ); + expect(limitation).toMatch( + /wait step.*capped at 180000 ms.*no runtime override/iu, ); - expect(limitation).not.toContain("180-second"); }); it("reports the owned deadline as a timeout during helper execution", async () => { diff --git a/src/native/apple/AppleIpsParser.test.ts b/src/native/apple/AppleIpsParser.test.ts index 06d72d0d97..a458a1dc52 100644 --- a/src/native/apple/AppleIpsParser.test.ts +++ b/src/native/apple/AppleIpsParser.test.ts @@ -170,17 +170,17 @@ it("reads a 309 crash report, uppercase UUID, and lossless register", () => { it("refuses a stackshot bug_type without a crash body", () => { const bytes = appleIpsBytes({ bugType: "288" }); - expect(() => parseAppleIps(bytes, artifact(bytes), input)).toThrow( - AppleIpsFormatFailure, - ); + let failure: unknown; try { parseAppleIps(bytes, artifact(bytes), input); } catch (cause) { - expect(cause).toMatchObject({ - kind: "unsupported", - message: expect.stringContaining("288"), - }); + failure = cause; } + expect(failure).toBeInstanceOf(AppleIpsFormatFailure); + expect(failure).toMatchObject({ + kind: "unsupported", + message: expect.stringContaining("288"), + }); }); it("fails closed on a truncated crash body", () => { diff --git a/src/native/apple/losslessJson.test.ts b/src/native/apple/losslessJson.test.ts index 42c4b74588..5f08fc64f5 100644 --- a/src/native/apple/losslessJson.test.ts +++ b/src/native/apple/losslessJson.test.ts @@ -131,7 +131,8 @@ it("refuses JSON string escapes for unpaired surrogates", () => { for (const text of ['{"a":"\\ud800"}', '{"a":"ok\\udfff"}']) expect(() => parseLosslessJson(text, 0)).toThrow( expect.objectContaining({ - message: "JSON string contains an unpaired surrogate", + reason: "syntax", + message: expect.stringContaining("surrogate"), }), ); expect(() => parseLosslessJson('{"a":"\\ud83d\\ude00"}', 0)).not.toThrow(); @@ -146,7 +147,8 @@ it("applies the surrogate check to skipped strings and nested keys", () => { ]) expect(() => parseLosslessJson(text, 0, skipped)).toThrow( expect.objectContaining({ - message: "JSON string contains an unpaired surrogate", + reason: "syntax", + message: expect.stringContaining("surrogate"), }), ); expect(parseLosslessJson('{"extra":"ok"}', 0, skipped).value).toEqual({ diff --git a/src/native/elf/ElfNoteParser.test.ts b/src/native/elf/ElfNoteParser.test.ts index 6430aba884..04c7f47c4b 100644 --- a/src/native/elf/ElfNoteParser.test.ts +++ b/src/native/elf/ElfNoteParser.test.ts @@ -506,8 +506,8 @@ it.each([65535, 65536])( : { name: null, name_state: "truncated" }, ); if (terminatorOffset === 65536) - expect(directory.section_names_limitation).toBe( - "ELF section names are unavailable because a name is not NUL-terminated", + expect(directory.section_names_limitation).toMatch( + /section names.*unavailable.*not NUL-terminated/iu, ); }, ); @@ -516,11 +516,13 @@ it("bounds repeated scans of an unterminated section name", () => { const report = parse(unterminatedNoteSections(2, 64)); expect(report.tables.map(({ name }) => name)).toEqual([null, null]); expect(report.coverage.status).toBe("complete"); - expect( - report.limitations.filter((value) => value.includes("not NUL-terminated")), - ).toEqual([ - "ELF section names are incomplete because a name is not NUL-terminated.", - ]); + const nameWarnings = report.limitations.filter((value) => + /not NUL-terminated/iu.test(value), + ); + expect(nameWarnings).toHaveLength(1); + expect(nameWarnings[0]).toMatch( + /section names.*incomplete.*not NUL-terminated/iu, + ); let failure: unknown; try { parse(unterminatedNoteSections(3, 400)); diff --git a/src/native/elf/KernelModuleParser.test.ts b/src/native/elf/KernelModuleParser.test.ts index 3c1e316dcf..426c85d6cc 100644 --- a/src/native/elf/KernelModuleParser.test.ts +++ b/src/native/elf/KernelModuleParser.test.ts @@ -309,22 +309,21 @@ it.each(["gzip", "xz", "zstd"] as const)( ); it("fails closed when the combined entry budget is exhausted", () => { - expect(() => parse(kernelModuleFixture(), { max_entries: 7 })).toThrow( - ArtifactReaderFailure, - ); + let failure: unknown; try { parse(kernelModuleFixture(), { max_entries: 7 }); } catch (error) { - expect(error).toBeInstanceOf(ArtifactReaderFailure); - expect(error).toMatchObject({ reason: "limit", boundary: "capacity" }); + failure = error; } + expect(failure).toBeInstanceOf(ArtifactReaderFailure); + expect(failure).toMatchObject({ reason: "limit", boundary: "capacity" }); }); it("examines the first of two .modinfo sections and records the duplicate", () => { const report = parse(kernelModuleFixture({ repeatModinfo: true })); expect(report.modinfo?.entries).toHaveLength(6); - expect(report.limitations.join("\n")).toContain( - "Multiple .modinfo sections are declared; the first is examined.", + expect(report.limitations).toContainEqual( + expect.stringMatching(/multiple.*\.modinfo.*first.*examined/iu), ); }); @@ -418,8 +417,10 @@ it("names a non-file-backed __ksymtab section without dropping the module", () = bytes.writeUInt32LE(8, 64 + 64 * 4 + 4); const report = parse(bytes); expect(report.ksymtab?.sections).toEqual([]); - expect(report.limitations.join("\n")).toContain( - "1 __ksymtab* section(s) are declared but not file-backed", + expect(report.limitations).toContainEqual( + expect.stringMatching( + /1 __ksymtab\*.*not file-backed.*ranges are unknown/iu, + ), ); expect(report.modinfo?.entries[0]?.key).toBe("license"); }); diff --git a/src/native/metadata/BinaryContainer.test.ts b/src/native/metadata/BinaryContainer.test.ts index ab6f8a98a5..c76edc82e0 100644 --- a/src/native/metadata/BinaryContainer.test.ts +++ b/src/native/metadata/BinaryContainer.test.ts @@ -17,14 +17,14 @@ it("distinguishes a mapped string read allowance from malformed bytes", () => { const index = createFileMappingIndex([ { address: 100n, offset: 0, size: bytes.length }, ]); - expect(() => index.readCString(bytes, 100n, 3)).toThrow( - BinaryStringReadLimitFailure, - ); + let failure: unknown; try { index.readCString(bytes, 100n, 3); } catch (cause) { - expect(cause).toMatchObject({ maximumBytes: 3 }); + failure = cause; } + expect(failure).toBeInstanceOf(BinaryStringReadLimitFailure); + expect(failure).toMatchObject({ maximumBytes: 3 }); expect(index.readCString(bytes, 100n, bytes.length).toString()).toBe( "long-name", ); diff --git a/src/native/metadata/ElfStringTable.test.ts b/src/native/metadata/ElfStringTable.test.ts index f06fedaeec..c26b8b184d 100644 --- a/src/native/metadata/ElfStringTable.test.ts +++ b/src/native/metadata/ElfStringTable.test.ts @@ -31,15 +31,15 @@ it.each([65_536, 70_000])( Buffer.alloc(size, 0x61), Buffer.from([0]), ]); - expect(() => readElfStringBytes(table, 1)).toThrow( - BinaryStringReadLimitFailure, - ); + let failure: unknown; try { readElfStringBytes(table, 1); } catch (cause) { - expect(cause).toBeInstanceOf(BinaryFormatFailure); - expect(cause).toMatchObject({ maximumBytes: 65_536 }); + failure = cause; } + expect(failure).toBeInstanceOf(BinaryStringReadLimitFailure); + expect(failure).toBeInstanceOf(BinaryFormatFailure); + expect(failure).toMatchObject({ maximumBytes: 65_536 }); }, ); diff --git a/src/native/metadata/PeImage.test.ts b/src/native/metadata/PeImage.test.ts index 60174ff0fb..df3f1b6932 100644 --- a/src/native/metadata/PeImage.test.ts +++ b/src/native/metadata/PeImage.test.ts @@ -71,7 +71,7 @@ it.each(["NE", "LE", "LX"])( expect(() => readBinaryContainer(bytes)).toThrow( expect.objectContaining({ kind: "unsupported", - message: `${signature} images are outside the supported ELF, PE and thin Mach-O formats`, + message: expect.stringContaining(signature), }), ); }, diff --git a/src/native/parsers/lipo.test.ts b/src/native/parsers/lipo.test.ts index bd5a51bba1..38451f41d7 100644 --- a/src/native/parsers/lipo.test.ts +++ b/src/native/parsers/lipo.test.ts @@ -72,9 +72,12 @@ describe("lipo numeric field observations", () => { kind: "uninterpretable", sourceLines: [sourceLine], }); - expect(architecture?.limitations).toEqual([ - `lipo ${field} for arm64 could not be interpreted; source line: ${sourceLine}`, - ]); + expect(architecture?.limitations).toHaveLength(1); + expect(architecture?.limitations[0]).toContain(sourceLine); + expect(architecture?.limitations[0]).toContain(field); + expect(architecture?.limitations[0]).toMatch( + /lipo.*arm64.*could not be interpreted/iu, + ); }, ); @@ -280,12 +283,12 @@ describe("lipo FAT operand framing", () => { (path) => { const output = `Fat header in: ${path}\n${fatRecords}`; - expect(parseLipoOutput(output, path)).toEqual( - parseLipoOutput(fatRecords), - ); - expect( - parseLipoOutput(output, path).architectures.map(({ name }) => name), - ).toEqual(["arm64", "x86_64"]); + const parsed = parseLipoOutput(output, path); + expect(parsed).toEqual(parseLipoOutput(fatRecords)); + expect(parsed.architectures.map(({ name }) => name)).toEqual([ + "arm64", + "x86_64", + ]); }, ); @@ -293,7 +296,12 @@ describe("lipo FAT operand framing", () => { const path = "/fixture/first\r\narchitecture forged\r\n size 64"; const output = `Fat header in: ${path}\r\n${fatRecords.replaceAll("\n", "\r\n")}`; - expect(parseLipoOutput(output, path)).toEqual(parseLipoOutput(fatRecords)); + const parsed = parseLipoOutput(output, path); + expect(parsed).toEqual(parseLipoOutput(fatRecords)); + expect(parsed.architectures).toMatchObject([ + { name: "arm64", file_offset: 4096, size: 32 }, + { name: "x86_64", file_offset: 8192, size: 32 }, + ]); }); it("preserves ordinary FAT metadata with and without an operand", () => { diff --git a/src/native/pe/PeImportParser.test.ts b/src/native/pe/PeImportParser.test.ts index 4f58c8205e..23454da7b4 100644 --- a/src/native/pe/PeImportParser.test.ts +++ b/src/native/pe/PeImportParser.test.ts @@ -114,8 +114,9 @@ it("does not consume a DLL name past the uint32 RVA end", async () => { expect(report.imports[0]?.functions).toHaveLength(2); expect(report.coverage).toMatchObject({ status: "partial", - reason: - "1 import table issue(s) affect reported rows or metadata; see issues", + reason: expect.stringMatching( + /1 import table issue.*reported rows or metadata/i, + ), }); expect(report.issues).toEqual( expect.arrayContaining([ diff --git a/src/native/pe/PeResourceParser.test.ts b/src/native/pe/PeResourceParser.test.ts index 99ef05b079..3b343e3eab 100644 --- a/src/native/pe/PeResourceParser.test.ts +++ b/src/native/pe/PeResourceParser.test.ts @@ -658,6 +658,6 @@ it("keeps raw name probing separate from full resource-name alignment admission" expect(await probeSeaName(fixture.bytes)).toBe(true); await expect(parse(fixture.bytes)).rejects.toMatchObject({ reason: "format", - message: "Unaligned PE resource name.", + message: expect.stringMatching(/unaligned.*resource name/iu), }); }); diff --git a/src/pcap/PcapNativeBound.test.ts b/src/pcap/PcapNativeBound.test.ts index 2b221ab9d2..87b66a6229 100644 --- a/src/pcap/PcapNativeBound.test.ts +++ b/src/pcap/PcapNativeBound.test.ts @@ -159,8 +159,8 @@ it("rejects a directory or a command that does not identify as util-linux prlimi ).rejects.toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", // The limiter owns the repair; readiness keeps it over Wireshark advice. - remediationAction: expect.stringContaining( - "REA_TSHARK_PRLIMIT_COMMAND", + remediationAction: expect.stringMatching( + /REA_TSHARK_PRLIMIT_COMMAND.*\/usr\/bin\/prlimit.*absolute.*executable.*util-linux prlimit/iu, ) as unknown, diagnostics: { configuration_key: "REA_TSHARK_PRLIMIT_COMMAND", diff --git a/src/pcap/PcapNativeBound.ts b/src/pcap/PcapNativeBound.ts index d38c32271f..d14e0885b8 100644 --- a/src/pcap/PcapNativeBound.ts +++ b/src/pcap/PcapNativeBound.ts @@ -25,7 +25,7 @@ import type { ArtifactCommandPath } from "../process/ArtifactCommandPath.js"; const PRLIMIT_COMMAND_ENV = "REA_TSHARK_PRLIMIT_COMMAND"; /** Repair for an unusable selected capture limiter; readiness and admission share it. */ -export const PCAP_LIMITER_REMEDIATION = `Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`; +const PCAP_LIMITER_REMEDIATION = `Check that ${PRLIMIT_COMMAND_ENV} (default /usr/bin/prlimit) names an absolute, executable util-linux prlimit.`; /** Capture CPU ceiling, in seconds. */ const PCAP_CPU_SECONDS = 300; diff --git a/src/process/DarwinProcessRunTokenReader.timeout.test.ts b/src/process/DarwinProcessRunTokenReader.timeout.test.ts index 339a0642fa..224b496006 100644 --- a/src/process/DarwinProcessRunTokenReader.timeout.test.ts +++ b/src/process/DarwinProcessRunTokenReader.timeout.test.ts @@ -1,10 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - resolveSwiftcCompileTimeoutMs, - swiftcCompileTimeoutRecovery, -} from "./DarwinProcessRunTokenReader.js"; -import { MAX_NODE_TIMER_DELAY_MS } from "../domain/analysisResourceLimits.js"; +import { resolveSwiftcCompileTimeoutMs } from "./DarwinProcessRunTokenReader.js"; describe("resolveSwiftcCompileTimeoutMs", () => { it("keeps the historical default when unset", () => { @@ -35,14 +31,4 @@ describe("resolveSwiftcCompileTimeoutMs", () => { }), ).toBe(120_000); }); - - it("names the launch-environment deadline, range, and restart on a timeout", () => { - const recovery = swiftcCompileTimeoutRecovery(300_000); - expect(recovery).toContain("REA_SWIFTC_COMPILE_TIMEOUT_MS"); - expect(recovery).toContain("300000 ms"); - expect(recovery).toContain(`1..${String(MAX_NODE_TIMER_DELAY_MS)}`); - expect(recovery).toContain("120000 ms"); - expect(recovery).toContain("restart REA"); - expect(recovery).toContain("next ownership-helper compile"); - }); }); diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index ffb8faf3d0..248c0f5a9e 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -133,7 +133,7 @@ export const resolveSwiftcCompileTimeoutMs = ( }; /** Name the existing swiftc deadline when a helper compile times out. */ -export const swiftcCompileTimeoutRecovery = (timeoutMs: number): string => +const swiftcCompileTimeoutRecovery = (timeoutMs: number): string => `The effective REA_SWIFTC_COMPILE_TIMEOUT_MS value is ${String(timeoutMs)} ms (integer range 1..${String(MAX_NODE_TIMER_DELAY_MS)}; omitted default ${String(DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS)} ms). Set it in the environment that starts REA and restart REA; the next ownership-helper compile uses the new value.`; /** Bound on compiler diagnostics embedded in the inspection failure text. */ diff --git a/src/process/OwnedCommand.test.ts b/src/process/OwnedCommand.test.ts index cc68e8642d..6e5b374ff6 100644 --- a/src/process/OwnedCommand.test.ts +++ b/src/process/OwnedCommand.test.ts @@ -291,8 +291,9 @@ it("reports a premature input close as a collection failure", async () => { ).rejects.toMatchObject({ reason: "process", collectionFailure: true, - message: - "stdin stream failed: closed before writable finalization completed", + message: expect.stringMatching( + /stdin stream failed.*closed before writable finalization completed/i, + ), processError: { message: "closed before writable finalization completed", }, diff --git a/src/process/OwnedLease.test.ts b/src/process/OwnedLease.test.ts index a31f157a74..123b725814 100644 --- a/src/process/OwnedLease.test.ts +++ b/src/process/OwnedLease.test.ts @@ -9,6 +9,7 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { HopperStartError } from "../domain/hopperErrors.js"; +// Unix socket paths have a short platform limit; the canonical system temp path can exceed it. const createTestTempDirectory = async (prefix: string): Promise => { const directory = await mkdtemp(join("/tmp", prefix)); onTestFinished(() => rm(directory, { recursive: true, force: true })); diff --git a/src/process/ProcessOwnership.finalSweep.test.ts b/src/process/ProcessOwnership.finalSweep.test.ts index ab353eef56..972a449625 100644 --- a/src/process/ProcessOwnership.finalSweep.test.ts +++ b/src/process/ProcessOwnership.finalSweep.test.ts @@ -279,7 +279,7 @@ it("preserves a moved same-generation helper as unresolved through the final dea expect(result).toMatchObject({ cleaned: false, - reason: "process ownership token mismatch for PID(s): 101", + reason: expect.stringMatching(/ownership token mismatch.*\b101\b/i), }); expect(fixture.rows()).toMatchObject([ { pid: 101, parentPid: 1, processGroupId: 777 }, diff --git a/src/process/ProcessOwnership.observation.test.ts b/src/process/ProcessOwnership.observation.test.ts index 407177358f..55babc26a9 100644 --- a/src/process/ProcessOwnership.observation.test.ts +++ b/src/process/ProcessOwnership.observation.test.ts @@ -222,8 +222,9 @@ it("rechecks unavailable members individually before classifying their groups", 200, { state: "unverifiable", - reason: - "process ownership could not be revalidated for PID 200: unreadable-200", + reason: expect.stringMatching( + /ownership.*could not be revalidated.*PID 200.*unreadable-200/i, + ), }, ], ]), diff --git a/src/process/ProcessOwnership.part2.test.ts b/src/process/ProcessOwnership.part2.test.ts index 2d7f7d8d4e..7a9dcb7acf 100644 --- a/src/process/ProcessOwnership.part2.test.ts +++ b/src/process/ProcessOwnership.part2.test.ts @@ -124,7 +124,7 @@ describe("owned process-group cleanup discovery", () => { await expect(cleanupOwnedProcessGroup(ownership, adapter)).resolves.toEqual( { cleaned: false, - reason: "process ownership token mismatch for PID(s): 104", + reason: expect.stringMatching(/ownership token mismatch.*\b104\b/i), failures: [{ pid: 104, reason: "run-token-mismatch" }], }, ); diff --git a/src/process/ProcessOwnership.part3.test.ts b/src/process/ProcessOwnership.part3.test.ts index 941be0c8aa..c949fdd499 100644 --- a/src/process/ProcessOwnership.part3.test.ts +++ b/src/process/ProcessOwnership.part3.test.ts @@ -160,8 +160,9 @@ describe("identityless lineage refresh keeps replacement rows", () => { observeLineage(listProcesses, environment), ).resolves.toMatchObject({ status: "unavailable", - reason: - "process lineage refresh contained owned PID(s) outside the observed descendants: 102", + reason: expect.stringMatching( + /lineage refresh.*owned PID.*outside.*observed descendants.*102/i, + ), }); expect(environment).toHaveBeenCalledWith(102); }); diff --git a/src/process/ProcessOwnership.test.ts b/src/process/ProcessOwnership.test.ts index d2e268e008..92b407aee8 100644 --- a/src/process/ProcessOwnership.test.ts +++ b/src/process/ProcessOwnership.test.ts @@ -361,7 +361,7 @@ describe("related sweep candidates during cleanup", () => { ); expect(result).toMatchObject({ cleaned: false, - reason: "process ownership token mismatch for PID(s): 200", + reason: expect.stringMatching(/ownership token mismatch.*\b200\b/i), failures: [{ pid: 200, reason: "run-token-mismatch" }], }); expect(signalGroup).not.toHaveBeenCalledWith(200, "SIGKILL"); @@ -560,7 +560,7 @@ describe("sampled detached process groups during cleanup", () => { expect(result).toMatchObject({ cleaned: false, - reason: "process ownership token mismatch for PID(s): 200", + reason: expect.stringMatching(/ownership token mismatch.*\b200\b/i), failures: [{ pid: 200, reason: "run-token-mismatch" }], }); expect(signalGroup).toHaveBeenCalledTimes(1); @@ -598,7 +598,7 @@ describe("sampled detached process groups during cleanup", () => { expect(result).toMatchObject({ cleaned: false, - reason: "process ownership token mismatch for PID(s): 200", + reason: expect.stringMatching(/ownership token mismatch.*\b200\b/i), failures: [{ pid: 200, reason: "run-token-mismatch" }], }); expect(signalGroup).not.toHaveBeenCalled(); diff --git a/src/process/ProcessOwnership.validation.test.ts b/src/process/ProcessOwnership.validation.test.ts index 730a0c3d80..b77591a6ce 100644 --- a/src/process/ProcessOwnership.validation.test.ts +++ b/src/process/ProcessOwnership.validation.test.ts @@ -113,7 +113,7 @@ describe("owned process-group cleanup validation: ownership and lineage", () => }); expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ cleaned: false, - reason: "process ownership token mismatch for PID(s): 101", + reason: expect.stringMatching(/ownership token mismatch.*\b101\b/i), failures: [{ pid: 101, reason: "run-token-mismatch" }], }); expect(signalGroup).not.toHaveBeenCalled(); @@ -145,7 +145,7 @@ describe("owned process-group cleanup validation: ownership and lineage", () => }; expect(await cleanupOwnedProcessGroup(ownership, adapter)).toEqual({ cleaned: false, - reason: "process ownership token mismatch for PID(s): 101", + reason: expect.stringMatching(/ownership token mismatch.*\b101\b/i), failures: [ expect.objectContaining({ pid: 100, @@ -265,7 +265,7 @@ describe("owned process-group cleanup liveness rechecks", () => { cleanupOwnedProcessGroup(ownership, adapter), ).resolves.toEqual({ cleaned: false, - reason: "process ownership token mismatch for PID(s): 101", + reason: expect.stringMatching(/ownership token mismatch.*\b101\b/i), failures: recheckFails ? [ { @@ -485,8 +485,9 @@ describe("identityless exit refresh leaves unrelated replacements unresolved", ( ), ).resolves.toMatchObject({ cleaned: false, - reason: - "process ownership could not be revalidated for PID 102: environment unavailable", + reason: expect.stringMatching( + /ownership.*could not be revalidated.*PID 102.*environment unavailable/i, + ), }); expect(signalGroup).not.toHaveBeenCalled(); }); @@ -997,7 +998,7 @@ describe("owned process-group cleanup validation: launcher exit races", () => { if (remaining === "foreign") expect(result).toMatchObject({ cleaned: false, - reason: "process ownership token mismatch for PID(s): 101", + reason: expect.stringMatching(/ownership token mismatch.*\b101\b/i), }); else expect(result).toEqual({ @@ -1340,8 +1341,9 @@ describe("Windows P0 process-tree cleanup", () => { }); await expect(cleanupWindowsProcessTree(42, failing)).resolves.toEqual({ cleaned: false, - reason: - "Windows process-tree termination failed for PID 42: taskkill failed", + reason: expect.stringMatching( + /Windows process-tree termination failed.*PID 42.*taskkill failed/i, + ), }); }); }); diff --git a/src/process/RegexMatcher.test.ts b/src/process/RegexMatcher.test.ts index 26e2c60fca..15db616194 100644 --- a/src/process/RegexMatcher.test.ts +++ b/src/process/RegexMatcher.test.ts @@ -394,10 +394,6 @@ it("describes the effective heap and the configurable matching deadline", () => expect(limitation.length).toBeLessThan(700); if (heap.kind === "effective") expect(limitation).toContain(`${String(heap.mib)} MiB`); - expect( - projectAnalysisError(regexMatchingDeadlineError("search_procedures")) - .remediation.action, - ).toContain(regexMatchingDeadlineRecovery(DEFAULT_REGEX_MATCHING_DEADLINE)); const projected = projectAnalysisError( regexWorkerHeapError("search_procedures", new Error("heap"), heap), ); @@ -412,9 +408,6 @@ it("describes the effective heap and the configurable matching deadline", () => expect(projected.remediation.action).toContain( "--max-old-space-size-percentage", ); - expect(projected.remediation.action).toContain( - regexWorkerHeapRequestRecovery(DEFAULT_REGEX_WORKER_HEAP_REQUEST), - ); if (heap.kind === "unknown") { expect(projected.remediation.action).toContain( "--experimental-config-file", diff --git a/src/process/capture/ProcessCaptureCapability.ts b/src/process/capture/ProcessCaptureCapability.ts index 7d0de76251..2ac53ee1cc 100644 --- a/src/process/capture/ProcessCaptureCapability.ts +++ b/src/process/capture/ProcessCaptureCapability.ts @@ -25,7 +25,7 @@ export interface ProcessCaptureProbeOptions { } /** Probe the native PTY seam and explain loader failures at their source. */ -export const processCaptureProbeFailureReason = (cause: unknown): string => { +const processCaptureProbeFailureReason = (cause: unknown): string => { const error = cause instanceof Error ? cause : new Error(String(cause)); const code = "code" in error ? String(error.code) : ""; const diagnostic = error.message; diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index 4d28b43c68..25cb5d8c46 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -1441,14 +1441,14 @@ export const cleanupReportFailure = ( }; export const captureTerminalFrames = (options: { - readonly terminal: IPty; + readonly terminal: Pick; readonly scenario: ProcessScenario; readonly frames: TerminalFrame[]; readonly sensitiveOutput?: ProcessSensitiveOutput; readonly onSensitiveOutputFailure?: () => void; readonly started: number; readonly onOutput: () => void; - readonly renderer: TerminalRenderer; + readonly renderer: Pick; readonly normalizeText: ( value: string, maxBytes: number, diff --git a/src/process/capture/ProcessCaptureObservation.test.ts b/src/process/capture/ProcessCaptureObservation.test.ts index 012f573108..0c3181d5b9 100644 --- a/src/process/capture/ProcessCaptureObservation.test.ts +++ b/src/process/capture/ProcessCaptureObservation.test.ts @@ -257,6 +257,24 @@ it("admits PTY samples only after stable session and token setup", () => { after: { ...initialized, startTime: "201" }, }), ).toBe(false); + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: initialized, + observedRunId: "other-run", + after: initialized, + }), + ).toBe(false); + expect( + isInitializedPtyRoot({ + rootPid: 100, + expectedRunId: "run-token", + before: initialized, + observedRunId: "run-token", + after: { ...initialized, parent_pid: 11 }, + }), + ).toBe(false); expect( isInitializedPtyRoot({ rootPid: 100, diff --git a/src/process/capture/ProcessCaptureTerminalRetention.test.ts b/src/process/capture/ProcessCaptureTerminalRetention.test.ts index d84a975a9a..1fc20c2b69 100644 --- a/src/process/capture/ProcessCaptureTerminalRetention.test.ts +++ b/src/process/capture/ProcessCaptureTerminalRetention.test.ts @@ -1,5 +1,4 @@ import { expect, it } from "vitest"; -import type { IPty } from "@lydell/node-pty"; import { processScenarioSchema } from "../../domain/process/processScenario.js"; import { AnalysisResourceConstraintError } from "../../domain/analysisErrorCore.js"; @@ -20,7 +19,7 @@ it("omits an expanding chunk whole and retains a later smaller normalized chunk" onData = listener; return { dispose: () => undefined }; }, - } as unknown as IPty; + }; const scenario = processScenarioSchema.parse({ executable: "unused", limits: { output_bytes: 1_000 }, @@ -40,7 +39,7 @@ it("omits an expanding chunk whole and retains a later smaller normalized chunk" frames, started: 0, onOutput: () => undefined, - renderer: { write: (data: string) => renderedChunks.push(data) } as never, + renderer: { write: (data: string) => renderedChunks.push(data) }, normalizeText: createBoundedProcessTextNormalizer(scenario, "/tmp", -1), recordEvent: (collection, index) => journal.push([collection, index]), identifiers: () => [], @@ -79,7 +78,7 @@ it.each([false, true])( onData = listener; return { dispose: () => undefined }; }, - } as unknown as IPty; + }; const scenario = processScenarioSchema.parse({ executable: "unused", limits: { output_bytes: overBudget ? 4 : 1000 }, @@ -104,7 +103,7 @@ it.each([false, true])( frames, started: 0, onOutput: () => undefined, - renderer: { write: () => undefined } as never, + renderer: { write: () => undefined }, normalizeText: createBoundedProcessTextNormalizer( scenario, "/tmp", @@ -148,7 +147,7 @@ it("retains PTY chunk boundaries when two output messages arrive fragmented", () onData = listener; return { dispose: () => undefined }; }, - } as unknown as IPty; + }; const scenario = processScenarioSchema.parse({ executable: "unused", normalization: { paths: false, pids: false, ports: false }, @@ -160,7 +159,7 @@ it("retains PTY chunk boundaries when two output messages arrive fragmented", () frames, started: 0, onOutput: () => undefined, - renderer: { write: () => undefined } as never, + renderer: { write: () => undefined }, normalizeText: createBoundedProcessTextNormalizer(scenario, "/tmp", -1), recordEvent: () => undefined, identifiers: () => [], diff --git a/src/process/capture/ProcessNormalization.test.ts b/src/process/capture/ProcessNormalization.test.ts index c671276fd9..936ac13442 100644 --- a/src/process/capture/ProcessNormalization.test.ts +++ b/src/process/capture/ProcessNormalization.test.ts @@ -2,7 +2,6 @@ import { expect, it } from "vitest"; import { createBoundedProcessTextNormalizer, - createProcessTextNormalizer, createProcessIdentifierLookup, normalizeProcessSamples, normalizeProcessText, @@ -279,11 +278,17 @@ it("lets a later pattern shrink text when an earlier pattern does not match", () it("reuses matchers while observing the terminal PID after launch", () => { let pid = -1; - const normalize = createProcessTextNormalizer( + const bounded = createBoundedProcessTextNormalizer( scenario({ ports: false, patterns: [] }), "/temporary", () => pid, ); + const normalize = (text: string) => { + const result = bounded(text, 4096); + if (result.state !== "normalized") + throw new Error("unexpected budget failure"); + return result.value; + }; expect(normalize("root 41010 /workspace/item")).toBe( "root 41010 /item", ); @@ -302,8 +307,10 @@ it("keeps equal-path priority and replacement aliases local to each capture", () filesystem_observation_paths: ["/selected", ""], normalization: { paths: true, pids: false, ports: false, patterns: [] }, }); - const normalize = createProcessTextNormalizer(declared, "/selected", -1); - const other = createProcessTextNormalizer(declared, "/other", -1); + const normalize = (text: string) => + normalizeProcessText(text, declared, "/selected", -1); + const other = (text: string) => + normalizeProcessText(text, declared, "/other", -1); const text = "/selected/item /source"; expect(normalize(text)).toBe( "/item /source", @@ -605,11 +612,12 @@ it.each([ pids: enabled, patterns: enabled ? [{ pattern: "marker", replacement: "selected" }] : [], }); - const expected = createProcessTextNormalizer( + const expected = normalizeProcessText( + input, settings, "/temporary", rootPid, - )(input); + ); expect( createBoundedProcessTextNormalizer( settings, @@ -647,7 +655,17 @@ it("shares sampled identifiers before custom patterns in terminal and command no { pattern: "200", replacement: "wrong-order" }, ], }); - const normalize = createProcessTextNormalizer(declared, "/temporary", lookup); + const bounded = createBoundedProcessTextNormalizer( + declared, + "/temporary", + lookup, + ); + const normalize = (text: string) => { + const result = bounded(text, 4096); + if (result.state !== "normalized") + throw new Error("unexpected budget failure"); + return result.value; + }; expect(normalize("root=100 child=200 other=201")).toBe( "root=selected child=wrong-order other=201", ); @@ -688,9 +706,9 @@ it("preserves literal absence text while normalizing selected paths with and wit "/usr/bin/node /workspace/file /capture/file"; for (const temporaryRoot of [undefined, "/capture"]) { const expected = ` /file ${temporaryRoot === undefined ? "/capture/file" : "/file"}`; - expect( - createProcessTextNormalizer(selected, temporaryRoot, rootPid)(text), - ).toBe(expected); + expect(normalizeProcessText(text, selected, temporaryRoot, rootPid)).toBe( + expected, + ); expect( createBoundedProcessTextNormalizer( selected, diff --git a/src/process/capture/ProcessNormalization.ts b/src/process/capture/ProcessNormalization.ts index 7247fae277..6f46173a7c 100644 --- a/src/process/capture/ProcessNormalization.ts +++ b/src/process/capture/ProcessNormalization.ts @@ -276,7 +276,7 @@ const createProcessTextRules = ( }; /** Prepare capture-local matchers while retaining a live terminal PID lookup. */ -export const createProcessTextNormalizer = ( +const createProcessTextNormalizer = ( scenario: ProcessScenario, temporaryRoot: string | undefined, pid: ProcessIdentifierSource, diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts index b1d94df534..6c185ecef9 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.test.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.test.ts @@ -17,10 +17,7 @@ import { AnalysisInputError, } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; -import { - resolveExecutable, - resolveProcessScenarioRuntimePaths, -} from "./ProcessScenarioRuntimeValidation.js"; +import { resolveProcessScenarioRuntimePaths } from "./ProcessScenarioRuntimeValidation.js"; import { processScenarioSchema } from "../../domain/process/processScenario.js"; import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js"; @@ -33,7 +30,6 @@ it.skipIf(process.platform === "win32")( const command = "selected\\node"; const selected = join(bin, command); await symlink(process.execPath, selected); - await expect(resolveExecutable(command, root, bin)).resolves.toBe(selected); const resolved = await resolveProcessScenarioRuntimePaths( processScenarioSchema.parse({ executable: command, @@ -53,7 +49,14 @@ it.skipIf(process.platform === "win32")( const alias = join(root, "chosen-node"); await symlink(process.execPath, alias); - await expect(resolveExecutable(alias, root, "")).resolves.toBe(alias); + const resolved = await resolveProcessScenarioRuntimePaths( + processScenarioSchema.parse({ + executable: alias, + working_directory: root, + environment: { PATH: "" }, + }), + ); + expect(resolved.executable).toBe(alias); } finally { await rm(root, { recursive: true, force: true }); } @@ -300,9 +303,14 @@ it.skipIf(SKIP_PERMISSIONS)( const tool = join(bin, "tool"); await writeFile(tool, "#!/bin/sh\n", { mode: 0o755 }); await withoutSearchPermission(denied, async () => { - await expect( - resolveExecutable("tool", root, [denied, bin].join(delimiter)), - ).resolves.toBe(tool); + const resolved = await resolveProcessScenarioRuntimePaths( + processScenarioSchema.parse({ + executable: "tool", + working_directory: root, + environment: { PATH: [denied, bin].join(delimiter) }, + }), + ); + expect(resolved.executable).toBe(tool); const failure = await runtimePathOutcome({ executable: "tool", working_directory: root, @@ -365,7 +373,13 @@ it.skipIf(process.platform === "win32").each(["absolute", "relative", "PATH"])( : selection === "relative" ? "alias/../probe" : "probe"; - const executable = await resolveExecutable(command, root, "alias/.."); + const { executable } = await resolveProcessScenarioRuntimePaths( + processScenarioSchema.parse({ + executable: command, + working_directory: root, + environment: { PATH: "alias/.." }, + }), + ); expect(await realpath(executable)).toBe(join(intended, "probe")); expect(execFileSync(executable, { encoding: "utf8", timeout: 2000 })).toBe( "intended", diff --git a/src/process/capture/ProcessScenarioRuntimeValidation.ts b/src/process/capture/ProcessScenarioRuntimeValidation.ts index c8f527c613..1cd9db182f 100644 --- a/src/process/capture/ProcessScenarioRuntimeValidation.ts +++ b/src/process/capture/ProcessScenarioRuntimeValidation.ts @@ -78,7 +78,7 @@ export const resolveProcessScenarioRuntimePaths = async ( * continues past denied directories, and an existing file without execute * permission remains an invalid selection. */ -export const resolveExecutable = async ( +const resolveExecutable = async ( command: string, workingDirectory: string, searchPath: string | undefined, diff --git a/src/process/capture/ProcessSensitiveTerminal.test.ts b/src/process/capture/ProcessSensitiveTerminal.test.ts index df4731dfc2..ad2e3fe9e3 100644 --- a/src/process/capture/ProcessSensitiveTerminal.test.ts +++ b/src/process/capture/ProcessSensitiveTerminal.test.ts @@ -1,4 +1,3 @@ -import type { IPty } from "@lydell/node-pty"; import { expect, it } from "vitest"; import type { TerminalFrame } from "../../domain/process/processCapture.js"; import { processScenarioSchema } from "../../domain/process/processScenario.js"; @@ -56,7 +55,7 @@ const fixture = ( }, }; }, - } as unknown as IPty; + }; const retention = captureTerminalFrames({ terminal, scenario, diff --git a/src/process/capture/ProcessTerminalObservation.test.ts b/src/process/capture/ProcessTerminalObservation.test.ts index 3b6f1ec275..eba902af7e 100644 --- a/src/process/capture/ProcessTerminalObservation.test.ts +++ b/src/process/capture/ProcessTerminalObservation.test.ts @@ -1,5 +1,4 @@ import { expect, it, vi, onTestFinished } from "vitest"; -import type { IPty } from "@lydell/node-pty"; import type { ProcessSample, TerminalFrame, @@ -70,7 +69,7 @@ const createTerminalCleanupFixture = () => { }, }; }, - } as unknown as IPty; + }; const retention = captureTerminalFrames({ terminal, scenario, diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index 075a43bcf3..dd0b3749dc 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -25,7 +25,6 @@ import { type SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; import { - applyDebuggerAdmissionOverride, debuggerControlReplyOutput, resolveDebuggerSessionLimits, type DebuggerAdmissionLimit, @@ -129,9 +128,6 @@ interface RizinCommandInterruption { readonly capture: RizinCommandCapture; } -export const isRizinCommandDeadlineExpired = (remainingMs: number): boolean => - remainingMs <= 0; - interface RizinCommandTimeoutEvidenceInput { readonly sessionId: string; readonly command: string; @@ -139,22 +135,22 @@ interface RizinCommandTimeoutEvidenceInput { readonly inferiorTty: string; readonly cleanupFailure: string | undefined; readonly launchProvenance: RizinLaunchProvenance; - readonly deadline?: DebuggerAdmissionLimit; + readonly deadline: DebuggerAdmissionLimit; } -export const rizinCommandTimeoutLimitation = ( +const rizinCommandTimeoutLimitation = ( deadline: DebuggerAdmissionLimit, ): string => `The command timed out before its completion marker at ${String(deadline.value)} ms (deadlineExpired). ${debuggerAdmissionRecovery(deadline)} Retained output is partial and completion is unknown.`; -export const createRizinCommandTimeoutEvidence = ({ +const createRizinCommandTimeoutEvidence = ({ sessionId, command, result, inferiorTty, cleanupFailure, launchProvenance, - deadline = resolveDebuggerSessionLimits({}).commandDeadline, + deadline, }: RizinCommandTimeoutEvidenceInput): Evidence => createRizinCommandEvidence({ sessionId, @@ -270,16 +266,8 @@ export class RizinDebugSessionManager { constructor( environment: Readonly = process.env, options: { - /** @internal Test seam. Production uses REA_DEBUGGER_COMMAND_DEADLINE_MS. */ - readonly frameTimeoutMs?: number; /** @internal Test seam; production callers should use the 30 s default. */ readonly startupTimeoutMs?: number; - /** @internal Test seam. Production uses REA_DEBUGGER_MAX_SESSIONS. */ - readonly maxSessions?: number; - /** @internal Test seam. Production uses REA_DEBUGGER_MAX_PENDING_COMMANDS. */ - readonly maxPendingCommands?: number; - /** @internal Test seam. Production uses REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES. */ - readonly maxCommandOutputBytes?: number; readonly platform?: NodeJS.Platform; readonly stopProcess?: typeof stopOwnedDebuggerProcess; readonly spawnProcess?: typeof spawnOwnedProviderProcess; @@ -288,24 +276,12 @@ export class RizinDebugSessionManager { this.#environment = { ...environment }; this.#command = environment.REA_RIZIN_COMMAND ?? "rizin"; const limits = resolveDebuggerSessionLimits(this.#environment); - this.#commandDeadline = applyDebuggerAdmissionOverride( - limits.commandDeadline, - options.frameTimeoutMs, - ); + this.#commandDeadline = limits.commandDeadline; this.#frameTimeoutMs = this.#commandDeadline.value; this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS; - this.#maxSessions = applyDebuggerAdmissionOverride( - limits.maxSessions, - options.maxSessions, - ); - this.#maxPendingCommands = applyDebuggerAdmissionOverride( - limits.maxPendingCommands, - options.maxPendingCommands, - ); - this.#commandOutput = applyDebuggerAdmissionOverride( - limits.maxCommandOutputBytes, - options.maxCommandOutputBytes, - ); + this.#maxSessions = limits.maxSessions; + this.#maxPendingCommands = limits.maxPendingCommands; + this.#commandOutput = limits.maxCommandOutputBytes; this.#maxCommandOutputBytes = this.#commandOutput.value; this.#platform = options.platform ?? process.platform; this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess; @@ -1791,7 +1767,7 @@ const waitForCommandFrames = async ( }); } const remaining = deadline - Date.now(); - if (isRizinCommandDeadlineExpired(remaining)) + if (remaining <= 0) return err({ kind: "timeout", cause: new RizinFrameTimeoutError(), @@ -1848,7 +1824,7 @@ const waitForCommandFrames = async ( } }; -export const truncateUtf8 = (value: string, maximumBytes: number): string => { +const truncateUtf8 = (value: string, maximumBytes: number): string => { if (maximumBytes <= 0) return ""; // A UTF-16 code unit needs at most three UTF-8 bytes. Buffer.write stops // before a partial character; only decode bytes it actually initialized. diff --git a/src/server/EvidenceMcpServer.test.ts b/src/server/EvidenceMcpServer.test.ts index eaae922d1d..7381c97f2f 100644 --- a/src/server/EvidenceMcpServer.test.ts +++ b/src/server/EvidenceMcpServer.test.ts @@ -14,7 +14,6 @@ import { z } from "zod"; import { InvestigationRecords } from "../application/investigation/InvestigationRecords.js"; import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/officialToolContracts.js"; -import { knownArgumentMistakeGuidance } from "../domain/inputArgumentGuidance.js"; import { EvidenceMcpServer } from "./EvidenceMcpServer.js"; import { forgetPendingMcpRequest } from "./EvidenceMcpServerCore.js"; import { createToolResultDelivery } from "./toolResult.js"; @@ -411,11 +410,6 @@ const officialContract = ( describe("SDK argument name guidance", () => { it("guides procedure_pseudo_code caller from address to procedure without duplicating diagnostic", async () => { - const guidance = knownArgumentMistakeGuidance( - "procedure_pseudo_code", - "address", - ); - expect(guidance).toBeDefined(); const session = await createGuidanceClientServer( "procedure_pseudo_code", officialContract("procedure_pseudo_code").inputSchema, @@ -433,12 +427,10 @@ describe("SDK argument name guidance", () => { path: ["address"], reason: "unknown_argument", expected: "procedure", - message: guidance!.message, + message: expect.stringMatching(/address.*procedure/i), }), ); - expect( - payload.error.remediation.action.startsWith(guidance!.remediation), - ).toBe(true); + expect(payload.error.remediation.action).toMatch(/procedure.*address/i); expect(payload.error.message).not.toContain("address"); } finally { await session.close(); @@ -446,8 +438,6 @@ describe("SDK argument name guidance", () => { }); it("guides search_strings caller from query to pattern", async () => { - const guidance = knownArgumentMistakeGuidance("search_strings", "query"); - expect(guidance).toBeDefined(); const session = await createGuidanceClientServer( "search_strings", officialContract("search_strings").inputSchema, @@ -465,23 +455,16 @@ describe("SDK argument name guidance", () => { path: ["query"], reason: "unknown_argument", expected: "pattern", - message: guidance!.message, + message: expect.stringMatching(/query.*pattern/i), }), ); - expect( - payload.error.remediation.action.startsWith(guidance!.remediation), - ).toBe(true); + expect(payload.error.remediation.action).toMatch(/pattern.*query/i); } finally { await session.close(); } }); it("rejects address with procedure guidance and retains unexplained key count in diagnostics", async () => { - const guidance = knownArgumentMistakeGuidance( - "read_function_instructions", - "address", - ); - expect(guidance).toBeDefined(); const session = await createGuidanceClientServer( "read_function_instructions", officialContract("read_function_instructions").inputSchema, @@ -499,7 +482,7 @@ describe("SDK argument name guidance", () => { path: ["address"], reason: "unknown_argument", expected: "procedure", - message: guidance!.message, + message: expect.stringMatching(/address.*procedure/i), }), ); expect(payload.error.details.issues).toContainEqual( @@ -511,6 +494,7 @@ describe("SDK argument name guidance", () => { const countIssue = payload.error.details.issues.find( (issue: { path: string[] }) => issue.path?.[0] === "count", ); + expect(payload.error.remediation.action).toMatch(/procedure.*address/i); expect(countIssue?.expected).toBeUndefined(); expect(countIssue?.message).toBeUndefined(); expect(payload.error.message).toContain("count"); diff --git a/stryker.semantics.config.mjs b/stryker.semantics.config.mjs index 83a71a4021..47db21a74d 100644 --- a/stryker.semantics.config.mjs +++ b/stryker.semantics.config.mjs @@ -37,8 +37,8 @@ export default { ]), ...mutationRanges("src/rizin/RizinDebugSessionManager.ts", [ { - kind: "declaration", - name: "isRizinCommandDeadlineExpired", + kind: "expression", + text: "remaining <= 0", }, { kind: "declaration", diff --git a/tests/acceptance/artifactDmgAttachmentRecoveryCli.test.ts b/tests/acceptance/artifactDmgAttachmentRecoveryCli.test.ts index 1d2341f2dd..ace76f9e32 100644 --- a/tests/acceptance/artifactDmgAttachmentRecoveryCli.test.ts +++ b/tests/acceptance/artifactDmgAttachmentRecoveryCli.test.ts @@ -3,10 +3,7 @@ import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { join, sep } from "node:path"; import { expect } from "vitest"; -import { - NativeDmgArtifactReader, - DMG_EXISTING_ATTACHMENT_RECOVERY, -} from "../../src/artifacts/NativeDmgArtifactReader.js"; +import { NativeDmgArtifactReader } from "../../src/artifacts/NativeDmgArtifactReader.js"; import { parseNativeDmgInfo } from "../../src/artifacts/NativeDmgOwnership.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -90,9 +87,13 @@ cliTest.skipIf(process.platform !== "darwin")( expect(busy.json).toMatchObject({ code: "artifact_operation_failed", category: "unavailable", - message: DMG_EXISTING_ATTACHMENT_RECOVERY, + message: expect.stringMatching( + /hdiutil.*existing attachment.*owner.*release.*retry.*does not borrow or detach/iu, + ), remediation: { - action: DMG_EXISTING_ATTACHMENT_RECOVERY, + action: expect.stringMatching( + /hdiutil.*existing attachment.*owner.*release.*retry.*does not borrow or detach/iu, + ), }, details: { reason: "unavailable", diff --git a/tests/acceptance/espFlashImageCli.test.ts b/tests/acceptance/espFlashImageCli.test.ts index 8d0404b021..b9314778a0 100644 --- a/tests/acceptance/espFlashImageCli.test.ts +++ b/tests/acceptance/espFlashImageCli.test.ts @@ -114,8 +114,8 @@ cliTest( ], }); expect(unpaired.exitCode).not.toBe(0); - expect(unpaired.stdout + unpaired.stderr).toContain( - "flash_offset and partition_table_offset apply only with format=esp-flash-image", + expect(unpaired.stdout + unpaired.stderr).toMatch( + /flash_offset.*only with format=esp-flash-image/iu, ); // Without the format, the bootloader is read as one ESP image, and the // table at file offset 0x7000 says how to reopen the dump. @@ -128,20 +128,22 @@ cliTest( expect(guessed.root).toMatchObject({ flash_dump: { flash_offset: 0x1000, table_file_offset: 0x7000 }, }); - expect( - artifactInspectionResultSchema.parse( - parseEvidence(automatic.json).normalized_result, - ).next_probes, - ).toEqual([ - { + const inspection = artifactInspectionResultSchema.parse( + parseEvidence(automatic.json).normalized_result, + ); + expect(inspection.next_probes).toEqual([ + expect.objectContaining({ operation: "open_binary", - rationale: - "Reopen the file with format esp-flash-image and flash_offset 0x1000, then inspect_artifact: it is a flash dump read from flash 0x1000, with a partition table at file offset 0x7000.", authority: "artifact-bytes", - }, + rationale: expect.stringMatching( + /esp-flash-image.*flash_offset 0x1000.*0x7000/iu, + ), + }), ]); - expect(guessed.graph.limitations).toContain( - "This file looks like a flash dump read from flash 0x1000, not one ESP image: its esp32 image at file offset 0 is where the ROM loads the bootloader from 0x1000, and a partition table sits at file offset 0x7000, which is flash 0x8000 from that origin. Reopen it with format esp-flash-image and flash_offset 0x1000.", + expect(guessed.graph.limitations).toContainEqual( + expect.stringMatching( + /flash dump.*0x1000.*0x7000.*0x8000.*esp-flash-image.*0x1000/iu, + ), ); const inspected = await cli.run({ arguments: [ diff --git a/tests/acceptance/firmwareContainersCli.test.ts b/tests/acceptance/firmwareContainersCli.test.ts index fbdc8d3cc9..be500a4bb3 100644 --- a/tests/acceptance/firmwareContainersCli.test.ts +++ b/tests/acceptance/firmwareContainersCli.test.ts @@ -205,8 +205,8 @@ cliTest( expect(result.json).toMatchObject({ details: { operation: "inspect_artifact", reason: "format" }, }); - expect(result.stdout + result.stderr).toContain( - "UF2 is malformed: family 0xe48bff56 (RP2040) address 0x10000000 is written with different values by blocks 0-1 (0x01) and block 2 (0xff)", + expect(result.stdout + result.stderr).toMatch( + /0xe48bff56.*RP2040.*0x10000000.*different values.*0-1.*0x01.*2.*0xff/iu, ); }, ); diff --git a/tests/acceptance/hexRecordsCli.test.ts b/tests/acceptance/hexRecordsCli.test.ts index 3c2169895a..83969500bb 100644 --- a/tests/acceptance/hexRecordsCli.test.ts +++ b/tests/acceptance/hexRecordsCli.test.ts @@ -9,6 +9,7 @@ import { SREC_S19, } from "../../src/domain/firmware/hexRecords.fixture.js"; import { parseEvidence } from "../../src/domain/evidence.js"; +import { artifactFormatDescriptor } from "../../src/artifacts/artifactFormats.js"; import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; import { artifactExtractionResultSchema, @@ -96,9 +97,14 @@ cliTest.for(cases)( ).toEqual(Object.keys(regions)); if (encoding === "intel-hex") { expect(graph.coverage.scope).toBe("container-entries"); - expect(graph.limitations).toContain( - "Intel HEX/S-record inventory decodes data records into address-contiguous regions. It does not establish CPU, byte order, memory map, or that a declared start address is executable; gaps are unwritten, not zero.", + const limitation = artifactFormatDescriptor(encoding)?.readerLimitation; + if (limitation === undefined) + throw new Error("Missing format limitation"); + expect(graph.limitations).toContain(limitation); + expect(limitation).toMatch( + /not establish.*CPU.*byte order.*memory map.*start address.*executable/iu, ); + expect(limitation).toMatch(/gaps.*unwritten.*not zero/iu); expect( graph.occurrences.find((row) => row.logical_path === "."), ).toMatchObject({ @@ -177,8 +183,8 @@ cliTest("rejects a malformed record through the CLI", async ({ cli }) => { code: "artifact_operation_failed", details: { operation: "inspect_artifact", reason: "format" }, }); - expect(result.stdout + result.stderr).toContain( - "Intel HEX is malformed: line 4: record type 00 checksum 0xc4 does not match computed 0xc3", + expect(result.stdout + result.stderr).toMatch( + /line 4.*record type 00.*checksum 0xc4.*computed 0xc3/iu, ); }); diff --git a/tests/acceptance/jsonLinesCli.test.ts b/tests/acceptance/jsonLinesCli.test.ts index 7788d879ea..31f86af756 100644 --- a/tests/acceptance/jsonLinesCli.test.ts +++ b/tests/acceptance/jsonLinesCli.test.ts @@ -4,7 +4,6 @@ import { DatabaseSync } from "node:sqlite"; import { expect } from "vitest"; -import { JSON_LINES_SELECTION_REMEDIATION } from "../../src/cli/jsonLines.js"; import { parseEvidence } from "../../src/domain/evidence.js"; import { analysisViewJavaScriptEvidence } from "../fixtures/analysisView.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -174,7 +173,11 @@ cliTest( code: "invalid_request", category: "invalid_input", retryable: false, - remediation: { action: JSON_LINES_SELECTION_REMEDIATION }, + remediation: { + action: expect.stringMatching( + /--format json.*record collection.*--filter-output/iu, + ), + }, details: { operation: "inspect-sqlite-database", issues: [{ path: [option], reason: "invalid_value", observed }], diff --git a/tests/acceptance/managedCliOutput.test.ts b/tests/acceptance/managedCliOutput.test.ts index ffca6fda48..71bc9aa298 100644 --- a/tests/acceptance/managedCliOutput.test.ts +++ b/tests/acceptance/managedCliOutput.test.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import { expect } from "vitest"; -import { JSON_LINES_SELECTION_REMEDIATION } from "../../src/cli/jsonLines.js"; import { elf, thinMach } from "../../src/domain/binaryTarget.fixture.js"; import { buildManagedPeFixture } from "../../src/dotnet/ManagedPe.fixture.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; @@ -406,7 +405,11 @@ cliTest( result: { code: "invalid_request", category: "invalid_input", - remediation: { action: JSON_LINES_SELECTION_REMEDIATION }, + remediation: { + action: expect.stringMatching( + /--format json.*record collection.*--filter-output/iu, + ), + }, details: { issues: [ { diff --git a/tests/acceptance/pyinstallerArchive.test.ts b/tests/acceptance/pyinstallerArchive.test.ts index 46085f2a79..c0a9b6f1fb 100644 --- a/tests/acceptance/pyinstallerArchive.test.ts +++ b/tests/acceptance/pyinstallerArchive.test.ts @@ -13,6 +13,7 @@ import { cArchiveBytecodeExecutable, pyzArchive, } from "../../src/artifacts/pyinstaller/CArchive.fixture.js"; +import { RECONSTRUCTED_PYTHON_HEADER_LIMITATION } from "../../src/artifacts/pyinstaller/PythonBytecode.js"; import { artifactInspectionResultSchema } from "../../src/domain/artifactInspection.js"; import { artifactExtractionResultSchema } from "../../src/domain/artifactGraph.js"; import { parseEvidence } from "../../src/domain/evidence.js"; @@ -468,7 +469,13 @@ it("extracts CArchive members through extract-artifact", async () => { rm(extracted.output_root, { recursive: true, force: true }), ); expect(extracted.limitations).toContain( - "PYZ.pyz/app.pyc: Python bytecode header was reconstructed from the archive Python magic; this digest covers the derived file.", + `PYZ.pyz/app.pyc: ${RECONSTRUCTED_PYTHON_HEADER_LIMITATION}`, + ); + expect(RECONSTRUCTED_PYTHON_HEADER_LIMITATION).toMatch( + /header.*reconstructed.*archive.*magic/iu, + ); + expect(RECONSTRUCTED_PYTHON_HEADER_LIMITATION).toMatch( + /digest.*derived file/iu, ); expect(extracted.coverage).toEqual({ scope: "container-entries", diff --git a/tests/acceptance/tools/historicalCaptureResources.test.ts b/tests/acceptance/tools/historicalCaptureResources.test.ts index 5f33a38d02..5c762f461b 100644 --- a/tests/acceptance/tools/historicalCaptureResources.test.ts +++ b/tests/acceptance/tools/historicalCaptureResources.test.ts @@ -166,15 +166,7 @@ it.skipIf(process.versions.bun !== undefined && jobLimit === null)( normalized_result: { total_records: 2 }, }); const controlText = JSON.stringify(control.structuredContent); - expect(controlText).toContain( - "The effective REA_CAPTURE_DECODER_OLD_GENERATION_MIB value is 64 MiB (source setting; default 192 MiB, floor 8 MiB, maximum 16384 MiB).", - ); - expect(controlText).toContain("source setting"); - expect(controlText).toContain("floor 8 MiB"); - expect(controlText).toContain("maximum 16384 MiB"); - expect(controlText).toContain( - "Set REA_CAPTURE_DECODER_OLD_GENERATION_MIB in the environment that starts REA", - ); + expect(controlText).toContain(captureDecoderHeapRecovery(testHeap)); const defaultServer = await connectLocalToolsMcp(); const defaultControl = await defaultServer.call( "inspect_web_network_capture", diff --git a/tests/boundary/cli/cliJsonStdinCli.test.ts b/tests/boundary/cli/cliJsonStdinCli.test.ts index 6ffc692ec1..933a34455d 100644 --- a/tests/boundary/cli/cliJsonStdinCli.test.ts +++ b/tests/boundary/cli/cliJsonStdinCli.test.ts @@ -4,7 +4,6 @@ import { pathToFileURL } from "node:url"; import { expect } from "vitest"; import { nativeJsonParseRequiredBytes } from "../../../src/application/JsonInputFile.js"; -import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; import { cliTest } from "../../support/cli/cliFixture.js"; import { waitForExit } from "../../support/process/processFixture.js"; @@ -13,7 +12,10 @@ const probe = pathToFileURL( ).href; cliTest.for([ - [Buffer.from("\uFEFF{}"), JSON_BYTE_ORDER_MARK_MESSAGE], + [ + Buffer.from("\uFEFF{}"), + expect.stringMatching(/byte-order mark.*UTF-8 without a byte-order mark/iu), + ], [Buffer.from([0xf0, 0x28, 0x8c, 0xbc]), "JSON input is not valid UTF-8"], [Buffer.from('{"secret-value":'), undefined], ] as const)( diff --git a/tests/boundary/cli/evidenceBundleCommands.test.ts b/tests/boundary/cli/evidenceBundleCommands.test.ts index 778f867692..16d4a8c704 100644 --- a/tests/boundary/cli/evidenceBundleCommands.test.ts +++ b/tests/boundary/cli/evidenceBundleCommands.test.ts @@ -23,7 +23,6 @@ import { } from "../../../src/domain/residualUnknown.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; -import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; const revisionBundle = () => { const records = ["open", "investigate"].map((operation) => @@ -254,8 +253,14 @@ cliTest.for([ expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ code: "evidence_integrity_mismatch", - message: JSON_BYTE_ORDER_MARK_MESSAGE, - remediation: { action: JSON_BYTE_ORDER_MARK_MESSAGE }, + message: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + remediation: { + action: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + }, }); expect(await readFile(path, "utf8")).toBe(bom); await expect(access(output)).rejects.toMatchObject({ code: "ENOENT" }); diff --git a/tests/boundary/cli/gamecubeDolCliOptions.test.ts b/tests/boundary/cli/gamecubeDolCliOptions.test.ts deleted file mode 100644 index d84d5fa27f..0000000000 --- a/tests/boundary/cli/gamecubeDolCliOptions.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { formatSelectionOption } from "../../../src/cli/options.js"; - -describe("gamecube-dol CLI option", () => { - it("accepts gamecube-dol as the --target-format selection", () => { - expect(formatSelectionOption.safeParse("gamecube-dol").success).toBe(true); - }); - - it("rejects an unknown format name", () => { - expect(formatSelectionOption.safeParse("gamecube").success).toBe(false); - }); -}); diff --git a/tests/boundary/cli/ghidraJavaDoctor.test.ts b/tests/boundary/cli/ghidraJavaDoctor.test.ts index c08d0f8092..1377170ea3 100644 --- a/tests/boundary/cli/ghidraJavaDoctor.test.ts +++ b/tests/boundary/cli/ghidraJavaDoctor.test.ts @@ -9,7 +9,8 @@ import { observeOwnedProcessGroups, systemProcessOwnershipHost, } from "../../../src/process/ProcessOwnershipObservation.js"; -import { resolveExecutable } from "../../../src/process/capture/ProcessScenarioRuntimeValidation.js"; +import { resolveProcessScenarioRuntimePaths } from "../../../src/process/capture/ProcessScenarioRuntimeValidation.js"; +import { processScenarioSchema } from "../../../src/domain/process/processScenario.js"; cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for([ ["missing", "missing_dependency", "runtime_missing"], @@ -244,7 +245,15 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform))( .filter((directory) => isAbsolute(directory)) .join(delimiter); await symlink( - await resolveExecutable("ps", process.cwd(), hostPath), + ( + await resolveProcessScenarioRuntimePaths( + processScenarioSchema.parse({ + executable: "ps", + working_directory: process.cwd(), + }), + { PATH: hostPath }, + ) + ).executable, join(bin, "ps"), ); await mkdir(join(install, "Ghidra"), { recursive: true }); diff --git a/tests/boundary/cli/ghidraPackedProgramDoctor.test.ts b/tests/boundary/cli/ghidraPackedProgramDoctor.test.ts index 64035ca4a3..7eb26e18c2 100644 --- a/tests/boundary/cli/ghidraPackedProgramDoctor.test.ts +++ b/tests/boundary/cli/ghidraPackedProgramDoctor.test.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { expect } from "vitest"; +import { z } from "zod"; -import { GHIDRA_PACKED_PROGRAM_RECOVERY } from "../../../src/config/ghidraExistingProject.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; @@ -14,24 +14,33 @@ cliTest( environment: { REA_GHIDRA_PROJECT_PATH: join(root, "program.gzf") }, }); expect(result.exitCode).toBe(1); - expect(result.json).toMatchObject({ - healthy: false, - scope_checks: expect.arrayContaining([ - expect.objectContaining({ - name: "runtime-configuration:REA_GHIDRA_PROJECT_PATH", - ok: false, - classification: "config_drift", - detail: `REA_GHIDRA_PROJECT_PATH: ${GHIDRA_PACKED_PROGRAM_RECOVERY}`, - }), - ]), - }); - for (const required of [ + const report = z + .object({ + healthy: z.boolean(), + scope_checks: z.array( + z.object({ + name: z.string(), + ok: z.boolean().optional(), + classification: z.string().optional(), + detail: z.string().optional(), + }), + ), + }) + .parse(result.json); + expect(report.healthy).toBe(false); + const check = report.scope_checks.find( + ({ name }) => name === "runtime-configuration:REA_GHIDRA_PROJECT_PATH", + ); + expect(check).toMatchObject({ ok: false, classification: "config_drift" }); + for (const fact of [ + "REA_GHIDRA_PROJECT_PATH", + ".gzf", + ".gpr", "analyzeHeadless", "-import", "-noanalysis", - ".gpr", "REA_GHIDRA_PROGRAM_NAME", ]) - expect(GHIDRA_PACKED_PROGRAM_RECOVERY).toContain(required); + expect(check?.detail).toContain(fact); }, ); diff --git a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts index c0a9d62127..dca47a6952 100644 --- a/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts +++ b/tests/boundary/cli/javascriptApplicationJsonOutput.test.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import { expect } from "vitest"; import { z } from "zod"; -import { JSON_LINES_SELECTION_REMEDIATION } from "../../../src/cli/jsonLines.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -69,7 +68,11 @@ for (const scenario of [ expect(failure).toMatchObject({ code: "invalid_request", category: "invalid_input", - remediation: { action: JSON_LINES_SELECTION_REMEDIATION }, + remediation: { + action: expect.stringMatching( + /--format json.*record collection.*--filter-output/iu, + ), + }, details: { issues: [ { diff --git a/tests/boundary/cli/jsonLinesOutput.test.ts b/tests/boundary/cli/jsonLinesOutput.test.ts index ec084ddadf..c5337d7df0 100644 --- a/tests/boundary/cli/jsonLinesOutput.test.ts +++ b/tests/boundary/cli/jsonLinesOutput.test.ts @@ -8,10 +8,7 @@ import { createCliResultOutput, streamCliCommandResults, } from "../../../src/cli/cliResultOutput.js"; -import { - JSON_LINES_SELECTION_REMEDIATION, - jsonLinesErrorRecord, -} from "../../../src/cli/jsonLines.js"; +import { jsonLinesErrorRecord } from "../../../src/cli/jsonLines.js"; import { parseCliOutputArguments } from "../../../src/cliOutput.js"; import { logCliCommand } from "../../../src/cliLogging.js"; import { silentLogger } from "../../../src/logger.js"; @@ -139,7 +136,11 @@ const selectionRefusal = ( code: "invalid_request", category: "invalid_input", retryable: false, - remediation: { action: JSON_LINES_SELECTION_REMEDIATION }, + remediation: { + action: expect.stringMatching( + /--format json.*record collection.*--filter-output/iu, + ), + }, recovery: { kind: "inspect_input_contract", operation: "fixture" }, details: { operation: "fixture", diff --git a/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts b/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts index 0dabed7c91..94160cbc18 100644 --- a/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts +++ b/tests/boundary/cli/keyedArchiveXmlNestingCli.test.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import { expect } from "vitest"; import { build } from "plist"; -import { plistNestingLimitMessage } from "../../../src/domain/apple/plistValue.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js"; @@ -38,7 +37,9 @@ for (const [name, xml] of [ details: { reason: "limit", truncated: true, - detail: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`, + detail: expect.stringMatching( + /keyed archive.*128 nesting levels.*no runtime override/iu, + ), }, }); }, diff --git a/tests/boundary/cli/managedMemberComparisonCli.test.ts b/tests/boundary/cli/managedMemberComparisonCli.test.ts index f47842bf22..ee2ef1fa5a 100644 --- a/tests/boundary/cli/managedMemberComparisonCli.test.ts +++ b/tests/boundary/cli/managedMemberComparisonCli.test.ts @@ -257,6 +257,14 @@ cliTest( expect(wholeReport.limitations).toEqual( comparisonLimitations(leftSource.report, rightSource.report), ); + expect(wholeReport.limitations).toContainEqual( + expect.stringMatching( + /right.*metadata\.representation.*do not establish absence/i, + ), + ); + expect(wholeReport.limitations).toContainEqual( + expect.stringMatching(/smaller selection.*type_names.*--type/i), + ); const mcpCompared = await call("compare_managed_members", { left: leftSource.evidence, right: rightSource.evidence, diff --git a/tests/boundary/cli/rawBinaryCli.test.ts b/tests/boundary/cli/rawBinaryCli.test.ts index 2e1b4b9dce..b9d520a928 100644 --- a/tests/boundary/cli/rawBinaryCli.test.ts +++ b/tests/boundary/cli/rawBinaryCli.test.ts @@ -5,6 +5,51 @@ import { expect } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { buildGamecubeDol } from "../../../src/domain/gamecubeDol.fixture.js"; + +cliTest( + "validates GameCube format and architecture selections", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-dol-cli-"); + const path = join(root, "image.dol"); + await writeFile(path, buildGamecubeDol()); + const result = await cli.run({ + arguments: [ + "inspect", + path, + "--target-format", + "gamecube-dol", + "--architecture", + "arm64", + "--json", + ], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + reason: expect.stringMatching( + /gamecube-dol.*architecture.*DOL header/iu, + ), + }, + }); + const invalid = await cli.run({ + arguments: ["inspect", path, "--target-format", "gamecube", "--json"], + environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root }, + }); + expect(invalid.exitCode).not.toBe(0); + expect(invalid.json).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: expect.arrayContaining([ + expect.objectContaining({ + code: "invalid_value", + path: "target-format", + }), + ]), + }); + }, +); cliTest( "rejects a headerless image until architecture and entry are supplied", diff --git a/tests/boundary/cli/referenceSourceUtf8.test.ts b/tests/boundary/cli/referenceSourceUtf8.test.ts index f33bd285c2..2cf5281b65 100644 --- a/tests/boundary/cli/referenceSourceUtf8.test.ts +++ b/tests/boundary/cli/referenceSourceUtf8.test.ts @@ -53,14 +53,16 @@ cliTest( { path: "comment.ts", parser: "utf-8", - reason: - "Source bytes are not valid UTF-8; import targets were not parsed.", + reason: expect.stringMatching( + /not valid UTF-8.*import targets.*not parsed/i, + ), }, { path: "invalid.mjs", parser: "utf-8", - reason: - "Source bytes are not valid UTF-8; import targets were not parsed.", + reason: expect.stringMatching( + /not valid UTF-8.*import targets.*not parsed/i, + ), }, ]); expect(graph.relationships).toEqual([ diff --git a/tests/boundary/filesystem/asarHeaderAdmission.test.ts b/tests/boundary/filesystem/asarHeaderAdmission.test.ts index 79b62db3e9..f77547099a 100644 --- a/tests/boundary/filesystem/asarHeaderAdmission.test.ts +++ b/tests/boundary/filesystem/asarHeaderAdmission.test.ts @@ -351,10 +351,14 @@ it("refuses invalid settings at startup validation and at each owner", async () ]) { const parsed = parseAsarHeaderAdmission(value); if (parsed.ok) throw new Error(`Accepted ${JSON.stringify(value)}`); + expect(parsed.error.message).toContain("REA_ASAR_MAX_HEADER_BYTES"); + expect(parsed.error.message).toMatch(/positive decimal integer/i); + expect(parsed.error.message).toContain("268435456 bytes"); + expect(parsed.error.message).toContain("16777216 bytes"); expect(parsed.error.settings).toEqual([ { setting: "REA_ASAR_MAX_HEADER_BYTES", - constraint: expect.stringContaining("at most 268435456 bytes"), + constraint: parsed.error.message, }, ]); const startup = parseEnvironment({ REA_ASAR_MAX_HEADER_BYTES: value }); @@ -362,7 +366,7 @@ it("refuses invalid settings at startup validation and at each owner", async () throw new Error(`Startup accepted ${JSON.stringify(value)}`); expect(startup.error.settings).toContainEqual({ setting: "REA_ASAR_MAX_HEADER_BYTES", - constraint: expect.stringContaining("at most 268435456 bytes"), + constraint: parsed.error.message, }); } diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index e3dafaf3fe..9859c46541 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -16,6 +16,7 @@ import { buildBinary } from "plist"; import { describe, expect, it, vi } from "vitest"; import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { espFlashSelectionError } from "../../../src/domain/firmware/espFlashSelection.js"; import { resolveAppBundleExecutable, type AppBundleFileSystem, @@ -756,19 +757,24 @@ describe("binary target I/O: ESP flash layouts", () => { }, }, }); - for (const [options, reason] of [ + for (const [options, selection, facts] of [ [ { espFlash: { flashOffset: 0x1000 } }, - "flash_offset and partition_table_offset apply only with format=esp-flash-image", + { flashOffset: 0x1000 }, + ["flash_offset", "esp-flash-image"], ], [ { formatHint: "esp-flash-image", espFlash: { partitionTableOffset: 0x8800 }, }, - "partition_table_offset must be a multiple of 0x1000, as ESP-IDF requires", + { format: "esp-flash-image", partitionTableOffset: 0x8800 }, + ["partition_table_offset", "0x1000"], ], - ] as const) + ] as const) { + const reason = espFlashSelectionError(selection); + if (reason === undefined) throw new Error("Expected selection error"); + for (const fact of facts) expect(reason).toContain(fact); expect(await parseBinaryTarget(partial, options)).toMatchObject({ ok: false, error: { @@ -777,6 +783,7 @@ describe("binary target I/O: ESP flash layouts", () => { reason, }, }); + } // Ciphertext is not recognized as a layout: unknown, not malformed. expect( await parseBinaryTarget(noise, { formatHint: "esp-flash-image" }), @@ -1149,8 +1156,7 @@ describe("binary target I/O: explicit kinds and executable headers", () => { expect(result.ok).toBe(false); expect(result.ok ? undefined : result.error).toMatchObject({ path, - reason: - "truncated Mach-O header: a 64-bit header needs 32 bytes; the file has 12", + reason: expect.stringMatching(/truncated Mach-O.*64-bit.*32 bytes.*12/i), }); }); diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 5d5d96dfb9..dbbc598740 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -341,8 +341,6 @@ it.each([ "tests/boundary/providers/GdbSessionManagerCoverage.test.ts", "tests/boundary/providers/GdbCommandQueue.test.ts", "tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts", - "tests/boundary/providers/RizinCommandTimeout.test.ts", - "tests/boundary/providers/RizinTimeoutEvidence.test.ts", "tests/boundary/providers/GdbSessionReviewRegressions.test.ts", "tests/boundary/providers/gdbTestHelpers.ts", "tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts", diff --git a/tests/boundary/filesystem/cliJsonInput.test.ts b/tests/boundary/filesystem/cliJsonInput.test.ts index 855d79e546..a4eec98606 100644 --- a/tests/boundary/filesystem/cliJsonInput.test.ts +++ b/tests/boundary/filesystem/cliJsonInput.test.ts @@ -12,7 +12,6 @@ import { parseCliJsonInput } from "../../../src/cliJsonInput.js"; import { cliJavaScriptSourcePaths } from "../../../src/cli/javascriptApplicationAnalysis.js"; import { readJsonInputFile } from "../../../src/application/JsonInputFile.js"; import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js"; -import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; import { readWithoutFifoWriter } from "../../fixtures/fifoInput.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -187,7 +186,15 @@ describe("CLI JSON streamed input", () => { ok: false, error: { input_reason: "invalid-json", - details: { issues: [{ message: JSON_BYTE_ORDER_MARK_MESSAGE }] }, + details: { + issues: [ + { + message: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + }, + ], + }, }, }); } @@ -488,7 +495,12 @@ describe("CLI JSON stdin cancellation and encoding", () => { ); it.each([ - [Buffer.from("\uFEFF{}"), JSON_BYTE_ORDER_MARK_MESSAGE], + [ + Buffer.from("\uFEFF{}"), + expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + ], [Buffer.from([0xf0, 0x28, 0x8c, 0xbc]), "JSON input is not valid UTF-8"], [Buffer.from([0xe2, 0x82]), "JSON input is not valid UTF-8"], ])( diff --git a/tests/boundary/filesystem/clientLifecycle.test.ts b/tests/boundary/filesystem/clientLifecycle.test.ts index 6957773103..cee53525f3 100644 --- a/tests/boundary/filesystem/clientLifecycle.test.ts +++ b/tests/boundary/filesystem/clientLifecycle.test.ts @@ -23,7 +23,7 @@ import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { readClientRegistrationStatuses } from "../../../src/application/ClientRegistrationStatus.js"; import { configureClientConfiguration } from "../../../src/application/SetupClientConfiguration.js"; -import { readConfigurationFile } from "../../../src/application/ClientConfigurationFiles.js"; +import { parseClientConfiguration } from "../../../src/application/ClientConfigurationDocument.js"; import { detectClients, systemSetupHost, @@ -1572,8 +1572,9 @@ describe("client configuration filesystem removal", () => { expect.objectContaining({ name: "cache", status: "retained", - detail: - "REA did not remove this item because its managed path is a symbolic link. Verify the link target before removing it manually.", + detail: expect.stringMatching( + /did not remove.*symbolic link.*verify.*link target/i, + ), }), ); expect((await runUninstall(true, systemUninstallHost(home))).status).toBe( @@ -1685,8 +1686,9 @@ describe("uninstall configuration symlink ownership", () => { name: "cursor", status: "failed", configuration_path: configPath, - detail: - "Configuration path could not be safely verified. Check its permissions and, if it is a symbolic link, verify that the link resolves to a regular file owned by the current user, then rerun uninstall.", + detail: expect.stringMatching( + /could not be safely verified.*permissions.*symbolic link.*regular file.*current user.*uninstall/i, + ), }); expect((await lstat(configPath)).isSymbolicLink()).toBe(true); await expect( @@ -1890,9 +1892,9 @@ describe("uninstall configuration preflight", () => { }); it.each([ - ['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'], - ["[]", "The configuration root must be an object"], - ['{"mcpServers":"x"}', '"mcpServers" must be an object'], + ['{"mcpServers":null,"keep":1}', "mcpServers"], + ["[]", "root"], + ['{"mcpServers":"x"}', "mcpServers"], ])( "names the structural problem in valid JSON %s", async (content, problem) => { @@ -1900,21 +1902,27 @@ describe("uninstall configuration preflight", () => { const config = join(home, ".cursor/mcp.json"); await mkdir(dirname(config), { recursive: true }); await writeFile(config, content); - expect(await readConfigurationFile(config, "json")).toMatchObject({ - ok: false, - error: { - kind: "malformed", - detail: `Configuration ${config} is malformed: ${problem}`, - }, - }); + let cause: unknown; + try { + parseClientConfiguration(content, "json"); + } catch (error: unknown) { + cause = error; + } + if (!(cause instanceof Error)) throw new Error("Expected shape error"); + expect(cause.message).toContain(problem); + expect(cause.message).toContain("object"); const result = await runUninstall(false, systemUninstallHost(home)); expect(result.status).toBe("failed"); - expect(result.items).toContainEqual({ - name: "cursor", - status: "failed", - configuration_path: config, - detail: `Configuration was not changed: ${problem}. Repair it, then rerun uninstall.`, - }); + expect(result.items).toContainEqual( + expect.objectContaining({ + name: "cursor", + status: "failed", + configuration_path: config, + detail: expect.stringContaining(cause.message), + }), + ); + const item = result.items.find(({ name }) => name === "cursor"); + expect(item?.detail).toMatch(/not changed.*repair.*uninstall/iu); expect(await readFile(config, "utf8")).toBe(content); await expect(readFile(`${config}.rea.backup`, "utf8")).rejects.toThrow(); }, diff --git a/tests/boundary/filesystem/evidenceBundleFiles.test.ts b/tests/boundary/filesystem/evidenceBundleFiles.test.ts index 3fe76795b4..3d889701e7 100644 --- a/tests/boundary/filesystem/evidenceBundleFiles.test.ts +++ b/tests/boundary/filesystem/evidenceBundleFiles.test.ts @@ -39,7 +39,6 @@ import { withEvidenceCallerInputPath, } from "../../../src/domain/evidence.js"; import { writeTextParts } from "../../../src/application/JsonFiles.js"; -import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; import { failFileReadAfterAbort } from "../../support/failFileReadAfterAbort.js"; import { holdFirstFileRead } from "../../support/holdFirstFileRead.js"; import { JsonInputPhysicalReadFailure } from "../../../src/application/JsonInputFile.js"; @@ -346,13 +345,24 @@ it.each([0, 9 * 1024 * 1024])( if (result.ok) throw new Error("Expected strict JSON BOM rejection"); expect(projectAnalysisError(result.error)).toMatchObject({ code: "evidence_integrity_mismatch", - message: JSON_BYTE_ORDER_MARK_MESSAGE, - remediation: { action: JSON_BYTE_ORDER_MARK_MESSAGE }, + message: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + remediation: { + action: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + }, details: { path, reason: "invalid-json", content_issues: [ - { reason: "invalid_format", message: JSON_BYTE_ORDER_MARK_MESSAGE }, + { + reason: "invalid_format", + message: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + }, ], }, }); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts index 3fe68d932e..59de2aa807 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts @@ -400,8 +400,9 @@ describe("JavaScript application failure diagnostics", () => { code: "unreadable_output", details: { operation: "analyze_javascript_application", - reason: - "Result schema rejected 1 issue at /semantic_graph/nodes/42/application_node_ids/0 (custom)", + reason: expect.stringMatching( + /schema.*1 issue.*\/semantic_graph\/nodes\/42\/application_node_ids\/0.*custom/i, + ), }, }); }); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts index 6b6cdc49ce..ad6642f9b3 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts @@ -49,13 +49,12 @@ it.each([ { parameter: "include_script_sources", value: false, - message: - "Runtime Evidence contains source without source-capture selection", + message: /source.*without source-capture selection/i, }, { parameter: "target_id", value: " target-main ", - message: "Runtime Evidence target disagrees with its captured result", + message: /target.*disagrees.*captured result/i, }, ])( "preserves $parameter input paths for runtime Evidence", @@ -84,7 +83,7 @@ it.each([ { path: ["runtime_observations", 0, "parameters", parameter], reason: "invalid_value", - message, + message: expect.stringMatching(message), }, ], }, diff --git a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts index f9295153c6..c62b2a5c31 100644 --- a/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts +++ b/tests/boundary/filesystem/javascriptApplicationWorkflows.test.ts @@ -597,22 +597,17 @@ describe("JavaScript application workflow error diagnostics", () => { runtime_observations: [evidence], }); - expect(traced.ok).toBe(false); - expect(reconciled.ok).toBe(false); - if (!traced.ok && traced.error instanceof AnalysisInputError) - expect(traced.error.issues).toContainEqual( + for (const result of [traced, reconciled]) { + if (result.ok || !(result.error instanceof AnalysisInputError)) + throw new Error("Expected an input error for the uncommitted graph"); + expect(result.error.issues).toContainEqual( expect.objectContaining({ - message: - "Semantic graph must commit the containing application graph", - }), - ); - if (!reconciled.ok && reconciled.error instanceof AnalysisInputError) - expect(reconciled.error.issues).toContainEqual( - expect.objectContaining({ - message: - "Semantic graph must commit the containing application graph", + message: expect.stringMatching( + /semantic graph.*commit.*application graph/i, + ), }), ); + } }); it("returns supported-operation constraints from runtime reconciliation", async () => { @@ -630,13 +625,17 @@ describe("JavaScript application workflow error diagnostics", () => { if (result.ok) throw new Error("Expected non-runtime Evidence to fail"); expect(result.error).toBeInstanceOf(AnalysisInputError); if (!(result.error instanceof AnalysisInputError)) return; - expect(result.error.issues).toContainEqual( - expect.objectContaining({ - reason: "invalid_value", - message: - "Runtime reconciliation requires inspect_web_page, inspect_electron_page, observe_javascript_runtime, or capture_electron_scenario Evidence", - }), + const issue = result.error.issues.find( + ({ reason }) => reason === "invalid_value", ); + expect(issue?.message).toMatch(/runtime reconciliation.*requires/i); + for (const operation of [ + "inspect_web_page", + "inspect_electron_page", + "observe_javascript_runtime", + "capture_electron_scenario", + ]) + expect(issue?.message).toContain(operation); }); }); diff --git a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts index 5a0611e718..51072ebc24 100644 --- a/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts +++ b/tests/boundary/filesystem/keyedArchiveXmlNesting.test.ts @@ -5,7 +5,6 @@ import { expect, it } from "vitest"; import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; import { inspectBundleKeyedArchive } from "../../../src/artifacts/apple/KeyedArchiveReader.js"; -import { plistNestingLimitMessage } from "../../../src/domain/apple/plistValue.js"; import type { OpenStepPropertyListCode } from "../../../src/domain/propertyListOpenStep.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { deeplyNestedXmlPlist } from "../../fixtures/keyedArchiveXmlNesting.js"; @@ -25,7 +24,9 @@ it.each([129, 3000])( // exhausts the call stack. expect(rejected).toMatchObject({ reason: "limit", - message: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`, + message: expect.stringMatching( + /keyed archive.*128 nesting levels.*no runtime override/iu, + ), }); }, ); @@ -70,7 +71,9 @@ it("retains the limit classification for syntactically valid deep OpenStep value }).catch((cause: unknown) => cause); expect(rejected).toMatchObject({ reason: "limit", - message: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`, + message: expect.stringMatching( + /keyed archive.*128 nesting levels.*no runtime override/iu, + ), }); }); @@ -93,7 +96,9 @@ it.each([ }).catch((cause: unknown) => cause); expect(rejected).toMatchObject({ reason: "limit", - message: `Cannot decode selected Foundation keyed archive: ${plistNestingLimitMessage()}`, + message: expect.stringMatching( + /keyed archive.*128 nesting levels.*no runtime override/iu, + ), }); }, ); diff --git a/tests/boundary/filesystem/processCliFileInput.test.ts b/tests/boundary/filesystem/processCliFileInput.test.ts index 8e186cdaac..3a2a11ef61 100644 --- a/tests/boundary/filesystem/processCliFileInput.test.ts +++ b/tests/boundary/filesystem/processCliFileInput.test.ts @@ -1,18 +1,19 @@ -import { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { captureProcessScenarioFile, compareProcessEvidenceFiles, - PROCESS_UNEXPECTED_FAILURE_REMEDIATION, } from "../../../src/application/process/ProcessCli.js"; -import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; import { ArtifactReaderFailure } from "../../../src/artifacts/ArtifactReader.js"; -import { analysisCliErrorEnvelopeSchema } from "../../../src/contracts/errorSchemas.js"; +import { + analysisCliErrorEnvelopeSchema, + analysisErrorProjectionSchema, +} from "../../../src/contracts/errorSchemas.js"; import { EvidenceFileError } from "../../../src/domain/evidenceErrors.js"; import { err } from "../../../src/domain/result.js"; import * as jsonFiles from "../../../src/application/JsonFiles.js"; @@ -54,7 +55,11 @@ describe("process CLI error projection", () => { retryable: false, message: "Process command could not complete: SECRET internal failure /private/path", - remediation: { action: PROCESS_UNEXPECTED_FAILURE_REMEDIATION }, + remediation: { + action: expect.stringMatching( + /do not repeat.*input files.*rea doctor/iu, + ), + }, details: { operation, provider_id: PROCESS_PROVIDER.id, @@ -62,10 +67,15 @@ describe("process CLI error projection", () => { }, }); expect(JSON.stringify(projected)).not.toContain("stack"); - expect(PROCESS_UNEXPECTED_FAILURE_REMEDIATION).not.toContain("SECRET"); - expect(PROCESS_UNEXPECTED_FAILURE_REMEDIATION).not.toMatch( - /\bretry\b/iu, - ); + const { error: _label, ...projection } = + analysisCliErrorEnvelopeSchema.parse(projected); + const action = + analysisErrorProjectionSchema.parse(projection).remediation.action; + expect(action).not.toContain("SECRET"); + expect(action).not.toMatch(/\bretry\b/iu); + expect( + await readFile("docs/user-visible-errors.csv", "utf8"), + ).toContain(action); } finally { read.mockRestore(); } @@ -486,13 +496,17 @@ describe("process CLI UTF-8 input diagnostics", () => { "scenario.json", ); await writeFile(path, '\uFEFF{"executable":"/bin/echo"}'); - expect(await captureProcessScenarioFile(path)).toMatchObject({ + const result = await captureProcessScenarioFile(path); + expect(result).toMatchObject({ error: "Process command failed", code: "invalid_request", category: "invalid_input", - message: `Process input file is not valid UTF-8 JSON: ${path} (${JSON_BYTE_ORDER_MARK_MESSAGE}). Repair the file, then try again.`, + message: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), details: { issues: [{ path: ["input_file"], reason: "invalid_value" }] }, }); + expect(result).toMatchObject({ message: expect.stringContaining(path) }); }); }); diff --git a/tests/boundary/filesystem/riscvIsaDeclaration.test.ts b/tests/boundary/filesystem/riscvIsaDeclaration.test.ts index b21f817aa3..8f240e07f1 100644 --- a/tests/boundary/filesystem/riscvIsaDeclaration.test.ts +++ b/tests/boundary/filesystem/riscvIsaDeclaration.test.ts @@ -112,8 +112,9 @@ describe("RISC-V ISA declarations at target resolution", () => { [{ type: SHT_RISCV_ATTRIBUTES, payload: attributes(C3_ARCH, "gnu") }], { status: "unavailable", - reason: - "RISC-V ELF declares non-riscv vendor attributes that REA does not inspect", + reason: expect.stringMatching( + /non-riscv vendor attributes.*not inspect/i, + ), }, "readable Tag_RISCV_arch declaration", ], @@ -125,7 +126,9 @@ describe("RISC-V ISA declarations at target resolution", () => { ], { status: "unavailable", - reason: "the image has more than one RISC-V attribute section", + reason: expect.stringMatching( + /more than one RISC-V attribute section/i, + ), }, "more than one RISC-V attribute section", ], @@ -134,8 +137,9 @@ describe("RISC-V ISA declarations at target resolution", () => { [{ type: SHT_RISCV_ATTRIBUTES, payload: Buffer.alloc(4097, 0x41) }], { status: "unavailable", - reason: - "RISC-V attribute section size 4097 is outside REA's inspected bound of 4096 bytes", + reason: expect.stringMatching( + /RISC-V attribute section.*4097.*4096 bytes/i, + ), }, "inspected bound", ], diff --git a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts index 45cd83da93..0f1336526f 100644 --- a/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupClientConfigCleanupOwnership.test.ts @@ -192,8 +192,9 @@ it("stops uninstall and retains the backup read owner when its close fails", asy expect(await host.removeClient(client)).toEqual({ name: "cursor", status: "failed", - detail: - "Configuration could not be backed up, so no change was made. Check file permissions, then rerun uninstall.", + detail: expect.stringMatching( + /could not be backed up.*no change.*permissions.*uninstall/i, + ), }); expect(await readFile(path, "utf8")).toBe(configured); expect(closeAttempts).toHaveLength(2); diff --git a/tests/boundary/filesystem/setupConfig.test.ts b/tests/boundary/filesystem/setupConfig.test.ts index f2b1940df4..6aca056501 100644 --- a/tests/boundary/filesystem/setupConfig.test.ts +++ b/tests/boundary/filesystem/setupConfig.test.ts @@ -595,9 +595,9 @@ describe("JSON client configuration shape diagnostics", () => { ); it.each([ - ['{"mcpServers":null,"keep":1}', '"mcpServers" must be an object'], - ["[]", "The configuration root must be an object"], - ['{"mcpServers":"x"}', '"mcpServers" must be an object'], + ['{"mcpServers":null,"keep":1}', "mcpServers"], + ["[]", "root"], + ['{"mcpServers":"x"}', "mcpServers"], ] as const)( "names structural shape problems in setup remediation: %s", async (original, problem) => { @@ -611,14 +611,15 @@ describe("JSON client configuration shape diagnostics", () => { await writeFile(configPath, original); const read = await readConfigurationFile(configPath, "json"); if (read.ok) throw new Error("expected malformed configuration"); - expect(read.error.detail).toBe( - `Configuration ${configPath} is malformed: ${problem}`, - ); + expect(read.error.kind).toBe("malformed"); + expect(read.error.detail).toContain(configPath); + expect(read.error.detail).toContain(problem); + expect(read.error.detail).toContain("object"); expect( await inspectClientConfiguration(client, {}, ["rea", "mcp"]), ).toEqual({ status: "invalid", - remediation: `Configuration ${configPath} is malformed: ${problem}`, + remediation: read.error.detail, }); expect(await readFile(configPath, "utf8")).toBe(original); }, diff --git a/tests/boundary/filesystem/targetDiagnostics.test.ts b/tests/boundary/filesystem/targetDiagnostics.test.ts index 4dd1f014aa..db69d7179f 100644 --- a/tests/boundary/filesystem/targetDiagnostics.test.ts +++ b/tests/boundary/filesystem/targetDiagnostics.test.ts @@ -61,8 +61,7 @@ describe("executable format refusal diagnostics", () => { constraint: "file_format_rejected", }, remediation: { - action: - "Review the reported file-format reason and select a supported, well-formed target.", + action: expect.stringMatching(/file.format.*supported.*target/i), }, }); expect(projected.message).not.toContain("Check that the path"); @@ -70,8 +69,7 @@ describe("executable format refusal diagnostics", () => { expect(await systemDoctorHost().inspectTarget?.(path)).toMatchObject({ classification: "unsupported_target", details: { constraint: "file_format_rejected" }, - remediation: - "Review the reported file-format reason and select a supported, well-formed target.", + remediation: projected.remediation.action, }); }, ); @@ -250,15 +248,19 @@ describe("raw-binary load rejection diagnostics", () => { code: "invalid_request", category: "invalid_input", retryable: false, - message: - "Cannot open artifact: raw-binary arm entry_point 0x1 must be 4-byte aligned.", + message: expect.stringMatching( + /raw-binary.*arm.*entry_point.*0x1.*4.byte.*align/i, + ), details: { - reason: "raw-binary arm entry_point 0x1 must be 4-byte aligned", + reason: expect.stringMatching( + /raw-binary.*arm.*entry_point.*0x1.*4.byte.*align/i, + ), constraint: "load_selection_rejected", }, remediation: { - action: - "Correct the reported load argument (--target-format, --architecture, or --entry-point on the CLI; format, architecture, or entry_point through MCP) and retry.", + action: expect.stringMatching( + /--target-format.*--architecture.*--entry-point.*entry_point/i, + ), }, }); expect(projected.message).not.toContain("Check that the path"); @@ -287,8 +289,9 @@ describe("raw-binary load rejection diagnostics", () => { expect(projected).toMatchObject({ code: "invalid_request", category: "invalid_input", - message: - "Cannot open artifact: architecture and entry_point apply only with format=raw-binary.", + message: expect.stringMatching( + /architecture.*entry_point.*format=raw-binary/i, + ), details: { constraint: "load_selection_rejected" }, }); expect(projected.message).not.toContain("Check that the path"); diff --git a/tests/boundary/firmware/firmwareReportFiles.test.ts b/tests/boundary/firmware/firmwareReportFiles.test.ts index 7aae3ca6c2..c5847c23c4 100644 --- a/tests/boundary/firmware/firmwareReportFiles.test.ts +++ b/tests/boundary/firmware/firmwareReportFiles.test.ts @@ -9,6 +9,7 @@ import { import { readFirmwareCommandReport } from "../../../src/firmware/FirmwareCommand.js"; import { FIRMWARE_LIMITS } from "../../../src/firmware/FirmwareRelease.js"; import { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { SafeOutputTree } from "../../../src/artifacts/SafeOutputTree.js"; import { @@ -235,14 +236,33 @@ it("refuses a staged file that reaches the producer's per-file limit", async () inventoryFirmwareOutput(root, 1024, 10, 4, "extract_firmware"), ).resolves.toEqual([expect.objectContaining({ size: 3 })]); await writeFile(join(root, "reached"), "1234"); - await expect( - inventoryFirmwareOutput(root, 1024, 10, 4, "extract_firmware"), - ).rejects.toMatchObject({ + const failure: unknown = await inventoryFirmwareOutput( + root, + 1024, + 10, + 4, + "extract_firmware", + ).then( + () => undefined, + (cause: unknown) => cause, + ); + expect(failure).toMatchObject({ _tag: "AnalysisResourceConstraintError", resource: "file-size", reportedLimits: { file_size_bytes: 4 }, retryable: false, }); + if (!(failure instanceof AnalysisResourceConstraintError)) throw failure; + expect(failure.remediationAction).toMatch( + /inherited.*4-byte file-size limit.*RLIMIT_FSIZE/iu, + ); + expect(failure.remediationAction).toMatch( + /raise the file-size limit.*ulimit -f/iu, + ); + expect(failure.remediationAction).toMatch( + /analysis_timeout_ms does not raise it/iu, + ); + expect(failure.remediationAction).toMatch(/unchanged request fails/iu); }); it("attributes missing sources to the selected path without relabeling snapshot destination failure", async () => { diff --git a/tests/boundary/firmware/firmwareStagingBudgetWiring.test.ts b/tests/boundary/firmware/firmwareStagingBudgetWiring.test.ts index d53126c6e2..42f7548883 100644 --- a/tests/boundary/firmware/firmwareStagingBudgetWiring.test.ts +++ b/tests/boundary/firmware/firmwareStagingBudgetWiring.test.ts @@ -8,20 +8,9 @@ import { MAX_FIRMWARE_STAGING_BYTES, MAX_FIRMWARE_STAGING_ENTRIES, } from "../../../src/domain/firmware/firmwareAnalysis.js"; -import { - firmwareStagingBudgetRemediation, - inventoryFirmwareOutput, -} from "../../../src/firmware/FirmwareFiles.js"; +import { inventoryFirmwareOutput } from "../../../src/firmware/FirmwareFiles.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -/** - * The emitted recovery must come from the remediation owner for the selected - * value, not merely resemble it: assert exact equality with - * `firmwareStagingBudgetRemediation` so an ad-hoc or truncated action that keeps - * a few familiar phrases still fails. Recovery semantics themselves stay in - * `src/firmware/FirmwareStagingBudget.test.ts`. - */ - it("refuses staging bytes at the schema ceiling with the ceiling recovery", async () => { const root = await createTestTempDirectory("rea-firmware-ceiling-bytes-"); // A sparse file reports its declared size without storing the bytes, so the @@ -54,14 +43,11 @@ it("refuses staging bytes at the schema ceiling with the ceiling recovery", asyn reported_limits: { max_output_bytes: MAX_FIRMWARE_STAGING_BYTES }, }, }); - // Compared against the owner, so a divergent emitted action fails even when it - // repeats the ceiling phrases. - expect(projected.remediation.action).toBe( - firmwareStagingBudgetRemediation( - "max_output_bytes", - MAX_FIRMWARE_STAGING_BYTES, - ), - ); + const action = projected.remediation.action; + expect(action).toContain(`max_output_bytes=${MAX_FIRMWARE_STAGING_BYTES}`); + expect(action).toMatch(/raising max_output_bytes further is not supported/iu); + expect(action).toMatch(/smaller firmware byte range.*smaller image/iu); + expect(action).toMatch(/analysis_timeout_ms does not raise/iu); }); it("still offers a raise for a budget below the schema ceiling", async () => { @@ -81,9 +67,13 @@ it("still offers a raise for a budget below the schema ceiling", async () => { code: "resource_constraint", details: { reported_limits: { max_output_bytes: 1 } }, }); - expect(projected.remediation.action).toBe( - firmwareStagingBudgetRemediation("max_output_bytes", 1), + const action = projected.remediation.action; + expect(action).toContain("max_output_bytes=1"); + expect(action).toContain( + `Increase max_output_bytes up to ${MAX_FIRMWARE_STAGING_BYTES}`, ); + expect(action).toMatch(/narrower firmware byte range/iu); + expect(action).toMatch(/analysis_timeout_ms does not raise/iu); }); // The entry ceiling needs more staged entries than the schema permits, so the diff --git a/tests/boundary/gpu/codeObjects.test.ts b/tests/boundary/gpu/codeObjects.test.ts index 73e693d59a..a3d326ca3e 100644 --- a/tests/boundary/gpu/codeObjects.test.ts +++ b/tests/boundary/gpu/codeObjects.test.ts @@ -410,8 +410,10 @@ it("retains kernels from conflicting AMDGPU notes without choosing either note o inspection: "partial", }, ]); - expect(result.value.objects[0]?.limitations).toContain( - "AMDGPU notes declare conflicting processor targets; processor identity is unknown", + expect(result.value.objects[0]?.limitations).toContainEqual( + expect.stringMatching( + /conflicting processor targets.*identity is unknown/iu, + ), ); expect(result.value.coverage).toMatchObject({ discovery: "partial", @@ -566,8 +568,8 @@ it("bounds standalone AMD note discovery before retaining metadata", async () => target: "gfx900", inspection: "capacity", }); - expect(result.value.objects[0]!.limitations).toContain( - "AMDGPU ELF note discovery exceeds the 8 MiB retained metadata budget", + expect(result.value.objects[0]!.limitations).toContainEqual( + expect.stringMatching(/note discovery.*8 MiB.*metadata budget/iu), ); expect(result.value.kernels).toEqual([]); }); diff --git a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts index 2f6010e787..f877f2d5b5 100644 --- a/tests/boundary/il2cpp/il2cppBinaryFile.test.ts +++ b/tests/boundary/il2cpp/il2cppBinaryFile.test.ts @@ -1379,7 +1379,9 @@ it("admits every generic row and issue before retention", () => { expect(rows).toContainEqual({ row: 0, address: at(0xb80n), - message: "method spec 0 names method 9 outside the 5 metadata methods", + message: expect.stringMatching( + /method spec 0.*method 9.*5 metadata methods/i, + ), }); // A refused budget check stops before any generic row is retained. const refused: unknown[] = []; @@ -1420,8 +1422,8 @@ it("reports entries past the 64-bit address space at their table row", () => { at(0xbd0n), at(0xbe0n), ]); - expect(specs.issues[1]!.message).toBe( - "method spec 1 lies outside the 64-bit address space from methodSpecs at 0xffffffffffffffff", + expect(specs.issues[1]!.message).toMatch( + /method spec 1.*64-bit address space.*methodSpecs.*0xffffffffffffffff/i, ); const slots = linkGeneric({ genericMethodPointers: top }).generic_methods!; expect(slots.instances.map((row) => row.state)).toEqual([ @@ -1432,8 +1434,9 @@ it("reports entries past the 64-bit address space at their table row", () => { expect(slots.issues).toContainEqual({ row: 1, address: at(0xbd4n), - message: - "genericMethodPointers slot 1 lies outside the 64-bit address space from genericMethodPointers at 0xffffffffffffffff", + message: expect.stringMatching( + /genericMethodPointers.*slot 1.*64-bit address space.*0xffffffffffffffff/i, + ), }); }); @@ -1551,7 +1554,9 @@ it("keeps contradicting rows unknown and reports the record address", () => { { row: 0, address: at(0xb80n), - message: "method spec 0 names method 9 outside the 5 metadata methods", + message: expect.stringMatching( + /method spec 0.*method 9.*5 metadata methods/i, + ), }, ], }); @@ -1561,8 +1566,9 @@ it("keeps contradicting rows unknown and reports the record address", () => { { row: 0, address: at(0xb80n), - message: - "method spec 0 names generic instances 2 and -1, outside the 2 readable genericInsts", + message: expect.stringMatching( + /method spec 0.*generic instances 2 and -1.*2 readable genericInsts/i, + ), }, ], }); @@ -1573,7 +1579,12 @@ it("keeps contradicting rows unknown and reports the record address", () => { { row: 0, address: at(0xd00n), - message: `genericMethodPointers slot 0 points outside unambiguous executable file data at 0x${outside.toString(16)}`, + message: expect.stringMatching( + new RegExp( + `genericMethodPointers.*slot 0.*outside.*executable.*0x${outside.toString(16)}`, + "i", + ), + ), }, ], }); @@ -1591,33 +1602,35 @@ it("keeps contradicting rows unknown and reports the record address", () => { { row: 0, address: at(0xbc0n), - message: - "method spec 0 is shared by 2 genericMethodTable rows, including row 2", + message: expect.stringMatching( + /method spec 0.*2 genericMethodTable rows.*row 2/i, + ), }, { row: 2, address: at(0xbc0n + 32n), - message: - "method spec 0 is shared by 2 genericMethodTable rows, including row 0", + message: expect.stringMatching( + /method spec 0.*2 genericMethodTable rows.*row 0/i, + ), }, ]); }); -it.each<[string, GenericMethodFixtureOptions, string]>([ +it.each<[string, GenericMethodFixtureOptions, RegExp]>([ [ "class arguments for a type without a generic container", { firstSpecMethod: 2 }, - "method spec 0 gives type 1 1 generic arguments, but it declares 0", + /method spec 0.*type 1.*1 generic arguments.*declares 0/i, ], [ "class arguments that disagree with the container's count", { firstSpecClass: 1 }, - "method spec 0 gives type 3 2 generic arguments, but it declares 1", + /method spec 0.*type 3.*2 generic arguments.*declares 1/i, ], [ "method arguments for a method without a generic container", { firstSpecMethodInstance: 0 }, - "method spec 0 gives method 3 1 generic arguments, but it declares 0", + /method spec 0.*method 3.*1 generic arguments.*declares 0/i, ], ])("keeps a method spec with %s unnamed", (_, options, message) => { const generic = linkGeneric(options).generic_methods!; @@ -1625,7 +1638,9 @@ it.each<[string, GenericMethodFixtureOptions, string]>([ expect(generic.instances.slice(1)).toEqual( FIXTURE_GENERIC_INSTANCES.slice(1), ); - expect(generic.issues).toEqual([{ row: 0, address: at(0xb80n), message }]); + expect(generic.issues).toEqual([ + { row: 0, address: at(0xb80n), message: expect.stringMatching(message) }, + ]); }); it("keeps a method spec unnamed when its owner's container is out of range", () => { @@ -1652,8 +1667,9 @@ it("keeps a method spec unnamed when its owner's container is out of range", () { row: 2, address: at(0xb80n + 24n), - message: - "method spec 2 gives method 4 1 generic arguments, but its generic container 99 is out of range", + message: expect.stringMatching( + /method spec 2.*method 4.*1 generic arguments.*container 99.*out of range/i, + ), }, ]); }); @@ -1677,7 +1693,9 @@ it("keeps a generic slot with an unresolved relocation unknown", () => { { row: 1, address: at(0xd08n), - message: "genericMethodPointers slot 1 has an unresolved relocation", + message: expect.stringMatching( + /genericMethodPointers.*slot 1.*unresolved relocation/i, + ), }, ]); }); @@ -1694,7 +1712,9 @@ it("keeps the other generic rows when one pointer slot is unmapped", () => { { row: 2, address: at(0x1000n), - message: "genericMethodPointers slot 2 is not file mapped", + message: expect.stringMatching( + /genericMethodPointers.*slot 2.*not file mapped/i, + ), }, ]); }); @@ -1778,7 +1798,9 @@ it("treats a zero-filled method spec as unobserved, not as method 0", () => { expect(generic.issues.map(({ row, message }) => ({ row, message }))).toEqual( [0, 1, 2].map((row) => ({ row, - message: `method spec ${row} is not file backed`, + message: expect.stringMatching( + new RegExp(`method spec ${row}.*not file backed`, "i"), + ), })), ); }); diff --git a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts index 987026bb9a..c2aa0a5913 100644 --- a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts @@ -109,8 +109,9 @@ test("reports browser origin scope mismatch at the caller parameter path", async { path: ["runtime_observations", 0, "parameters", "allowed_origins"], reason: "invalid_value", - message: - "Browser Evidence target is outside its recorded origin scope", + message: expect.stringMatching( + /target.*outside.*recorded origin scope/i, + ), }, ], }, diff --git a/tests/boundary/javascript/javascriptParentResultAdmission.test.ts b/tests/boundary/javascript/javascriptParentResultAdmission.test.ts index 490e829824..40e47344af 100644 --- a/tests/boundary/javascript/javascriptParentResultAdmission.test.ts +++ b/tests/boundary/javascript/javascriptParentResultAdmission.test.ts @@ -76,8 +76,9 @@ async function verifyParentAdmission(reclaimedBytes: number) { expect(error).toMatchObject({ _tag: "AnalysisResourceConstraintError", resource: "memory", - reason: - "Semantic graph for a-large.mjs exceeds the parent's remaining result budget; static and module facts were retained", + reason: expect.stringMatching( + /a-large\.mjs.*result budget.*static and module facts were retained/i, + ), }); if (!(error instanceof AnalysisResourceConstraintError)) throw new Error("Expected a typed resource constraint"); diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index 0d5a140fad..b27760bac2 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -632,8 +632,9 @@ it("formats a real worker's declined semantic budget once", async () => { const { error } = result.error; expect(error).toMatchObject({ _tag: "AnalysisResourceConstraintError", - reason: - "The parent reserved its remaining result capacity for static observations; semantic analysis of main.js was not started", + reason: expect.stringMatching( + /result capacity.*static observations.*semantic analysis of main\.js was not started/i, + ), reportedLimits: { semantic_result_budget_bytes: 0 }, }); expect(error.message.match(/Resource constraint during/gu)).toHaveLength(1); diff --git a/tests/boundary/javascript/workerOrderAudit.test.ts b/tests/boundary/javascript/workerOrderAudit.test.ts index 1b83588760..5f8fb73214 100644 --- a/tests/boundary/javascript/workerOrderAudit.test.ts +++ b/tests/boundary/javascript/workerOrderAudit.test.ts @@ -354,8 +354,9 @@ it("selects output EOF before a later abort when the current job has no terminal if (!result.ok) expect(result.error.error).toMatchObject({ _tag: "AnalysisOutputError", - reason: - "JavaScript worker closed output before the operation received its terminal response", + reason: expect.stringMatching( + /worker closed output.*terminal response/i, + ), }); } finally { await worker.close(); @@ -390,8 +391,9 @@ it("selects startup EOF before an abort queued by the same close event", async ( if (!result.ok) expect(result.error.error).toMatchObject({ _tag: "AnalysisOutputError", - reason: - "JavaScript worker closed output before the operation received its terminal response", + reason: expect.stringMatching( + /worker closed output.*terminal response/i, + ), }); } finally { await worker.close(); diff --git a/tests/boundary/mcp/artifactMcp.test.ts b/tests/boundary/mcp/artifactMcp.test.ts index 18e49ab20d..0c02df9834 100644 --- a/tests/boundary/mcp/artifactMcp.test.ts +++ b/tests/boundary/mcp/artifactMcp.test.ts @@ -26,6 +26,7 @@ import { ok as resultOk, err as resultErr, } from "../../../src/domain/result.js"; +import { createHash } from "node:crypto"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; @@ -52,13 +53,6 @@ import { observed } from "../../fixtures/analysisExecution.js"; import { cArchiveExecutable } from "../../../src/artifacts/pyinstaller/CArchive.fixture.js"; import { compareUnicodeCodePoints } from "../../../src/domain/unicodeCodePointOrder.js"; -const integrityMessage = (unpacked: boolean): string => - `${ - unpacked - ? "Declared ASAR integrity for unpacked entry main.js contradicts its companion file; packaging tools commonly sign or strip unpacked native binaries after writing the archive." - : "Artifact bytes contradict declared integrity." - } If expected, rerun inspect_artifact with integrity_policy=record-and-continue (CLI: --integrity-policy record-and-continue) to retain observed bytes as untrusted; otherwise get a fresh copy.`; - it("refuses an artifact parent and summary when its actual inventory source cannot be recorded", async ({ mcp, onTestFinished, @@ -169,21 +163,35 @@ it.for([ arguments: {}, }); expect(result.isError).toBe(true); - expect(parseMcpToolError(result)).toEqual({ + const projected = parseMcpToolError(result); + expect(projected).toEqual({ error: { code: "artifact_integrity_mismatch", category: "integrity_mismatch", - message: integrityMessage(unpacked), + message: expect.stringMatching( + /integrity.*inspect_artifact.*integrity_policy=record-and-continue.*untrusted.*fresh copy/iu, + ), details: { logical_path: "main.js", - declared_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), - calculated_sha256: expect.stringMatching(/^[a-f0-9]{64}$/u), + declared_sha256: createHash("sha256") + .update(original) + .digest("hex"), + calculated_sha256: createHash("sha256") + .update(changed) + .digest("hex"), unpacked, }, retryable: false, - remediation: { action: integrityMessage(unpacked) }, + remediation: { action: projected.error.message }, }, }); + expect(projected.error.message).toContain( + "--integrity-policy record-and-continue", + ); + if (unpacked) + expect(projected.error.message).toMatch( + /unpacked entry main\.js.*companion file/iu, + ); } finally { await mcp.close(); } diff --git a/tests/boundary/mcp/capabilityRemediationMcp.test.ts b/tests/boundary/mcp/capabilityRemediationMcp.test.ts index 54fcf13294..389767edb6 100644 --- a/tests/boundary/mcp/capabilityRemediationMcp.test.ts +++ b/tests/boundary/mcp/capabilityRemediationMcp.test.ts @@ -18,18 +18,9 @@ import { } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; import { startJebMcpFixture } from "../../fixtures/jebMcp.js"; -import { jebMcpTimeoutGuidance } from "../../../src/jeb/JebMcpDeadline.js"; import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -import { - ADB_MISSING_REMEDIATION, - ADB_NOT_EXECUTABLE_REMEDIATION, -} from "../../../src/adb/AdbConfiguration.js"; -import { - APKTOOL_MISSING_REMEDIATION, - APKTOOL_NOT_EXECUTABLE_REMEDIATION, -} from "../../../src/apktool/ApktoolConfiguration.js"; const schemaValidation = createMcpSchemaValidation(); afterAll(() => schemaValidation.clear()); @@ -82,11 +73,15 @@ it("names the JEB discovery deadline setting instead of repeating the expired de available: false, reason: "provider_unavailable", }); - // The deadline owner authors the repair; discovery must not restate the failure. - expect(entry?.remediation).toBe( - jebMcpTimeoutGuidance("discovery", 100).remediationAction, + expect(entry?.remediation).toMatch( + /increase REA_JEB_MCP_TIMEOUT_MS.*restart and reconnect REA/iu, + ); + expect(entry?.remediation).toMatch( + /catalog discovery.*tools\/call.*cleanup.*headroom/iu, + ); + expect(entry?.remediation).toMatch( + /confirm outstanding work and cleanup before retrying/iu, ); - expect(entry?.remediation).toContain("REA_JEB_MCP_TIMEOUT_MS"); expect(entry?.remediation).not.toContain("deadlineExpired"); } finally { release(); @@ -97,13 +92,13 @@ it("names the JEB discovery deadline setting instead of repeating the expired de it.skipIf(process.platform === "win32").each([ { selection: "non-executable", - adb: ADB_NOT_EXECUTABLE_REMEDIATION, - apktool: APKTOOL_NOT_EXECUTABLE_REMEDIATION, + adb: /absolute path.*adb executable.*execute permission.*search permission/iu, + apktool: /file type and execute permission.*executable apktool launcher/iu, }, { selection: "missing", - adb: ADB_MISSING_REMEDIATION, - apktool: APKTOOL_MISSING_REMEDIATION, + adb: /absolute path.*existing adb binary/iu, + apktool: /absolute apktool launcher.*wrapper.*java -jar/iu, }, ] as const)( "keeps the owner's repair for a $selection ADB and Apktool selection", @@ -129,7 +124,10 @@ it.skipIf(process.platform === "win32").each([ for (const { setting, repair, names } of entries) for (const name of names) { const entry = tools.get(name); - expect(entry).toMatchObject({ available: false, remediation: repair }); + expect(entry).toMatchObject({ + available: false, + remediation: expect.stringMatching(repair), + }); // Independent of wording: the repair names the setting and matches // the observed failure, not the other outcome's repair. expect(entry?.remediation).toContain(setting); diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index d145224246..f9880800dd 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -620,12 +620,14 @@ describe("enhanced MCP batches", () => { code: "unreadable_output", category: "execution_failure", details: { operation: "decompile", reason: "failed" }, - message: - "Analysis returned an unreadable result. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.", + message: expect.stringMatching( + /unreadable result.*rea doctor.*repeating the unchanged request.*not expected to succeed/i, + ), retryable: false, remediation: { - action: - "Analysis returned an unreadable result. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.", + action: expect.stringMatching( + /unreadable result.*rea doctor.*repeating the unchanged request.*not expected to succeed/i, + ), }, }, }, diff --git a/tests/boundary/mcp/espFlashImageMcp.test.ts b/tests/boundary/mcp/espFlashImageMcp.test.ts index 65e0aa413d..5c90bc5e83 100644 --- a/tests/boundary/mcp/espFlashImageMcp.test.ts +++ b/tests/boundary/mcp/espFlashImageMcp.test.ts @@ -16,6 +16,7 @@ import { otaRecord, } from "../../../src/domain/firmware/espFlash.fixture.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; +import { espFlashSelectionError } from "../../../src/domain/firmware/espFlashSelection.js"; import { artifactExtractionResultSchema, artifactInventoryResultSchema, @@ -88,9 +89,12 @@ it.each(["full", "compact"] as const)( flash_offset: "0x1000", }); expect(rejected.isError).toBe(true); - expect(JSON.stringify(rejected)).toContain( - "flash_offset and partition_table_offset apply only with format=esp-flash-image", - ); + const selectionError = espFlashSelectionError({ flashOffset: 0x1000 }); + if (selectionError === undefined) + throw new Error("Expected selection error"); + expect(selectionError).toContain("flash_offset"); + expect(selectionError).toContain("esp-flash-image"); + expect(JSON.stringify(rejected)).toContain(selectionError); const opened = await call("open_binary", { path, format: "esp-flash-image", diff --git a/tests/boundary/mcp/evidenceBundleInline.test.ts b/tests/boundary/mcp/evidenceBundleInline.test.ts index 453e1a71a2..753b0a328d 100644 --- a/tests/boundary/mcp/evidenceBundleInline.test.ts +++ b/tests/boundary/mcp/evidenceBundleInline.test.ts @@ -16,7 +16,6 @@ import { ANALYSIS_SNAPSHOT_TARGET } from "../../../src/domain/analysisSnapshot.f import { EMPTY_PROCESS_CAPTURE_EXAMPLE } from "../../../src/domain/process/processCaptureExample.js"; import { PROCESS_PROVIDER } from "../../../src/domain/process/processEvidenceProvider.js"; import { createServer } from "../../../src/server/createServer.js"; -import { JSON_BYTE_ORDER_MARK_MESSAGE } from "../../../src/application/Utf8JsonInput.js"; it("exports and imports GameCube DOL Evidence without losing its subject identity", async () => { const root = await createTestTempDirectory("rea-dol-bundle-mcp-"); @@ -98,8 +97,14 @@ it("preserves BOM recovery through public bundle import", async () => { expect(parseMcpToolError(response)).toMatchObject({ error: { code: "evidence_integrity_mismatch", - message: JSON_BYTE_ORDER_MARK_MESSAGE, - remediation: { action: JSON_BYTE_ORDER_MARK_MESSAGE }, + message: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + remediation: { + action: expect.stringMatching( + /byte-order mark.*UTF-8 without a byte-order mark/iu, + ), + }, }, }); } finally { diff --git a/tests/boundary/mcp/flutterMcp.test.ts b/tests/boundary/mcp/flutterMcp.test.ts index dda9ac505c..88225ed103 100644 --- a/tests/boundary/mcp/flutterMcp.test.ts +++ b/tests/boundary/mcp/flutterMcp.test.ts @@ -8,10 +8,7 @@ import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok } from "../../../src/domain/result.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createFlutterBuildAnalysisProvider } from "../../../src/composition/flutter.js"; -import { - ABI_SELECTOR_MESSAGE, - flutterResultSchemas, -} from "../../../src/domain/flutter/flutterBuildAnalysis.js"; +import { flutterResultSchemas } from "../../../src/domain/flutter/flutterBuildAnalysis.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { parseMcpEvidenceBundle } from "../../fixtures/mcpEvidenceBundle.js"; import { aotFixtureImage } from "../../../src/flutter/FlutterBuild.fixture.js"; @@ -267,7 +264,9 @@ it("advertises and enforces the same Unicode selector grammar through MCP", asyn expect.objectContaining({ path: ["abi"], reason, - message: ABI_SELECTOR_MESSAGE, + message: expect.stringMatching( + /exact lib\/ directory label.*1–1010 Unicode characters.*without '\/'/iu, + ), }), ], }, diff --git a/tests/boundary/mcp/mcpDoctor.test.ts b/tests/boundary/mcp/mcpDoctor.test.ts index 9da70535b3..4d5b9140d3 100644 --- a/tests/boundary/mcp/mcpDoctor.test.ts +++ b/tests/boundary/mcp/mcpDoctor.test.ts @@ -17,8 +17,6 @@ import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { observed } from "../../fixtures/analysisExecution.js"; import { - MCP_DOCTOR_SESSION_SCOPE_DETAIL, - compareInventory, runProductionMcpDoctor, runProductionMcpDoctorCli, } from "../../../src/mcpDoctor.js"; @@ -53,6 +51,12 @@ const expectDoctorDeadline = ( }).doctor, ), ); + expect(detail).toContain("REA_MCP_DOCTOR_DEADLINE_MS"); + expect(detail).toContain(`${String(milliseconds)} ms`); + expect(detail).toContain("source setting"); + expect(detail).toMatch(/restart REA/i); + expect(detail).toMatch(/Codex.*startup_timeout_sec.*client guidance/i); + expect(detail).toMatch(/REA does not enforce/i); }; const captureProductionCli = async ( @@ -401,7 +405,9 @@ describe("production MCP doctor family scopes", () => { expect(result.checks).toContainEqual({ name: "request-flow", ok: false, - detail: MCP_DOCTOR_SESSION_SCOPE_DETAIL, + detail: expect.stringMatching( + /binary_session.*outside.*selected tool families.*include.*session family.*request.flow.*identity/iu, + ), }); }); @@ -566,7 +572,7 @@ describe("production MCP doctor output", () => { }); describe("production MCP doctor probe validation", () => { - it("rejects a schema-invalid probe while retaining the discovered inventory", async () => { + it("reports schema-invalid probes and mismatched inventory from the child", async () => { const root = await createTestTempDirectory("rea-mcp-doctor-reply-"); const snapshotPath = join(root, "reply.json"); const environment = { PATH: process.env.PATH ?? "" }; @@ -578,10 +584,15 @@ describe("production MCP doctor probe validation", () => { env: environment, stderr: "pipe", }); + let removedToolName = ""; transport.stderr?.on("data", () => undefined); try { await client.connect(transport); const tools = await client.listTools(); + removedToolName = + tools.tools.find(({ name }) => name !== "binary_session")?.name ?? ""; + if (removedToolName === "") + throw new Error("Expected a second production tool"); const prompts = await client.listPrompts(); const toolDefinition = tools.tools.find( ({ name }) => name === "binary_session", @@ -607,7 +618,7 @@ describe("production MCP doctor probe validation", () => { await transport.close(); } // Replay real production metadata through a synthetic stdio producer. - for (const mode of ["valid", "invalid"]) { + for (const mode of ["valid", "invalid", "inventory"]) { const result = await runProductionMcpDoctor({ command: process.execPath, args: [ @@ -620,7 +631,7 @@ describe("production MCP doctor probe validation", () => { }); expect(result.healthy).toBe(mode === "valid"); expect(result.inventory?.tools.observed).toBe( - FULL_CATALOG_IDENTITY.counts.mcp_tools, + FULL_CATALOG_IDENTITY.counts.mcp_tools + (mode === "inventory" ? 1 : 0), ); expect(result.inventory?.prompts.observed).toBe( FULL_CATALOG_IDENTITY.counts.mcp_prompts, @@ -636,18 +647,22 @@ describe("production MCP doctor probe validation", () => { ok: false, detail: expect.stringMatching(/output schema/iu), }); + if (mode === "inventory") { + expect(result.inventory?.tools).toMatchObject({ + expected: FULL_CATALOG_IDENTITY.counts.mcp_tools, + observed: FULL_CATALOG_IDENTITY.counts.mcp_tools + 1, + missing: [removedToolName], + unexpected: ["doctor_unexpected_fixture"], + duplicates: ["doctor_unexpected_fixture"], + }); + expect(result.checks).toContainEqual({ + name: "inventory:tools", + ok: false, + detail: expect.stringContaining("doctor_unexpected_fixture"), + }); + } } }, 30_000); - - it("reports exact missing, unexpected, and duplicate inventory names", () => { - expect(compareInventory(["a", "b"], ["b", "c", "c"])).toEqual({ - expected: 2, - observed: 3, - missing: ["a"], - unexpected: ["c"], - duplicates: ["c"], - }); - }); }); describe("production MCP doctor child lifecycle", () => { diff --git a/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts b/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts index d269985617..0f8fab4191 100644 --- a/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts +++ b/tests/boundary/mcp/processEvidenceIdentityMcp.test.ts @@ -241,16 +241,16 @@ it.each(providerNames)( ); const DIGEST_MISMATCH_REASON = - "Evidence validation failed at evidence_id: Evidence semantic identifier does not match its record"; + /evidence_id.*semantic identifier.*does not match/i; -const identityMismatchReason = (tool: string): string => { +const identityMismatchReason = (tool: string): RegExp => { switch (tool) { case "find_changed_behavior": - return "Comparison Evidence operation, predicate, and provider disagree"; + return /comparison Evidence.*operation.*predicate.*provider.*disagree/i; case "correlate_static_and_runtime": - return "Comparison Evidence operation, predicate, provider, or citations disagree"; + return /comparison Evidence.*operation.*predicate.*provider.*citations.*disagree/i; case "verify_reconstruction": - return "Comparison Evidence identity or authority disagrees"; + return /comparison Evidence.*identity.*authority.*disagrees/i; default: throw new Error(`Unexpected comparison consumer ${tool}`); } @@ -260,26 +260,26 @@ const unsupportedOperationIssue = ( tool: string, ): { readonly path: readonly (string | number)[]; - readonly message: string; + readonly message: RegExp; } => { switch (tool) { case "find_changed_behavior": return { path: ["comparisons", 0], message: - "Changed-behavior analysis requires process, artifact, or function comparison Evidence", + /changed.behavior.*requires.*process.*artifact.*function comparison Evidence/i, }; case "correlate_static_and_runtime": return { path: ["runtime_comparisons", 0], message: - "Static/runtime correlation requires process comparison Evidence", + /static\/runtime correlation.*requires.*process comparison Evidence/i, }; case "verify_reconstruction": return { path: ["claims", 0, "comparison_evidence_id"], message: - "Claim terminal-output requires compare_process_captures Evidence; selected fixture_comparison", + /terminal-output.*requires compare_process_captures Evidence.*fixture_comparison/i, }; default: throw new Error(`Unexpected comparison consumer ${tool}`); @@ -293,11 +293,13 @@ const expectComparisonRejection = ( ) => { if (kind === "operation") { const issue = unsupportedOperationIssue(tool); - expect(parseMcpToolError(response), tool).toMatchObject({ + const parsed = parseMcpToolError(response); + expect(parsed.error.message).toMatch(issue.message); + expect(parsed, tool).toMatchObject({ error: { code: "invalid_request", category: "invalid_input", - message: issue.message, + message: parsed.error.message, recovery: { kind: "inspect_input_contract", operation: tool }, details: { operation: tool, @@ -305,7 +307,7 @@ const expectComparisonRejection = ( { path: [...issue.path], reason: "invalid_value", - message: issue.message, + message: parsed.error.message, }, ], }, @@ -315,12 +317,13 @@ const expectComparisonRejection = ( } const reason = kind === "digest" ? DIGEST_MISMATCH_REASON : identityMismatchReason(tool); - expect(parseMcpToolError(response), tool).toMatchObject({ + const parsed = parseMcpToolError(response); + expect(parsed.error.message).toMatch(reason); + expect(parsed, tool).toMatchObject({ error: { code: "evidence_integrity_mismatch", category: "integrity_mismatch", - message: reason, - details: { reason }, + details: { reason: parsed.error.message }, }, }); }; diff --git a/tests/boundary/mcp/rizinOutputPreservation.test.ts b/tests/boundary/mcp/rizinOutputPreservation.test.ts index 2c09f3e9ef..3a86b60c10 100644 --- a/tests/boundary/mcp/rizinOutputPreservation.test.ts +++ b/tests/boundary/mcp/rizinOutputPreservation.test.ts @@ -35,11 +35,14 @@ const observe = async ( }, }); const manager = createRizinTestManager( - {}, + { + REA_DEBUGGER_COMMAND_DEADLINE_MS: "100", + ...(maximum === undefined + ? {} + : { REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: String(maximum) }), + }, { spawnProcess: injected.spawnProcess, - frameTimeoutMs: 100, - ...(maximum === undefined ? {} : { maxCommandOutputBytes: maximum }), }, ); const server = new McpServer({ name: "rizin-output-test", version: "1" }); diff --git a/tests/boundary/mcp/rizinPendingOutput.test.ts b/tests/boundary/mcp/rizinPendingOutput.test.ts index ede862f3de..937fc0fda5 100644 --- a/tests/boundary/mcp/rizinPendingOutput.test.ts +++ b/tests/boundary/mcp/rizinPendingOutput.test.ts @@ -44,10 +44,9 @@ test.each([ }, }); const manager = createRizinTestManager( - {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "100" }, { spawnProcess: injected.spawnProcess, - frameTimeoutMs: 100, }, ); const server = new McpServer({ name: "rizin-partial-test", version: "1" }); @@ -98,33 +97,101 @@ test.each([ ); test.each([ - { mode: "exit", maximum: 10, invalid: true, expected: "first\n�A" }, - { mode: "cancel", maximum: 9, invalid: true, expected: "first\n�" }, - { mode: "exit", maximum: 9, invalid: false, expected: "first\n�" }, - { mode: "cancel", maximum: 8, invalid: true, expected: "first" }, + { + mode: "exit", + maximum: 10, + invalid: true, + expected: "first\n�A", + payload: null, + }, + { + mode: "cancel", + maximum: 9, + invalid: true, + expected: "first\n�", + payload: null, + }, + { + mode: "exit", + maximum: 9, + invalid: false, + expected: "first\n�", + payload: null, + }, + { + mode: "cancel", + maximum: 8, + invalid: true, + expected: "first", + payload: null, + }, + { mode: "exit", maximum: 3, invalid: false, expected: "", payload: "🚀🚀X" }, + { + mode: "exit", + maximum: 4, + invalid: false, + expected: "🚀", + payload: "🚀🚀X", + }, + { + mode: "exit", + maximum: 7, + invalid: false, + expected: "🚀", + payload: "🚀🚀X", + }, + { + mode: "exit", + maximum: 8, + invalid: false, + expected: "🚀🚀", + payload: "🚀🚀X", + }, + { + mode: "exit", + maximum: 5, + invalid: false, + expected: "中", + payload: "中文X", + }, + { + mode: "exit", + maximum: 6, + invalid: false, + expected: "中文", + payload: "中文X", + }, + { + mode: "exit", + maximum: 3, + invalid: false, + expected: "hel", + payload: "hello", + }, ])( "bounds pending UTF-8 after $mode (budget: $maximum, invalid: $invalid)", - async ({ mode, maximum, invalid, expected }) => { + async ({ mode, maximum, invalid, expected, payload }) => { const controller = new AbortController(); const injected = createInjectedProcess({ onWrite: (text, control) => { if (!text.includes("!echo __REA_COMMAND_COMPLETE_")) return; control.output( - Buffer.concat([ - Buffer.from("first\0"), - invalid ? Buffer.from([0xff]) : Buffer.from("�"), - Buffer.from("ABCDEFGHIJKLM"), - ]), + payload === null + ? Buffer.concat([ + Buffer.from("first\0"), + invalid ? Buffer.from([0xff]) : Buffer.from("�"), + Buffer.from("ABCDEFGHIJKLM"), + ]) + : Buffer.from(payload), ); if (mode === "exit") control.exit(); else controller.abort(); }, }); const manager = createRizinTestManager( - {}, + { REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: String(maximum) }, { spawnProcess: injected.spawnProcess, - maxCommandOutputBytes: maximum, }, ); try { @@ -437,11 +504,12 @@ test("publishes the bounded prefix when an oversized frame has no terminator", a }, }); const manager = createRizinTestManager( - {}, + { + REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: "20", + REA_DEBUGGER_COMMAND_DEADLINE_MS: "100", + }, { spawnProcess: injected.spawnProcess, - maxCommandOutputBytes: 20, - frameTimeoutMs: 100, }, ); try { diff --git a/tests/boundary/mcp/universalSliceMcp.test.ts b/tests/boundary/mcp/universalSliceMcp.test.ts index 1009f3c1ad..c91e030418 100644 --- a/tests/boundary/mcp/universalSliceMcp.test.ts +++ b/tests/boundary/mcp/universalSliceMcp.test.ts @@ -227,8 +227,9 @@ sliceTest( issues: [ { path: ["architecture"], - message: - "architecture arm64 was selected, but the file is not a universal Mach-O; omit architecture.", + message: expect.stringMatching( + /architecture arm64.*not a universal Mach-O.*omit architecture/i, + ), }, ], }, diff --git a/tests/boundary/pcap/pcapProvider.test.ts b/tests/boundary/pcap/pcapProvider.test.ts index 3a368fbb69..a5ffb8c229 100644 --- a/tests/boundary/pcap/pcapProvider.test.ts +++ b/tests/boundary/pcap/pcapProvider.test.ts @@ -7,7 +7,6 @@ import { withVerifierCleanup } from "../../../scripts/lib/verifier-cleanup.mjs"; import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js"; import { PcapProvider } from "../../../src/pcap/PcapProvider.js"; -import { PCAP_LIMITER_REMEDIATION } from "../../../src/pcap/PcapNativeBound.js"; import { CAPINFOS_COMMAND_ENV, TSHARK_COMMAND_ENV, @@ -467,11 +466,16 @@ it("names the prlimit selection when the Linux limiter is unusable", async () => }); onTestFinished(() => provider.close()); // Readiness keeps the limiter owner's repair instead of Wireshark advice. - expect(await provider.inspectAvailability()).toMatchObject({ + const availability = await provider.inspectAvailability(); + expect(availability).toMatchObject({ status: "unavailable", reason: expect.stringContaining("Cannot establish the selected Linux"), - remediation: PCAP_LIMITER_REMEDIATION, + remediation: expect.stringMatching( + /REA_TSHARK_PRLIMIT_COMMAND.*\/usr\/bin\/prlimit.*absolute.*executable.*util-linux prlimit/iu, + ), }); + if (availability.status !== "unavailable") + throw new Error("Expected an unavailable limiter"); const result = await provider.execute({ operation: "inspect_pcap_capture", input: { path: "/not-opened/capture.pcap" }, @@ -484,7 +488,7 @@ it("names the prlimit selection when the Linux limiter is unusable", async () => }); expect(projected.message).toContain("must be absolute"); expect(projected.message).toContain("Check that REA_TSHARK_PRLIMIT_COMMAND"); - expect(projected.remediation.action).toBe(PCAP_LIMITER_REMEDIATION); + expect(projected.remediation.action).toBe(availability.remediation); }); it("rejects directory and non-prlimit selections before opening the capture", async () => { diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index 63f77789c4..7729165855 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -29,7 +29,6 @@ import { createDarwinProcessRunTokenReader, DarwinProcessOwnershipInspectionError, SWIFTC_INTERRUPT, - swiftcCompileTimeoutRecovery, } from "../../../src/process/DarwinProcessRunTokenReader.js"; import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; import { @@ -230,11 +229,11 @@ it.skipIf(process.platform === "win32")( stderrTruncated: true, killed: false, }); - expect(failure.message).toContain( - "(compiler result: exit code 1): module cache is not writable", + expect(failure.message).toMatch( + /exit code 1.*module cache is not writable/iu, ); - expect(failure.message).toContain( - "[diagnostics truncated at 512 bytes; this release has no runtime override]", + expect(failure.message).toMatch( + /diagnostics truncated at 512 bytes.*no runtime override/iu, ); const normalized = normalizeCaptureFailure(failure, undefined); if (!(normalized instanceof ProcessCaptureError)) @@ -277,10 +276,14 @@ it.skipIf(process.platform === "win32")( }); try { const failure = await preparationFailure(reader); - expect(failure.message).toContain( - "macOS process ownership helper compilation failed via xcrun (compiler result: timed out after 3000 ms.", + expect(failure.message).toMatch( + /ownership helper.*xcrun.*timed out after 3000 ms/iu, ); - expect(failure.message).toContain(swiftcCompileTimeoutRecovery(3_000)); + expect(failure.message).toContain("REA_SWIFTC_COMPILE_TIMEOUT_MS"); + expect(failure.message).toContain("1..2147483647"); + expect(failure.message).toContain("120000 ms"); + expect(failure.message).toMatch(/restart REA/i); + expect(failure.message).toMatch(/next ownership-helper compile/i); expect(failure.message).toContain( "error: PCH was compiled with module cache path that differs", ); @@ -312,8 +315,9 @@ it.skipIf(process.platform === "win32")( }); try { await expect(preparationFailure(reader)).resolves.toMatchObject({ - message: - "macOS process ownership helper compilation failed via xcrun (compiler result: stopped by signal SIGSEGV): fatal: segmentation fault during type checking", + message: expect.stringMatching( + /xcrun.*SIGSEGV.*fatal: segmentation fault during type checking/i, + ), compilerOutcome: { outcome: "signaled", code: null, diff --git a/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts b/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts index 81adfd6f88..8bbc75c84b 100644 --- a/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts +++ b/tests/boundary/process/javascriptRuntimeReconciliationAmbiguity.test.ts @@ -154,8 +154,9 @@ it("rejects runtime source bytes whose Evidence omits source-capture approval", "include_script_sources", ], reason: "invalid_value", - message: - "Runtime Evidence contains source without source-capture selection", + message: expect.stringMatching( + /source.*without source-capture selection/i, + ), }, ], }, diff --git a/tests/boundary/process/ownedDebuggerSession.test.ts b/tests/boundary/process/ownedDebuggerSession.test.ts index acb51a1344..7e9d64552f 100644 --- a/tests/boundary/process/ownedDebuggerSession.test.ts +++ b/tests/boundary/process/ownedDebuggerSession.test.ts @@ -327,8 +327,9 @@ it("refuses Windows job-backed debugger termination before signaling", async () stopOwnedDebuggerProcess(session, "GDB", { platform: "win32" }), ).resolves.toEqual({ status: "incomplete", - reason: - "Owned GDB process cannot be stopped safely on Windows because job termination can also terminate its inferior processes.", + reason: expect.stringMatching( + /Windows.*job termination.*inferior processes/i, + ), }); expect(signals).toEqual([]); expect(outputUnrefs).toBe(0); @@ -360,8 +361,9 @@ it("retains an already-exited Windows session while its job has descendants", as expect(result).toEqual({ status: "incomplete", - reason: - "Owned GDB process leader exited, but its Windows Job Object still has active descendants or has not reported settlement.", + reason: expect.stringMatching( + /leader exited.*Windows Job Object.*active descendants.*settlement/i, + ), }); expect(disposals).toBe(0); expect(outputUnrefs).toBe(0); @@ -393,8 +395,9 @@ it("retains an exited POSIX session while descendants still hold its output stre expect(result).toEqual({ status: "incomplete", - reason: - "Owned GDB process leader exited, but inherited output streams are still open or have not reported settlement.", + reason: expect.stringMatching( + /leader exited.*output streams.*open.*settlement/i, + ), }); expect(disposals).toBe(0); expect(outputUnrefs).toBe(0); @@ -477,8 +480,9 @@ it("does not signal an active debugger when its provider cannot detach the infer expect(result).toEqual({ status: "incomplete", - reason: - "Owned GDB inferior detach cannot be verified because the provider has no detach capability.", + reason: expect.stringMatching( + /inferior detach.*cannot be verified.*no detach capability/i, + ), }); expect(signals).toEqual([]); }); @@ -505,8 +509,9 @@ it("preserves a non-Error provider detach rejection and does not signal the debu expect(result).toEqual({ status: "incomplete", - reason: - "Owned GDB inferior detach could not be verified: detach state unknown", + reason: expect.stringMatching( + /inferior detach.*could not be verified.*detach state unknown/i, + ), }); expect(signals).toEqual([]); }); @@ -693,8 +698,9 @@ it("fails closed when detach lacks durable output sink confirmation", async () = expect(result).toEqual({ status: "incomplete", - reason: - "Owned GDB provider did not confirm no inferior or detachment with a durable output sink.", + reason: expect.stringMatching( + /did not confirm no inferior or detachment.*durable output sink/i, + ), }); expect(signals).toEqual([]); }); @@ -743,8 +749,9 @@ it("retains an owned debugger when inferior detach does not settle", async () => await vi.advanceTimersByTimeAsync(1_000); expect(await attempt).toEqual({ status: "incomplete", - reason: - "Owned GDB inferior detach was not verified within 1000 ms. This release has no runtime override.", + reason: expect.stringMatching( + /inferior detach.*not verified.*1000 ms.*no runtime override/i, + ), }); const retry = stopOwnedDebuggerProcess(session, "GDB", { platform: "linux", @@ -752,8 +759,9 @@ it("retains an owned debugger when inferior detach does not settle", async () => await vi.advanceTimersByTimeAsync(1_000); expect(await retry).toEqual({ status: "incomplete", - reason: - "Owned GDB inferior detach was not verified within 1000 ms. This release has no runtime override.", + reason: expect.stringMatching( + /inferior detach.*not verified.*1000 ms.*no runtime override/i, + ), }); expect(detachCalls).toBe(1); expect(signals).toEqual([]); diff --git a/tests/boundary/process/processCaptureCapability.test.ts b/tests/boundary/process/processCaptureCapability.test.ts index 23d0219c92..6cd8350714 100644 --- a/tests/boundary/process/processCaptureCapability.test.ts +++ b/tests/boundary/process/processCaptureCapability.test.ts @@ -1,56 +1,48 @@ import { describe, expect, it } from "vitest"; import { - processCaptureProbeFailureReason, processCaptureOwnershipUnavailableReason, probeProcessCaptureCapability, } from "../../../src/process/capture/ProcessCaptureCapability.js"; describe("process capture capability diagnostics", () => { - it("distinguishes missing native modules from runtime ABI mismatches", () => { - expect( - processCaptureProbeFailureReason( - Object.assign( - new Error("Cannot find package '@lydell/node-pty-win32-x64'"), - { - code: "ERR_MODULE_NOT_FOUND", - }, - ), + it.each([ + { + cause: Object.assign( + new Error("Cannot find package '@lydell/node-pty-win32-x64'"), + { code: "ERR_MODULE_NOT_FOUND" }, ), - ).toContain("optional dependencies enabled"); - expect( - processCaptureProbeFailureReason( - Object.assign( - new Error("compiled against a different Node.js version"), - { - code: "ERR_DLOPEN_FAILED", - }, - ), + guidance: /optional dependencies enabled/i, + }, + { + cause: Object.assign( + new Error("compiled against a different Node.js version"), + { code: "ERR_DLOPEN_FAILED" }, ), - ).toContain("incompatible with this Node.js runtime"); - }); - - it("returns the actual cause from the real probe boundary", async () => { - const capability = await probeProcessCaptureCapability({ - platform: "darwin", - loadPty: async () => { - throw Object.assign(new Error("optional binary was not installed"), { - code: "ERR_MODULE_NOT_FOUND", - }); - }, - }); - expect(capability).toEqual({ - available: false, - backend: "node-pty", - reason: expect.stringContaining("optional binary was not installed"), - }); - }); - - it("preserves the actual diagnostic for a genuine probe failure", () => { - expect( - processCaptureProbeFailureReason(new Error("spawn EACCES")), - ).toContain("spawn EACCES"); - }); + guidance: /incompatible with this Node.js runtime/i, + }, + { + cause: new Error("spawn EACCES"), + guidance: /operating-system or process-startup error/i, + }, + ])( + "reports the probe failure and recovery for $cause.message", + async ({ cause, guidance }) => { + const capability = await probeProcessCaptureCapability({ + platform: "linux", + loadPty: async () => { + throw cause; + }, + }); + expect(capability).toMatchObject({ + available: false, + backend: "node-pty", + }); + if (capability.available) throw new Error("Expected probe failure"); + expect(capability.reason).toContain(cause.message); + expect(capability.reason).toMatch(guidance); + }, + ); it("prepares Darwin ownership inspection before probing or admitting a PTY", async () => { let loaded = false; diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index a9a0e81cc4..348267374d 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -251,8 +251,10 @@ itWithCaptureCapability( expect(Date.parse(result.value.manifest.completed_at)).toBeLessThan( Date.parse(result.value.manifest.started_at), ); - expect(result.value.limitations).toContain( - "Wall-clock completion time precedes start time; elapsed capture timing is recorded separately and remains monotonic.", + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + /completion time precedes start time.*elapsed.*monotonic/iu, + ), ); expect( result.value.frames.map(({ at_ms }) => at_ms), @@ -450,8 +452,9 @@ itWithCaptureCapability( ); expect(observations.filesystem_snapshots.after).toEqual({ state: "unavailable", - reason: - "Final filesystem snapshot failed: fixture final snapshot failure", + reason: expect.stringMatching( + /final.*snapshot.*fixture final snapshot failure/i, + ), }); expect(observations.event_journal.state).toBe("available"); if (observations.event_journal.state !== "available") @@ -534,8 +537,10 @@ itWithCaptureCapability( }, }); expect(capture.exit.reason).toBe("timeout"); - expect(capture.limitations).toContain( - "The executable digest is a prelaunch file sample; matching path metadata immediately after spawn does not prove an atomic operating-system image binding.", + expect(capture.limitations).toContainEqual( + expect.stringMatching( + /digest.*prelaunch.*path metadata.*does not prove.*atomic.*image binding/iu, + ), ); } finally { await rm(root, { recursive: true, force: true }); diff --git a/tests/boundary/process/processCli.test.ts b/tests/boundary/process/processCli.test.ts index a8611c2af2..dfcb2f9025 100644 --- a/tests/boundary/process/processCli.test.ts +++ b/tests/boundary/process/processCli.test.ts @@ -318,8 +318,9 @@ describe("process CLI errors", () => { path: ["arguments", 0], reason: "invalid_format", expected: "regex", - message: - "Values passed to operating-system APIs cannot contain NUL", + message: expect.stringMatching( + /operating.system.*cannot contain NUL/i, + ), }, ], }, @@ -454,8 +455,9 @@ describe("process CLI evidence validation", () => { code: "invalid_request", retryable: false, category: "invalid_input", - message: - "Capture evidence is not from the current process-capture workflow. Create new capture evidence, then try again.", + message: expect.stringMatching( + /capture evidence.*not from.*process-capture.*create new capture evidence/i, + ), details: { operation: "compare_process_captures", issues: [{ path: ["left"], reason: "invalid_value" }], diff --git a/tests/boundary/process/regexMatcher.test.ts b/tests/boundary/process/regexMatcher.test.ts index 75f4f65f12..b95a64b4e3 100644 --- a/tests/boundary/process/regexMatcher.test.ts +++ b/tests/boundary/process/regexMatcher.test.ts @@ -74,6 +74,21 @@ it("names the configurable regex deadline and the effective worker heap", () => readLaunchNodeOptions(), ); expect(REGEX_MATCHING_LIMITATION).toBe(regexMatchingLimitation(heap)); + expect(REGEX_MATCHING_LIMITATION).toMatch( + /5000 ms matching deadline \(source default\)/iu, + ); + expect(REGEX_MATCHING_LIMITATION).toMatch( + /128 MiB worker heap request \(source default\)/iu, + ); + expect(REGEX_MATCHING_LIMITATION).toMatch( + /regex\.matching_deadline_ms.*regex\.worker_heap_request_mib/iu, + ); + if (heap.kind === "effective") + expect(REGEX_MATCHING_LIMITATION).toContain(`effective ${heap.mib} MiB`); + else + expect(REGEX_MATCHING_LIMITATION).toMatch( + /effective old-generation heap.*not established/iu, + ); const deadline = projectAnalysisError( regexMatchingDeadlineError("search_procedures"), ); diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 6b01b1d25f..d961251998 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -13,12 +13,10 @@ import { } from "../../../src/domain/analysisErrorCore.js"; import { commandMayChangeInferiorOutput, - gdbErrorMessage, gdbOneShotCommandMayInvalidateDetachProof, gdbThreadGroupCount, GdbSessionManager, miStringField, - waitForGdbCommandTurn, } from "../../../src/gdb/GdbSessionManager.js"; import type { OwnedProviderProcessSpawnOptions, @@ -145,11 +143,16 @@ it.each([ it.each([ ["show version", false], ["show inferior-tty", false], + [" show inferior-tty ", false], + ["-inferior-tty-show", false], ["help inferior-tty", false], ["show inferior-tty\nset inferior-tty /tmp/other", true], ["help inferior-tty; run > /tmp/other", true], ["set pagination off", false], ["set inferior-t /tmp/output", true], + ["set inferior-tty /tmp/output", true], + ["show inferior-t; set inferior-tty /tmp/output", true], + ["show inferior-tty; define redirect = run > /tmp/output", true], ["inferior-tty /tmp/output", true], ["tty /dev/pts/2", true], ["source commands.gdb", true], @@ -182,24 +185,6 @@ it.each([ expect(gdbOneShotCommandMayInvalidateDetachProof(command)).toBe(expected); }); -it.each([ - ["show inferior-tty", false], - [" show inferior-tty ", false], - ["-inferior-tty-show", false], - ["set inferior-tty /tmp/output", true], - ["show inferior-t; set inferior-tty /tmp/output", true], - ["show inferior-tty; define redirect = run > /tmp/output", true], -] as const)("classifies inferior TTY commands: %s", (command, expected) => { - expect(commandMayChangeInferiorOutput(command)).toBe(expected); -}); - -it.each([ - [new Error("provider failure"), "provider failure"], - ["non-error provider failure", "non-error provider failure"], -] as const)("formats unknown GDB boundary failures", (cause, message) => { - expect(gdbErrorMessage(cause)).toBe(message); -}); - it("rejects an invalid caller TTY and platforms without safe GDB ownership", async () => { const invalid = await new GdbSessionManager().start(undefined, { inferiorTty: "bad\0tty", @@ -1556,7 +1541,7 @@ it("keeps inferior TTY readback when the caller output cap is smaller than that ); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { - maxCommandOutputBytes: 80, + environment: { REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: "80" }, }); const started = await manager.start(undefined, { inferiorTty: tty }); @@ -1599,10 +1584,10 @@ const managerWithFakeSpawner = ( ): GdbSessionManager => { const manager = new GdbSessionManager({ platform: "linux", - environment: { REA_GDB_COMMAND: "fake-gdb" }, spawnProcess, verifyInferiorOutputSink: async () => undefined, ...options, + environment: { REA_GDB_COMMAND: "fake-gdb", ...options.environment }, }); managers.push(manager); return manager; @@ -1913,7 +1898,7 @@ it("handles a provider with no MI stdout stream as a startup timeout", async () it("preserves a timeout while startup waits for the automatic-loading response", async () => { const fake = createFakeSpawner((_child, _token, _command) => undefined); const manager = managerWithFakeSpawner(fake.spawnProcess, { - commandTimeoutMs: 25, + environment: { REA_DEBUGGER_COMMAND_DEADLINE_MS: "25" }, }); const started = await manager.start(); @@ -1928,7 +1913,7 @@ it("preserves a timeout while startup waits for the automatic-loading response", diagnostics: { setting: DEBUGGER_COMMAND_DEADLINE_SETTING, effective_value: 25, - source: "runtime", + source: "setting", unit: "ms", restart_required: true, }, @@ -1940,7 +1925,7 @@ it("preserves a timeout while startup waits for the automatic-loading response", it("maps an acknowledged startup-command timeout to the startup operation", async () => { const fake = createFakeSpawner((_child, _token, _command) => undefined); const manager = managerWithFakeSpawner(fake.spawnProcess, { - commandTimeoutMs: 20, + environment: { REA_DEBUGGER_COMMAND_DEADLINE_MS: "20" }, stopProcess: async () => ({ status: "stopped" }), }); @@ -1995,7 +1980,7 @@ it("maps startup command timeout and preserves initial no-inferior evidence when let retainedSessionId = ""; const fake = createFakeSpawner((_child, _token, _command) => undefined); const manager = managerWithFakeSpawner(fake.spawnProcess, { - commandTimeoutMs: 25, + environment: { REA_DEBUGGER_COMMAND_DEADLINE_MS: "25" }, stopProcess: async (session) => { retainedSessionId = (session as typeof session & { id: string }).id; const debuggerSession = session as typeof session & { @@ -2244,7 +2229,7 @@ it("rejects the command queued behind a timed-out GDB request", async () => { fake.children[0]?.stdout.write(`${_token}^done\n`); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { - commandTimeoutMs: 25, + environment: { REA_DEBUGGER_COMMAND_DEADLINE_MS: "25" }, }); const started = await expectGdbSessionStarted(manager); @@ -2260,43 +2245,6 @@ it("rejects the command queued behind a timed-out GDB request", async () => { if (!rejected.ok) expect(rejected.error._tag).toBe("AnalysisInputError"); }); -it("waits for the prior command and preserves cancellation and queue failures", async () => { - const resolved = Promise.resolve(); - expect(waitForGdbCommandTurn(resolved)).toBe(resolved); - - const preAborted = new AbortController(); - const queueReason = "queue cancelled before admission"; - preAborted.abort(queueReason); - await expect( - waitForGdbCommandTurn(Promise.resolve(), preAborted.signal), - ).rejects.toMatchObject({ - _tag: "AnalysisCancelledError", - cause: queueReason, - }); - - let release!: () => void; - const pending = new Promise((resolve) => { - release = resolve; - }); - const controller = new AbortController(); - const activeQueueReason = { source: "active queue cancellation" }; - const cancelledWait = waitForGdbCommandTurn(pending, controller.signal); - controller.abort(activeQueueReason); - await expect(cancelledWait).rejects.toMatchObject({ - _tag: "AnalysisCancelledError", - cause: activeQueueReason, - }); - release(); - - const queueFailure = new Error("previous command queue failed"); - await expect( - waitForGdbCommandTurn( - Promise.reject(queueFailure), - new AbortController().signal, - ), - ).rejects.toBe(queueFailure); -}); - it("retains a multi-run console command through its completion query before dispatching a follower", async () => { let completionToken = ""; let sourceToken = ""; @@ -2502,14 +2450,18 @@ it("correlates command output records and reports invalid or cancelled requests" ); expect(signalled.ok).toBe(true); const cancelled = new AbortController(); - cancelled.abort(); + const reason = { source: "cancelled before admission" }; + cancelled.abort(reason); const rejected = await manager.execute( started.value.session_id, "info registers", cancelled.signal, ); expect(rejected.ok).toBe(false); - if (!rejected.ok) expect(rejected.error._tag).toBe("AnalysisCancelledError"); + if (!rejected.ok) { + expect(rejected.error._tag).toBe("AnalysisCancelledError"); + expect(rejected.error.cause).toBe(reason); + } const closed = await manager.close(started.value.session_id); expect(closed.ok).toBe(false); expect(manager.status(started.value.session_id)).toBeDefined(); @@ -3075,7 +3027,7 @@ it("retains ownership and sends no signal when MI state is saturated or unknown" fake.children[0]?.stdout.write(`${token}^error,msg="unknown"\n`); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { - commandTimeoutMs: 20, + environment: { REA_DEBUGGER_COMMAND_DEADLINE_MS: "20" }, }); const started = await expectGdbSessionStarted(manager); @@ -3264,34 +3216,40 @@ it("retains an exited GDB session when provider stop verification is incomplete" expect(manager.status(started.value.session_id)?.state).toBe("closed"); }); -it("preserves a command timeout when stopping the owned process rejects", async () => { - let stopCalls = 0; - const provider = await createProvider( - responsiveProvider(` +it.each([new Error("simulated stop failure"), "simulated stop failure"])( + "preserves a command timeout when stopping the owned process rejects: %s", + async (failure) => { + let stopCalls = 0; + const provider = await createProvider( + responsiveProvider(` if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n'); `), - ); - const manager = new GdbSessionManager({ - platform: "linux", - environment: { REA_GDB_COMMAND: provider }, - commandTimeoutMs: 25, - stopProcess: async (session, name) => { - stopCalls += 1; - if (stopCalls === 1) throw new Error("simulated stop failure"); - return stopOwnedDebuggerProcess(session, name); - }, - }); - managers.push(manager); - const started = await expectGdbSessionStarted(manager); + ); + const manager = new GdbSessionManager({ + platform: "linux", + environment: { + REA_GDB_COMMAND: provider, + REA_DEBUGGER_COMMAND_DEADLINE_MS: "25", + }, + stopProcess: async (session, name) => { + stopCalls += 1; + if (stopCalls === 1) throw failure; + return stopOwnedDebuggerProcess(session, name); + }, + }); + managers.push(manager); + const started = await expectGdbSessionStarted(manager); - const timedOut = await manager.execute(started.value.session_id, "stall"); + const timedOut = await manager.execute(started.value.session_id, "stall"); - expect(timedOut.ok).toBe(false); - if (!timedOut.ok) { - expect(timedOut.error._tag).toBe("AnalysisCapabilityUnavailableError"); - expect(timedOut.error.message).toContain("shutdown failed"); - } -}); + expect(timedOut.ok).toBe(false); + if (!timedOut.ok) { + expect(timedOut.error._tag).toBe("AnalysisCapabilityUnavailableError"); + expect(timedOut.error.message).toContain("shutdown failed"); + expect(timedOut.error.message).toContain("simulated stop failure"); + } + }, +); it("retains caller cancellation and cleanup authority after active-command stop rejects", async () => { const controller = new AbortController(); @@ -3383,7 +3341,7 @@ it("returns partial command evidence when the active MI request times out after else _child.stdout.write(`${token}^done\n`); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { - commandTimeoutMs: 20, + environment: { REA_DEBUGGER_COMMAND_DEADLINE_MS: "20" }, stopProcess: async () => ({ status: "stopped" }), }); const started = await expectGdbSessionStarted(manager); @@ -3987,7 +3945,7 @@ it("reports command timeout when an incomplete stop cannot confirm process exit" fake.children[0]?.stdout.write(`${_token}^done\n`); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { - commandTimeoutMs: 20, + environment: { REA_DEBUGGER_COMMAND_DEADLINE_MS: "20" }, stopProcess: async (session, name) => { stopCalls += 1; if (stopCalls === 1) diff --git a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts index efbbd86516..7709350d97 100644 --- a/tests/boundary/providers/GdbSessionReviewRegressions.test.ts +++ b/tests/boundary/providers/GdbSessionReviewRegressions.test.ts @@ -308,8 +308,10 @@ process.stdin.on("data", (chunk) => { ); await chmod(provider, 0o700); const manager = createGdbManagerForInjectedPlatform({ - environment: { REA_GDB_COMMAND: provider }, - maxSessions: 1, + environment: { + REA_GDB_COMMAND: provider, + REA_DEBUGGER_MAX_SESSIONS: "1", + }, }); managers.push(manager); @@ -369,8 +371,10 @@ process.stdin.on("data", (chunk) => { releaseSecondSpawn = resolve; }); const manager = createGdbManagerForInjectedPlatform({ - environment: { REA_GDB_COMMAND: provider }, - maxSessions: 2, + environment: { + REA_GDB_COMMAND: provider, + REA_DEBUGGER_MAX_SESSIONS: "2", + }, spawnProcess: async (options) => { spawnCalls += 1; if (spawnCalls === 2) { @@ -555,8 +559,10 @@ setTimeout(() => process.exit(0), 250).unref(); ); await chmod(provider, 0o700); const manager = createGdbManagerForInjectedPlatform({ - environment: { REA_GDB_COMMAND: provider }, - commandTimeoutMs: 40, + environment: { + REA_GDB_COMMAND: provider, + REA_DEBUGGER_COMMAND_DEADLINE_MS: "40", + }, }); managers.push(manager); const started = await manager.start(); @@ -612,8 +618,10 @@ setTimeout(() => process.exit(0), 250); ); await chmod(provider, 0o700); const manager = createGdbManagerForInjectedPlatform({ - environment: { REA_GDB_COMMAND: provider }, - commandTimeoutMs: 40, + environment: { + REA_GDB_COMMAND: provider, + REA_DEBUGGER_COMMAND_DEADLINE_MS: "40", + }, stopProcess: async () => ({ status: "incomplete", reason: "simulated incomplete stop", @@ -700,9 +708,14 @@ process.stdin.on("data", (chunk) => { controller.signal, ); await new Promise((resolveDelay) => setTimeout(resolveDelay, 20)); - controller.abort(); + const reason = { source: "cancelled while queued" }; + controller.abort(reason); const third = manager.execute(started.value.session_id, "third"); - expect((await second).ok).toBe(false); + const cancelled = await second; + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError", cause: reason }, + }); expect((await first).ok).toBe(true); expect((await third).ok).toBe(true); await expect(readFile(overlapPath, "utf8")).rejects.toThrow(); diff --git a/tests/boundary/providers/RizinCommandTimeout.test.ts b/tests/boundary/providers/RizinCommandTimeout.test.ts deleted file mode 100644 index 1e6d0d6921..0000000000 --- a/tests/boundary/providers/RizinCommandTimeout.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { isRizinCommandDeadlineExpired } from "../../../src/rizin/RizinDebugSessionManager.js"; - -describe("Rizin command timeout semantics", () => { - it("treats zero and negative remaining time as expired", () => { - expect(isRizinCommandDeadlineExpired(1)).toBe(false); - expect(isRizinCommandDeadlineExpired(0)).toBe(true); - expect(isRizinCommandDeadlineExpired(-1)).toBe(true); - }); -}); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts b/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts index f9fcd91f50..a24b38fb6d 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.fixtures.ts @@ -233,8 +233,12 @@ export const createRizinCoverageHarness = ( const start = async ( provider: string, options: RizinTestManagerOptions = {}, + environment: Readonly = {}, ): Promise<{ manager: RizinDebugSessionManager; sessionId: string }> => { - const manager = createManager({ REA_RIZIN_COMMAND: provider }, options); + const manager = createManager( + { REA_RIZIN_COMMAND: provider, ...environment }, + options, + ); const result = await manager.start({ path: "/tmp/coverage-target", inferior_tty: await rizinTestInferiorTty(), @@ -245,9 +249,10 @@ export const createRizinCoverageHarness = ( const startInjected = async ( spawnProcess: InjectedSpawnProcess, options: RizinTestManagerOptions = {}, + environment: Readonly = {}, ) => { const manager = createManager( - { REA_RIZIN_COMMAND: "injected-rizin" }, + { REA_RIZIN_COMMAND: "injected-rizin", ...environment }, { ...options, spawnProcess }, ); const started = await manager.start({ diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts index c38041e327..e739aff10e 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part2.test.ts @@ -19,9 +19,11 @@ it.skipIf(process.platform === "win32")( 'exec 0<&-; printf "\\000"; while :; do :; done', "#!/bin/sh", ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 500, - }); + const { manager, sessionId } = await start( + provider, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "500" }, + ); const result = await manager.execute(sessionId, "dr"); expect(result.ok).toBe(false); expect(manager.status(sessionId)?.state).toBe("closed"); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts index db2d2f9eac..1ac7bc939f 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts @@ -6,12 +6,6 @@ import { AnalysisInputError, } from "../../../src/domain/analysisErrorCore.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; -import { - applyDebuggerAdmissionOverride, - DEBUGGER_COMMAND_DEADLINE_SETTING, - resolveDebuggerSessionLimits, -} from "../../../src/config/debuggerSessionLimits.js"; -import { rizinCommandTimeoutLimitation } from "../../../src/rizin/RizinDebugSessionManager.js"; import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js"; import { createRizinCoverageHarness, @@ -26,32 +20,33 @@ it.skipIf(process.platform === "win32")( "times out when an active command produces no frame", async () => { const injected = createInjectedProcess(); - const { manager, started } = await startInjected(injected.spawnProcess, { - frameTimeoutMs: 10, - stopProcess: async () => ({ status: "stopped" }), - }); - if (!started.ok) throw started.error; - const deadline = applyDebuggerAdmissionOverride( - resolveDebuggerSessionLimits({}).commandDeadline, - 10, + const { manager, started } = await startInjected( + injected.spawnProcess, + { stopProcess: async () => ({ status: "stopped" }) }, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "10" }, ); - expect(deadline).toMatchObject({ - setting: DEBUGGER_COMMAND_DEADLINE_SETTING, - value: 10, - source: "runtime", - unit: "ms", - }); + if (!started.ok) throw started.error; const result = await manager.execute(started.value.session_id, "wait"); if (!result.ok) throw result.error; expect(result.value.result.completion_status).toBe("unknown"); expect(result.value.evidence.raw_result).toEqual(result.value.result); - expect(result.value.evidence.parameters.launch_provenance).toEqual({ - caller_selected_path: "/tmp/injected", - resolved_path: "/tmp/injected", + expect(result.value.evidence.normalized_result).toEqual( + result.value.result, + ); + expect(result.value.evidence.parameters).toMatchObject({ + session_id: started.value.session_id, + command: "wait", + inferior_tty: await rizinTestInferiorTty(), + launch_provenance: { + caller_selected_path: "/tmp/injected", + resolved_path: "/tmp/injected", + }, }); expect(result.value.evidence.limitations).toEqual([ expect.stringMatching(/target identity.*not established/iu), - rizinCommandTimeoutLimitation(deadline), + expect.stringMatching( + /timed out.*10 ms.*REA_DEBUGGER_COMMAND_DEADLINE_MS.*partial.*completion is unknown/iu, + ), expect.stringMatching(/session.*stopped.*output correlation/iu), expect.stringMatching(/unrestricted.*shell.*network.*mutate/iu), expect.stringMatching( @@ -72,10 +67,13 @@ it.skipIf(process.platform === "win32")( control.output("first frame\0second frame\0"); }, }); - const { manager, started } = await startInjected(injected.spawnProcess, { - frameTimeoutMs: 1000, - stopProcess: async () => ({ status: "stopped" }), - }); + const { manager, started } = await startInjected( + injected.spawnProcess, + { + stopProcess: async () => ({ status: "stopped" }), + }, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "1000" }, + ); expect(started.ok).toBe(true); if (!started.ok) return; @@ -90,16 +88,20 @@ it.skipIf(process.platform === "win32")( ); it.skipIf(process.platform === "win32")( - "preserves queued partial frames when the command deadline reaches zero", + "keeps completion unknown when a queued marker reaches an expired deadline", async () => { const injected = createInjectedProcess({ - onWrite: (_text, control) => { - control.output("first frame\0second frame\0third frame\0"); + onWrite: (text, control) => { + const marker = /!echo (.+)\n/u.exec(text)?.[1]; + if (marker !== undefined) + control.output(`first frame\0second frame\0${marker}\0`); }, }); - const { manager, started } = await startInjected(injected.spawnProcess, { - frameTimeoutMs: 1000, - }); + const { manager, started } = await startInjected( + injected.spawnProcess, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "1000" }, + ); expect(started.ok).toBe(true); if (!started.ok) return; @@ -118,11 +120,13 @@ it.skipIf(process.platform === "win32")( expect(result.ok).toBe(true); if (result.ok) { - // All three frames arrived before expiry; the third is still queued. - expect(result.value.result.output).toBe( - "first frame\nsecond frame\nthird frame", - ); + // The completion marker is still queued when the deadline expires. + expect(result.value.result.output).toBe("first frame\nsecond frame"); expect(result.value.result.completion_status).toBe("unknown"); + expect(result.value.result.output_truncated).toBe(true); + expect(result.value.evidence.normalized_result).toEqual( + result.value.result, + ); } }, ); @@ -135,10 +139,13 @@ it.skipIf(process.platform === "win32")( setImmediate(() => control.stdinError()); }, }); - const { manager, started } = await startInjected(injected.spawnProcess, { - frameTimeoutMs: 100, - stopProcess: async () => ({ status: "stopped" }), - }); + const { manager, started } = await startInjected( + injected.spawnProcess, + { + stopProcess: async () => ({ status: "stopped" }), + }, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "100" }, + ); expect(started.ok).toBe(true); if (!started.ok) return; @@ -223,13 +230,16 @@ it.skipIf(process.platform === "win32").each([false, true])( "maps a frame-wait listener installation failure without losing cleanup (incomplete=%s)", async (incomplete) => { const injected = createInjectedProcess(); - const { manager, started } = await startInjected(injected.spawnProcess, { - stopProcess: async () => - incomplete - ? { status: "incomplete", reason: "injected stop uncertainty" } - : { status: "stopped" }, - frameTimeoutMs: 1000, - }); + const { manager, started } = await startInjected( + injected.spawnProcess, + { + stopProcess: async () => + incomplete + ? { status: "incomplete", reason: "injected stop uncertainty" } + : { status: "stopped" }, + }, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "1000" }, + ); expect(started.ok).toBe(true); if (!started.ok) return; const controller = new AbortController(); @@ -407,21 +417,24 @@ it.skipIf(process.platform === "win32").each([false, true])( else throw new Error(`Unexpected cleanup command: ${text}`); }, }); - const { manager, started } = await startInjected(injected.spawnProcess, { - // The injected stream has no OS process identity. Exercise protocol detach - // here; real debugger process ownership is covered by process boundaries. - stopProcess: async (session) => { - if (session.exited) return { status: "already_exited" }; - try { - await session.detachInferior?.(); - injected.control.exit(); - return { status: "stopped" }; - } catch (cause) { - return { status: "incomplete", reason: String(cause) }; - } + const { manager, started } = await startInjected( + injected.spawnProcess, + { + // The injected stream has no OS process identity. Exercise protocol detach + // here; real debugger process ownership is covered by process boundaries. + stopProcess: async (session) => { + if (session.exited) return { status: "already_exited" }; + try { + await session.detachInferior?.(); + injected.control.exit(); + return { status: "stopped" }; + } catch (cause) { + return { status: "incomplete", reason: String(cause) }; + } + }, }, - maxCommandOutputBytes: 8, - }); + { REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: "8" }, + ); if (!started.ok) throw started.error; const result = await manager.execute(started.value.session_id, "noisy"); expect(result.ok).toBe(!retry); @@ -470,8 +483,9 @@ it.skipIf(process.platform === "win32")( if (!result.ok) { expect(result.error).toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", - reason: - "Unsolicited Rizin frames arrived before a command; command output can no longer be safely correlated.", + reason: expect.stringMatching( + /unsolicited Rizin frames.*before a command.*no longer.*safely correlated/i, + ), }); expect(result.error.cleanupIncomplete).toBe(false); } @@ -510,19 +524,24 @@ it.skipIf(process.platform === "win32").each([ } }, }); - const { manager, started } = await startInjected(injected.spawnProcess, { - maxCommandOutputBytes: stage === "output" ? 8 : 128, - frameTimeoutMs: 10, - stopProcess: async () => { - if (stopping) { - enterStop(); - await released; - } - return incomplete && stopping - ? { status: "incomplete", reason: "stop settlement pending" } - : { status: "stopped" }; + const { manager, started } = await startInjected( + injected.spawnProcess, + { + stopProcess: async () => { + if (stopping) { + enterStop(); + await released; + } + return incomplete && stopping + ? { status: "incomplete", reason: "stop settlement pending" } + : { status: "stopped" }; + }, }, - }); + { + REA_DEBUGGER_COMMAND_DEADLINE_MS: "10", + REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: stage === "output" ? "8" : "128", + }, + ); if (!started.ok) throw started.error; const pending = manager.execute( started.value.session_id, @@ -576,18 +595,21 @@ it.skipIf(process.platform === "win32")( else control.output(`${marker}\0`); }, }); - const { manager, started } = await startInjected(injected.spawnProcess, { - maxCommandOutputBytes: 8, - stopProcess: async (session) => { - try { - await session.detachInferior?.(); - injected.control.exit(); - return { status: "stopped" }; - } catch (cause) { - return { status: "incomplete", reason: String(cause) }; - } + const { manager, started } = await startInjected( + injected.spawnProcess, + { + stopProcess: async (session) => { + try { + await session.detachInferior?.(); + injected.control.exit(); + return { status: "stopped" }; + } catch (cause) { + return { status: "incomplete", reason: String(cause) }; + } + }, }, - }); + { REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: "8" }, + ); if (!started.ok) throw started.error; const executed = await manager.execute(started.value.session_id, "ok"); if (!executed.ok) throw executed.error; diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts index 7d4301d9b8..d66cbbfd49 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts @@ -32,9 +32,11 @@ it.skipIf(process.platform === "win32")( }); }, }); - const { manager, started } = await startInjected(injected.spawnProcess, { - frameTimeoutMs: 1000, - }); + const { manager, started } = await startInjected( + injected.spawnProcess, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "1000" }, + ); expect(started.ok).toBe(true); if (!started.ok) return; @@ -64,10 +66,13 @@ it.skipIf(process.platform === "win32")( }); }, }); - const { manager, started } = await startInjected(injected.spawnProcess, { - frameTimeoutMs: 1000, - stopProcess: async () => ({ status: "stopped" }), - }); + const { manager, started } = await startInjected( + injected.spawnProcess, + { + stopProcess: async () => ({ status: "stopped" }), + }, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "1000" }, + ); expect(started.ok).toBe(true); if (!started.ok) return; const result = await manager.execute(started.value.session_id, "large"); @@ -256,10 +261,13 @@ it.skipIf(process.platform === "win32")( const provider = await makeProvider( 'process.stdout.write("\\0"); process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { if (chunk.includes("slow")) process.stdout.write("partial\\0"); });', ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 25, - stopProcess: incompleteStop, - }); + const { manager, sessionId } = await start( + provider, + { + stopProcess: incompleteStop, + }, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "25" }, + ); const result = await manager.execute(sessionId, "slow"); expect(result.ok).toBe(false); if (!result.ok) { @@ -294,10 +302,13 @@ process.stdin.on("data", (chunk) => { } });`, ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 1000, - stopProcess: incompleteStop, - }); + const { manager, sessionId } = await start( + provider, + { + stopProcess: incompleteStop, + }, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "1000" }, + ); const result = await manager.execute(sessionId, "many"); expect(result.ok).toBe(false); if (!result.ok) { @@ -372,9 +383,11 @@ process.stdin.on("data", (chunk) => { } });`, ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 5_000, - }); + const { manager, sessionId } = await start( + provider, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "5000" }, + ); const first = manager.execute(sessionId, "first"); await expect .poll(() => manager.status(sessionId)?.recent_output) diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts index e8d19ba72c..b7b085f274 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts @@ -22,9 +22,11 @@ process.stdin.on("data", (chunk) => { } });`, ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 1000, - }); + const { manager, sessionId } = await start( + provider, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "1000" }, + ); const result = await manager.execute(sessionId, "many"); expect(result.ok).toBe(true); if (result.ok) { @@ -39,26 +41,6 @@ process.stdin.on("data", (chunk) => { }, ); -it.skipIf(process.platform === "win32")( - "returns partial evidence when the command frame deadline is already expired", - async () => { - const provider = await makeProvider( - 'process.stdout.write("\\0"); process.stdin.resume();', - ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 0, - }); - const result = await manager.execute(sessionId, "dr"); - expect(result.ok).toBe(true); - if (result.ok) - expect(result.value.result).toMatchObject({ - output: "", - output_truncated: true, - completion_status: "unknown", - }); - }, -); - it.skipIf(process.platform === "win32")( "does not exceed the exact command byte cap when marker output follows a full frame", async () => { @@ -77,9 +59,11 @@ process.stdin.on("data", (chunk) => { } });`, ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 3000, - }); + const { manager, sessionId } = await start( + provider, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "3000" }, + ); const result = await manager.execute(sessionId, "full"); expect(result.ok).toBe(true); if (result.ok) { @@ -118,9 +102,11 @@ process.stdin.on("data", (chunk) => { } });`, ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 3000, - }); + const { manager, sessionId } = await start( + provider, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "3000" }, + ); const result = await manager.execute(sessionId, "aggregate"); expect(result.ok).toBe(true); if (result.ok) { @@ -152,9 +138,11 @@ process.stdin.on("data", (chunk) => { } });`, ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 2000, - }); + const { manager, sessionId } = await start( + provider, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "2000" }, + ); const result = await manager.execute(sessionId, "large"); expect(result.ok).toBe(true); if (result.ok) { @@ -188,9 +176,11 @@ process.stdin.on("data", (chunk) => { } });`, ); - const { manager, sessionId } = await start(provider, { - frameTimeoutMs: 2000, - }); + const { manager, sessionId } = await start( + provider, + {}, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "2000" }, + ); const result = await manager.execute(sessionId, "large"); expect(result.ok).toBe(true); if (result.ok) diff --git a/tests/boundary/providers/RizinDebugSessionInferiorStdin.test.ts b/tests/boundary/providers/RizinDebugSessionInferiorStdin.test.ts index cd2a37a76c..63b700e43e 100644 --- a/tests/boundary/providers/RizinDebugSessionInferiorStdin.test.ts +++ b/tests/boundary/providers/RizinDebugSessionInferiorStdin.test.ts @@ -235,9 +235,10 @@ const createManager = ( options: NonNullable< ConstructorParameters[1] > = {}, + environment: Readonly = {}, ): RizinDebugSessionManager => { const manager = new RizinDebugSessionManager( - { ...process.env, REA_RIZIN_COMMAND: launcher }, + { PATH: process.env.PATH, ...environment, REA_RIZIN_COMMAND: launcher }, options, ); onTestFinished(() => manager.closeAll().catch(() => undefined)); @@ -310,11 +311,14 @@ it.skipIf(process.platform === "win32")( target: "cat", reportsInferior: false, }); - const manager = createManager(current, { - // The launcher reports no inferior, so close needs no Linux /proc readback. - platform: "linux", - frameTimeoutMs: 3_000, - }); + const manager = createManager( + current, + { + // The launcher reports no inferior, so close needs no Linux /proc readback. + platform: "linux", + }, + { REA_DEBUGGER_COMMAND_DEADLINE_MS: "3000" }, + ); const sessionId = await startSession(manager, current); successValue(await manager.execute(sessionId, "resume-target")); diff --git a/tests/boundary/providers/RizinDebugSessionManagers.test.ts b/tests/boundary/providers/RizinDebugSessionManagers.test.ts index b485b2df6f..61e31ee8c7 100644 --- a/tests/boundary/providers/RizinDebugSessionManagers.test.ts +++ b/tests/boundary/providers/RizinDebugSessionManagers.test.ts @@ -153,9 +153,11 @@ process.stdin.on("data", (chunk) => { await chmod(provider, 0o700); let stopRejected = false; const manager = createRizinTestManager( - { REA_RIZIN_COMMAND: provider }, { - frameTimeoutMs: 50, + REA_RIZIN_COMMAND: provider, + REA_DEBUGGER_COMMAND_DEADLINE_MS: "50", + }, + { stopProcess: (session, name, options) => { if (stopOutcome !== "stopped" && !stopRejected) { stopRejected = true; diff --git a/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts b/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts index ae93aff599..2362e31da5 100644 --- a/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts +++ b/tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts @@ -609,10 +609,10 @@ process.stdin.on("data", () => process.stdout.write(Buffer.from([0xff, 0]))); `, ); await chmod(provider, 0o700); - const manager = createRizinTestManager( - { REA_RIZIN_COMMAND: provider }, - { frameTimeoutMs: 40 }, - ); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + REA_DEBUGGER_COMMAND_DEADLINE_MS: "40", + }); managers.push(manager); const started = await manager.start({ path: "/tmp/fixture.bin", @@ -669,10 +669,10 @@ process.stdin.on("data", (chunk) => { `, ); await chmod(provider, 0o700); - const manager = createRizinTestManager( - { REA_RIZIN_COMMAND: provider }, - { frameTimeoutMs: 5_000 }, - ); + const manager = createRizinTestManager({ + REA_RIZIN_COMMAND: provider, + REA_DEBUGGER_COMMAND_DEADLINE_MS: "5000", + }); managers.push(manager); const callerSelectedPath = "./cancelled/../fixture.bin"; const resolvedPath = absoluteCallerPath(callerSelectedPath); diff --git a/tests/boundary/providers/RizinInferiorTty.test.ts b/tests/boundary/providers/RizinInferiorTty.test.ts index 9fa965d9ce..35603c119a 100644 --- a/tests/boundary/providers/RizinInferiorTty.test.ts +++ b/tests/boundary/providers/RizinInferiorTty.test.ts @@ -15,7 +15,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { assertRizinProcessStartTimeStable, RizinDebugSessionManager, - truncateUtf8, } from "../../../src/rizin/RizinDebugSessionManager.js"; import { evidenceSchema } from "../../../src/domain/evidence.js"; import { TOOL_EFFECTS } from "../../../src/contracts/toolEffects.js"; @@ -318,25 +317,6 @@ it("rejects changed or missing Rizin inferior identity during detach", () => { ); }); -it("truncates UTF-8 strings by byte limit preserving multi-byte code point boundaries", () => { - expect(truncateUtf8("", 10)).toBe(""); - expect(truncateUtf8("hello", 0)).toBe(""); - expect(truncateUtf8("hello", -1)).toBe(""); - expect(truncateUtf8("hello", 3)).toBe("hel"); - expect(truncateUtf8("hello", 10)).toBe("hello"); - - // Multibyte 3-byte CJK character (0xe6 0x96 0x87) - // 5 bytes allows only 1 character (3 bytes); the second character would need 6 bytes - expect(truncateUtf8("中文", 5)).toBe("中"); - expect(truncateUtf8("中文", 6)).toBe("中文"); - - // 4-byte emoji (🚀 = 0xf0 0x9f 0x9a 0x80) - expect(truncateUtf8("🚀🚀", 3)).toBe(""); - expect(truncateUtf8("🚀🚀", 4)).toBe("🚀"); - expect(truncateUtf8("🚀🚀", 7)).toBe("🚀"); - expect(truncateUtf8("🚀🚀", 8)).toBe("🚀🚀"); -}); - it.skipIf(process.platform !== "linux")( "retains ownership when cleanup cannot read Rizin stdin", async () => { @@ -422,8 +402,10 @@ it.skipIf(process.platform !== "linux")( expect(evidence.normalized_result).toMatchObject({ output_truncated: true, }); - expect(evidence.limitations.join(" ")).toContain( - "The owned debugger session remains retained because cleanup could not be verified", + expect(evidence.limitations).toContainEqual( + expect.stringMatching( + /debugger session.*retained.*cleanup could not be verified/iu, + ), ); expect(manager!.status(sessionId)?.state).toBe("closed"); }, @@ -585,7 +567,17 @@ process.stdin.on("data", (chunk) => { await chmod(provider, 0o700); let providerStderr = ""; manager = new RizinDebugSessionManager( - { ...process.env, REA_RIZIN_COMMAND: provider }, + { + PATH: process.env.PATH, + ...(options.maxCommandOutputBytes === undefined + ? {} + : { + REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: String( + options.maxCommandOutputBytes, + ), + }), + REA_RIZIN_COMMAND: provider, + }, { spawnProcess: async (spawnOptions) => { const launched = await spawnOwnedProviderProcess(spawnOptions); @@ -611,9 +603,6 @@ process.stdin.on("data", (chunk) => { }); return launched; }, - ...(options.maxCommandOutputBytes === undefined - ? {} - : { maxCommandOutputBytes: options.maxCommandOutputBytes }), }, ); const started = await manager.start({ diff --git a/tests/boundary/providers/RizinTimeoutEvidence.test.ts b/tests/boundary/providers/RizinTimeoutEvidence.test.ts deleted file mode 100644 index 4e100b294e..0000000000 --- a/tests/boundary/providers/RizinTimeoutEvidence.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expect, it } from "vitest"; - -import { - DEBUGGER_COMMAND_DEADLINE_SETTING, - resolveDebuggerSessionLimits, -} from "../../../src/config/debuggerSessionLimits.js"; -import { DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS } from "../../../src/domain/analysisResourceLimits.js"; -import { - createRizinCommandTimeoutEvidence, - rizinCommandTimeoutLimitation, -} from "../../../src/rizin/RizinDebugSessionManager.js"; - -it("preserves every field of partial Rizin timeout Evidence", () => { - const result = { - command: "dr", - output: "partial register output", - output_scope: "command_and_interleaved_session_output" as const, - output_truncated: true, - completion_status: "unknown" as const, - backend: null, - }; - - const evidence = createRizinCommandTimeoutEvidence({ - sessionId: "session-123", - command: "dr", - result, - inferiorTty: "/dev/pts/7", - cleanupFailure: undefined, - launchProvenance: { - caller_selected_path: "./sample/../program", - resolved_path: "/workspace/program", - }, - }); - - expect(evidence.parameters).toEqual({ - session_id: "session-123", - command: "dr", - inferior_tty: "/dev/pts/7", - launch_provenance: { - caller_selected_path: "./sample/../program", - resolved_path: "/workspace/program", - }, - }); - expect(evidence.raw_result).toEqual(result); - expect(evidence.normalized_result).toEqual(result); - const deadline = resolveDebuggerSessionLimits({}).commandDeadline; - expect(deadline).toMatchObject({ - setting: DEBUGGER_COMMAND_DEADLINE_SETTING, - value: DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS, - source: "default", - unit: "ms", - }); - expect(evidence.limitations).toEqual([ - expect.stringMatching(/target identity.*not established/iu), - rizinCommandTimeoutLimitation(deadline), - expect.stringMatching(/session.*stopped.*output correlation/iu), - expect.stringMatching(/unrestricted.*shell.*network.*mutate/iu), - expect.stringMatching(/incomplete completion.marker prefix.*diagnostics/iu), - expect.stringMatching( - /launch provenance.*does not establish.*current target/iu, - ), - ]); -}); diff --git a/tests/boundary/providers/fixedAllowanceRecovery.test.ts b/tests/boundary/providers/fixedAllowanceRecovery.test.ts index 43d631c02a..642ecbf380 100644 --- a/tests/boundary/providers/fixedAllowanceRecovery.test.ts +++ b/tests/boundary/providers/fixedAllowanceRecovery.test.ts @@ -5,10 +5,7 @@ import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErr import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { redactExplicitFailure } from "../../../src/domain/explicitSensitiveFailure.js"; -import { - fixedAllowanceOptions, - fixedAllowanceRemediation, -} from "../../../src/domain/fixedAllowanceRemediation.js"; +import { fixedAllowanceOptions } from "../../../src/domain/fixedAllowanceRemediation.js"; import { analysisClientFailureForOperation } from "../../../src/providers/AnalysisClientFailure.js"; const refusal = () => @@ -46,12 +43,15 @@ it.each([ remediationAction: undefined, retryable: false, }); - expect(projectAnalysisError(copied)).toMatchObject({ + const projected = projectAnalysisError(copied); + expect(projected).toMatchObject({ code: "resource_constraint", retryable: false, - remediation: { action: fixedAllowanceRemediation(allowance, "parser") }, + remediation: { action: expect.stringContaining(allowance) }, details: { reported_limits: { maximum_decoded_bytes: 64 } }, }); + expect(projected.remediation.action).toMatch(/host memory do not raise/iu); + expect(projected.remediation.action).toMatch(/unchanged request.*fails/iu); }, ); diff --git a/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts index fd5eb1ef8e..269140cbb1 100644 --- a/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts +++ b/tests/boundary/providers/hopper/hopperBridgeFacade.test.ts @@ -166,8 +166,9 @@ describe("Hopper API facade", () => { classification: null, body: { available: false, - reason: - "Hopper's public Python API does not expose complete function body ranges", + reason: expect.stringMatching( + /Hopper.*Python API.*does not expose complete function body ranges/i, + ), }, }, native_api: null, diff --git a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts index ffea9aa070..9a7db4881f 100644 --- a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts +++ b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts @@ -119,19 +119,17 @@ it("says why Kuna cannot answer instead of a generic unavailable message", async operation: "procedure_assembly", parameters: { procedure: "0x2000" }, message: - "Kuna's export lists no code at 0x2000; import slots and data entries have no listing.", + /no code at 0x2000.*import slots and data entries.*no listing/i, }, { operation: "procedure_callers", parameters: { procedure: "0x1004" }, - message: - "Kuna's graph has no function node at 0x1004; its relationships are unknown.", + message: /no function node at 0x1004.*relationships are unknown/i, }, { operation: "list_segments", parameters: {}, - message: - "Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.", + message: /operation needs another provider/i, }, ] as const; for (const { operation, parameters, message } of cases) { @@ -139,7 +137,7 @@ it("says why Kuna cannot answer instead of a generic unavailable message", async if (result.ok) throw new Error(`${operation} unexpectedly succeeded`); expect(projectAnalysisError(result.error)).toMatchObject({ code: "capability_unavailable", - message, + message: expect.stringMatching(message), retryable: false, }); } diff --git a/tests/boundary/providers/kuna/kunaInventoryPlacement.test.ts b/tests/boundary/providers/kuna/kunaInventoryPlacement.test.ts index bd03e088fd..446ae60562 100644 --- a/tests/boundary/providers/kuna/kunaInventoryPlacement.test.ts +++ b/tests/boundary/providers/kuna/kunaInventoryPlacement.test.ts @@ -128,7 +128,8 @@ it("refuses overlapping sections and load commands above the limit as unclassifi await readKunaMachoLayout({ bytes: 32 * 1024 * 1024, prefix: head }), ).toEqual({ status: "unavailable", - reason: - "its load commands occupy 16777224 bytes, above the 16777216-byte load-command limit", + reason: expect.stringMatching( + /load commands.*16777224 bytes.*16777216.byte.*limit/i, + ), }); }); diff --git a/tests/boundary/providers/kuna/kunaMachoInventory.test.ts b/tests/boundary/providers/kuna/kunaMachoInventory.test.ts index 0b673115d3..731e09fd68 100644 --- a/tests/boundary/providers/kuna/kunaMachoInventory.test.ts +++ b/tests/boundary/providers/kuna/kunaMachoInventory.test.ts @@ -2,12 +2,6 @@ import { expect, it as test } from "vitest"; import { functionDossierSchema } from "../../../../src/domain/native/functionDossier.js"; import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; -import { - ambiguousKunaNameMessage, - KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION, - KUNA_XREF_MISSING_NAME_REMEDIATION, - missingKunaXrefNameMessage, -} from "../../../../src/kuna/KunaSelectorGuidance.js"; import { fakeKunaCalls } from "./fakeKuna.js"; import { MACHO_FUNCTIONS, @@ -212,31 +206,32 @@ it("reports missing and ambiguous xref names within Kuna's inventory", async () const missing = await client.execute("xrefs", { address: "missing_data" }); expect(missing.ok).toBe(false); if (missing.ok) throw new Error("Expected missing inventory name"); - expect(projectAnalysisError(missing.error)).toMatchObject({ + const missingProjection = projectAnalysisError(missing.error); + expect(missingProjection).toMatchObject({ code: "invalid_request", - message: missingKunaXrefNameMessage("missing_data"), - remediation: { action: KUNA_XREF_MISSING_NAME_REMEDIATION }, + message: expect.stringMatching(/no inventory entry.*missing_data/iu), + remediation: { + action: expect.stringMatching( + /symbols or exports.*xrefs.*address.*does not establish absence/iu, + ), + }, }); - expect(projectAnalysisError(missing.error).recovery).toBeUndefined(); - expect(projectAnalysisError(missing.error).message).toContain( - "missing_data", - ); + expect(missingProjection.recovery).toBeUndefined(); const ambiguous = await client.execute("xrefs", { address: "puts" }); expect(ambiguous.ok).toBe(false); if (ambiguous.ok) throw new Error("Expected ambiguous inventory name"); - expect(projectAnalysisError(ambiguous.error)).toMatchObject({ + const ambiguousProjection = projectAnalysisError(ambiguous.error); + expect(ambiguousProjection).toMatchObject({ code: "invalid_request", - message: ambiguousKunaNameMessage("puts", ["0x100000480", "0x100004000"]), - remediation: { action: KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION }, + message: expect.stringMatching(/puts.*2 Kuna entries/iu), + remediation: { + action: expect.stringMatching(/select.*addresses.*xrefs/iu), + }, }); - expect(projectAnalysisError(ambiguous.error).message).toContain( - "0x100000480", - ); - expect(projectAnalysisError(ambiguous.error).message).toContain( - "0x100004000", - ); - expect(projectAnalysisError(ambiguous.error).recovery).toBeUndefined(); + expect(ambiguousProjection.message).toContain("0x100000480"); + expect(ambiguousProjection.message).toContain("0x100004000"); + expect(ambiguousProjection.recovery).toBeUndefined(); } finally { expect(await client.close()).toEqual({ ok: true, value: null }); } diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 1666b12088..597f9c25d6 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -441,8 +441,10 @@ it("reports a failed function and an import slot as unavailable text, not errors procedure: "broken", }); expect(broken.result).toBeNull(); - expect(broken.limitations).toContain( - "Kuna could not decompile this function: Flow exceeded maximum instructions", + expect(broken.limitations).toContainEqual( + expect.stringMatching( + /could not decompile.*Flow exceeded maximum instructions/i, + ), ); const dossier = await run(client, "analyze_function", { procedure: "broken", @@ -451,8 +453,9 @@ it("reports a failed function and an import slot as unavailable text, not errors facet_availability: { pseudocode: { available: false, - reason: - "Kuna could not decompile this function: Flow exceeded maximum instructions", + reason: expect.stringMatching( + /could not decompile.*Flow exceeded maximum instructions/i, + ), }, }, }); diff --git a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts index 580631b18a..9c324f733c 100644 --- a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts +++ b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts @@ -367,11 +367,13 @@ it("names the setting that owns a single-function deadline", async () => { if (result.ok) throw new Error("the hanging run answered"); expect(projectAnalysisError(result.error)).toMatchObject({ code: "provider_timeout", - message: - "kuna decompile did not finish within REA_KUNA_TIMEOUT_MS (300 ms).", + message: expect.stringMatching( + /kuna decompile.*REA_KUNA_TIMEOUT_MS.*300 ms/i, + ), remediation: { - action: - "Raise REA_KUNA_TIMEOUT_MS in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.", + action: expect.stringMatching( + /raise.*REA_KUNA_TIMEOUT_MS.*restart REA/i, + ), }, details: { timeout_ms: 300 }, }); diff --git a/tests/boundary/providers/kuna/kunaStrings.test.ts b/tests/boundary/providers/kuna/kunaStrings.test.ts index 6454fc27df..fbd514ebaa 100644 --- a/tests/boundary/providers/kuna/kunaStrings.test.ts +++ b/tests/boundary/providers/kuna/kunaStrings.test.ts @@ -432,11 +432,13 @@ it.each([ expect(result.error).toMatchObject({ timeoutMs: 500 }); // Each waiter keeps the producer's setting and recovery. expect(projectAnalysisError(result.error)).toMatchObject({ - message: - "kuna strings did not finish within REA_KUNA_PROJECT_TIMEOUT_MS (500 ms).", + message: expect.stringMatching( + /kuna strings.*REA_KUNA_PROJECT_TIMEOUT_MS.*500 ms/i, + ), remediation: { - action: - "Raise REA_KUNA_PROJECT_TIMEOUT_MS in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.", + action: expect.stringMatching( + /raise.*REA_KUNA_PROJECT_TIMEOUT_MS.*restart REA/i, + ), }, }); } diff --git a/tests/boundary/providers/managed/managedMemberComparison.test.ts b/tests/boundary/providers/managed/managedMemberComparison.test.ts index e055a3ff73..490527b0e2 100644 --- a/tests/boundary/providers/managed/managedMemberComparison.test.ts +++ b/tests/boundary/providers/managed/managedMemberComparison.test.ts @@ -142,6 +142,18 @@ it("keeps missing members unknown when the opposite inspection retains only type source.coverage.issues.filter(({ code }) => code === "resource-limit"), ); expect(report.limitations).toEqual(comparisonLimitations(...sources)); + const limitedSide = leftPath === limitedPath ? "left" : "right"; + expect(report.limitations).toContainEqual( + expect.stringMatching( + new RegExp( + `${limitedSide}.*metadata\\.representation.*do not establish absence`, + "i", + ), + ), + ); + expect(report.limitations).toContainEqual( + expect.stringMatching(/smaller selection.*type_names.*--type/i), + ); } }); diff --git a/tests/boundary/providers/managed/managedStaticProvider.test.ts b/tests/boundary/providers/managed/managedStaticProvider.test.ts index 81eecd7970..eb2aa8234a 100644 --- a/tests/boundary/providers/managed/managedStaticProvider.test.ts +++ b/tests/boundary/providers/managed/managedStaticProvider.test.ts @@ -683,8 +683,9 @@ it("preserves custom attribute value evidence through the filesystem provider wh code: "invalid-row", scope: "metadata.CustomAttribute:0x0c000001", offset: attribute.row_offset + 2, - detail: - "CustomAttribute constructor coded index 0x13 is invalid: coded index selects row 2 in table 10, which has 1 rows", + detail: expect.stringMatching( + /CustomAttribute.*0x13.*row 2.*table 10.*1 rows/i, + ), }), ]); } finally { diff --git a/tests/boundary/providers/native/nativeCallObservation.test.ts b/tests/boundary/providers/native/nativeCallObservation.test.ts index aa17d7fdeb..50194f501d 100644 --- a/tests/boundary/providers/native/nativeCallObservation.test.ts +++ b/tests/boundary/providers/native/nativeCallObservation.test.ts @@ -399,15 +399,25 @@ describe("observe_native_calls projection: reports partial observation coverage" }); expect(result.process.terminated).toBe(false); expect(result.limitations.join(" ")).not.toContain("was killed"); - expect(result.limitations).toContain( - "Target output draining did not complete; reported byte counts are observed lower bounds, not stream totals.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /output draining did not complete.*byte counts.*lower bounds.*not stream totals/iu, + ), ); expect(result.limitations).toEqual( expect.arrayContaining([ - "Observation stopped at max_events (1); later calls were not recorded and termination was requested but could not be confirmed.", - "Breakpoints 1 matched no code in any image loaded while observing; their calls could not be observed.", - "Target output beyond 1048576 bytes (1 MiB) per stream is counted but not kept. This release has no runtime override.", - "REA could not confirm that process 4242 exited; check for it before relying on host state.", + expect.stringMatching( + /max_events \(1\).*later calls.*not recorded.*termination.*could not be confirmed/iu, + ), + expect.stringMatching( + /Breakpoints 1.*matched no code.*calls could not be observed/iu, + ), + expect.stringMatching( + /1048576 bytes.*per stream.*counted but not kept.*no runtime override/iu, + ), + expect.stringMatching( + /could not confirm.*process 4242 exited.*check for it/iu, + ), ]), ); }); @@ -482,8 +492,10 @@ describe("observe_native_calls projection: preserves unknown launch identity", ( module_uuid: null, stable: false, }); - expect(result.limitations).toContain( - "LLDB did not provide enough module identity evidence to confirm pathname and module continuity around launch.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /not provide enough module identity evidence.*pathname and module continuity.*launch/iu, + ), ); }); }); diff --git a/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts b/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts index 0fe9ecb241..cbf648cd81 100644 --- a/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts +++ b/tests/boundary/providers/native/nativeMachoUuidlessInspection.test.ts @@ -275,8 +275,9 @@ it("does not call a target UUID-less when binary contains LC_UUID", async () => ok: false, error: { _tag: "AnalysisOutputError", - reason: - "dyld_info reported a missing LC_UUID despite an observed LC_UUID load command", + reason: expect.stringMatching( + /dyld_info.*missing LC_UUID.*observed LC_UUID/i, + ), capturedOutput: { stdout: dyldDiagnosticOutput(target.path, missingUuidDiagnostic), exit_code: 1, diff --git a/tests/boundary/sqlite/sqliteDatabaseBusy.test.ts b/tests/boundary/sqlite/sqliteDatabaseBusy.test.ts index 963ee9c831..c4779ca5e2 100644 --- a/tests/boundary/sqlite/sqliteDatabaseBusy.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseBusy.test.ts @@ -42,19 +42,23 @@ const createProvider = (parent: string, roots: string[]) => const expectRetryableBusy = ( error: Parameters[0], path: string, -) => - expect(projectAnalysisError(error)).toMatchObject({ +) => { + const projected = projectAnalysisError(error); + expect(projected).toMatchObject({ code: "capability_unavailable", category: "unavailable", retryable: true, - message: - "The selected SQLite database is held open by another process without read sharing. Close that application or inspect a copy, then retry.", + message: expect.stringMatching( + /held open.*without read sharing.*close.*copy.*retry/i, + ), details: { provider_id: "sqlite", operation: "inspect_sqlite_database", - reason: `Selected database file set is in use by another process (EBUSY): ${path}`, + reason: expect.stringContaining(path), }, }); + expect(projected.details?.reason).toEqual(expect.stringContaining("EBUSY")); +}; const expectRootsRemoved = async (roots: string[]) => { expect(roots).toHaveLength(1); diff --git a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts index 44ce4ca8fa..44bdaa1b80 100644 --- a/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseInspection.test.ts @@ -321,8 +321,7 @@ it("keeps unrelated catalog names outside the selected-table reply budget", asyn ).toThrowError( expect.objectContaining({ reason: "selection", - message: - "Selected ordinary table does not exist with that exact name: absent", + message: expect.stringMatching(/ordinary table.*exact name.*absent/i), }), ); for (const schema_scope of ["inventory", "database"] as const) diff --git a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts index 74770b0126..b3141a6219 100644 --- a/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts +++ b/tests/boundary/sqlite/sqliteDatabaseProvider.test.ts @@ -816,8 +816,9 @@ it("attributes table and column selection failures to their own arguments", asyn { path: ["columns"], reason: "invalid_format", - message: - "Selected column does not exist with that exact name in table records: nope", + message: expect.stringMatching( + /column.*exact name.*table records.*nope/i, + ), }, ], }, diff --git a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts index a6b30a5d07..69e7bb9d64 100644 --- a/tests/composition/analysis-sessions/managedApplicationGraph.test.ts +++ b/tests/composition/analysis-sessions/managedApplicationGraph.test.ts @@ -136,8 +136,10 @@ describe("managed application graph MethodDef scope", () => { ); expect(result.coverage.status).toBe("partial"); expect(result.summary.methods).toBe(1); - expect(result.limitations).toContain( - "Managed member Evidence did not record its MethodDef row scope; coverage of methods and their relations outside the supplied observations is unknown.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /MethodDef row scope.*methods.*relations.*unknown/iu, + ), ); }); }); @@ -345,8 +347,10 @@ describe("managed application graph identity facts", () => { }, }); expect(result.evidence_links).toEqual([source.evidence_id]); - expect(result.limitations).toContain( - "Managed artifact Evidence was not supplied; assembly identity observations are absent.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /artifact Evidence.*not supplied.*assembly identity.*absent/iu, + ), ); }, ); @@ -719,8 +723,10 @@ describe("unknown managed application graph token scope", () => { const graph = parseJavaScriptApplicationGraph(result.graph); expect(result.summary.modules).toBe(1); expect(result.coverage.status).toBe("partial"); - expect(result.limitations).toContain( - "Managed boundary tokens were not linked to member nodes because matching module MVID scope was not established.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /boundary tokens.*not linked.*member nodes.*MVID.*not established/iu, + ), ); const moduleObservation = graph.nodes.find( ({ kind }) => kind === "managed-module", @@ -831,11 +837,13 @@ describe("managed application graph type selection", () => { expect(selected.coverage.state).toBe("complete"); const result = project(selected); expect(result.coverage).toMatchObject({ status: "partial" }); - expect(result.limitations).toContain( - "Managed member Evidence selected only these declaring types: Fixture.Program; type, method, and field nodes of other types are omitted, not absent.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /Fixture\.Program.*type, method, and field.*omitted, not absent/iu, + ), ); - expect(result.limitations).not.toContain( - "At least one supplied managed Evidence record has partial coverage.", + expect(result.limitations).not.toContainEqual( + expect.stringMatching(/managed Evidence.*partial coverage/iu), ); }); @@ -960,8 +968,10 @@ describe("managed call and field-access relations", () => { omitted_count: 7, limits: [], }); - expect(result.limitations).toContain( - "Calls edges link only method nodes in the inspected module; these call sites are omitted: 2 member-reference, 2 function-pointer (ldftn/ldvirtftn), 3 recursive self-call.", + expect(result.limitations).toContainEqual( + expect.stringMatching( + /inspected module.*omitted: 2 member-reference, 2 function-pointer.*3 recursive self-call/iu, + ), ); }); diff --git a/tests/composition/objdumpSelectedDisassembly.test.ts b/tests/composition/objdumpSelectedDisassembly.test.ts index c0d81e0861..1a4742cf8a 100644 --- a/tests/composition/objdumpSelectedDisassembly.test.ts +++ b/tests/composition/objdumpSelectedDisassembly.test.ts @@ -63,8 +63,10 @@ it.each([ artifact_changed: false, completion_status: "complete", }); - expect(result.value.limitations).toContain( - "Disassembly is scoped to the caller-selected symbol using the provider's matching and symbol-boundary semantics; an empty match does not establish artifact-wide absence.", + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + /caller-selected symbol.*provider.*symbol-boundary.*empty match.*not establish artifact-wide absence/iu, + ), ); } finally { await service.close(); diff --git a/tests/composition/reverseEngineering.test.ts b/tests/composition/reverseEngineering.test.ts index 101fc3d3e5..2b6f7f5cb1 100644 --- a/tests/composition/reverseEngineering.test.ts +++ b/tests/composition/reverseEngineering.test.ts @@ -349,8 +349,10 @@ it("marks signal-terminated provider commands as unknown completion", async () = expect(ordinaryExit.value.limitations.join(" ")).not.toContain( "terminated by signal", ); - expect(ordinaryExit.value.limitations).toContain( - "Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.", + expect(ordinaryExit.value.limitations).toContainEqual( + expect.stringMatching( + /files.*network.*outside the selected artifact.*not bound to Evidence/iu, + ), ); expect(ordinaryExit.value.limitations).toHaveLength(4); } @@ -419,8 +421,10 @@ describe("Rizin command service", () => { expect(result.value.normalized_result).toMatchObject({ stdout: "\u001b[31mpartial data\u001b[0m\n", }); - expect(result.value.limitations).toContain( - "Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.", + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + /files.*network.*outside the selected artifact.*not bound to Evidence/iu, + ), ); expect(result.value.limitations).toHaveLength(4); expect(result.value.parameters).toMatchObject({ @@ -486,8 +490,10 @@ describe("Rizin command service", () => { .digest("hex"), artifact_changed: true, }); - expect(result.value.limitations).toContain( - "The provider command may have changed the artifact while analysis was running; pre-run and post-run digests are both reported.", + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + /may have changed the artifact.*pre-run and post-run digests/iu, + ), ); } finally { await rm(directory, { recursive: true, force: true }); diff --git a/tests/composition/reverseEngineeringEdgeCases.test.ts b/tests/composition/reverseEngineeringEdgeCases.test.ts index 401c9fc0c7..425d7cf559 100644 --- a/tests/composition/reverseEngineeringEdgeCases.test.ts +++ b/tests/composition/reverseEngineeringEdgeCases.test.ts @@ -1084,8 +1084,10 @@ it("records an explicit loss marker when provider bytes are invalid UTF-8", asyn expect(result.value).toMatchObject({ raw_result: { stdout: "captured � output" }, }); - expect(result.value.limitations).toContain( - "Captured stdout bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.", + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + /stdout bytes.*invalid or incomplete UTF-8.*replacement characters.*do not preserve the original bytes/iu, + ), ); } } finally { @@ -1136,8 +1138,13 @@ it.each([ const result = await service.executeRizinCommand({ path, command: "iI" }); expect(result.ok).toBe(true); if (result.ok) - expect(result.value.limitations).toContain( - `Captured ${streams} bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.`, + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + new RegExp( + `${streams} bytes.*invalid or incomplete UTF-8.*replacement characters.*do not preserve the original bytes`, + "iu", + ), + ), ); } finally { await rm(directory, { recursive: true, force: true }); @@ -1163,8 +1170,10 @@ it.each(["objdump", "rizin"] as const)( output_truncated: true, completion_status: "complete", }); - expect(result.value.limitations).toContain( - "Captured provider output is partial because bytes were omitted; process completion is reported separately.", + expect(result.value.limitations).toContainEqual( + expect.stringMatching( + /output is partial.*bytes were omitted.*process completion.*separately/iu, + ), ); } finally { await service.close(); diff --git a/tests/conformance/providers/managed/managedNativeVerification.test.ts b/tests/conformance/providers/managed/managedNativeVerification.test.ts index 357ff8fdea..adfead0942 100644 --- a/tests/conformance/providers/managed/managedNativeVerification.test.ts +++ b/tests/conformance/providers/managed/managedNativeVerification.test.ts @@ -594,8 +594,10 @@ it("keeps empty identities unknown and compares suffix-only identities as data", matched_native: { name: "open_native" }, candidates: [{ name: "open_native" }], }); - expect(result.pinvoke_imports[0]?.limitations).toContain( - "A native name candidate exists, but a required module identity is unavailable; module correspondence is unknown.", + expect(result.pinvoke_imports[0]?.limitations).toContainEqual( + expect.stringMatching( + /module identity.*unavailable.*correspondence.*unknown/iu, + ), ); } diff --git a/tests/fixtures/mcpDoctorReply.mjs b/tests/fixtures/mcpDoctorReply.mjs index 14dad1240e..88b2905cd1 100644 --- a/tests/fixtures/mcpDoctorReply.mjs +++ b/tests/fixtures/mcpDoctorReply.mjs @@ -3,6 +3,14 @@ import { createInterface } from "node:readline"; const snapshot = JSON.parse(await readFile(process.argv[2], "utf8")); const reply = structuredClone(snapshot.reply); +if (process.argv[3] === "inventory") { + const removed = snapshot.tools.tools.findIndex( + (tool) => tool.name !== "binary_session", + ); + const [template] = snapshot.tools.tools.splice(removed, 1); + const unexpected = { ...template, name: "doctor_unexpected_fixture" }; + snapshot.tools.tools.push(unexpected, unexpected); +} if (process.argv[3] === "invalid") { reply.structuredContent.schema_violation_control = true; reply.content = reply.content.map((item) => diff --git a/src/application/ownerObservationExpectation.fixture.ts b/tests/fixtures/ownerObservationExpectation.ts similarity index 89% rename from src/application/ownerObservationExpectation.fixture.ts rename to tests/fixtures/ownerObservationExpectation.ts index 609edb0f48..a141e10b64 100644 --- a/src/application/ownerObservationExpectation.fixture.ts +++ b/tests/fixtures/ownerObservationExpectation.ts @@ -1,11 +1,11 @@ import { expect } from "vitest"; -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import type { AnalysisError } from "../../src/domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisOutputError, -} from "../domain/analysisErrorCore.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import type { Result } from "../domain/result.js"; +} from "../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import type { Result } from "../../src/domain/result.js"; /** Assert the service returned the same owner error and its cause. */ export const expectOwnerError = ( diff --git a/tests/process-global/firmwareInheritedFileSizeCeiling.test.ts b/tests/process-global/firmwareInheritedFileSizeCeiling.test.ts index 502575605d..e346c6596a 100644 --- a/tests/process-global/firmwareInheritedFileSizeCeiling.test.ts +++ b/tests/process-global/firmwareInheritedFileSizeCeiling.test.ts @@ -2,7 +2,7 @@ import { execFile } from "node:child_process"; import { access, readFile } from "node:fs/promises"; import { promisify } from "node:util"; import { expect, it, onTestFinished } from "vitest"; -import { firmwareFileSizeLimitReached } from "../../src/firmware/FirmwareFiles.js"; +import { AnalysisResourceConstraintError } from "../../src/domain/analysisErrorCore.js"; import { FIRMWARE_LIMITS } from "../../src/firmware/FirmwareRelease.js"; import { firmwareFileSizeService } from "../fixtures/firmware/fileSizeProducer.js"; import { assertFirmwareCleanup } from "../fixtures/firmware/provider.js"; @@ -44,13 +44,19 @@ it.skipIf(process.platform !== "linux")( resource: "file-size", reportedLimits: { file_size_bytes: budget }, retryable: false, - // The inventory refusal, not the producer's SIGXFSZ recovery, owns this. - remediationAction: firmwareFileSizeLimitReached( - "extract_firmware", - budget, - ).remediationAction, }, }); + if (outcome.ok) throw new Error("Expected a file-size refusal"); + if (!(outcome.error instanceof AnalysisResourceConstraintError)) + throw outcome.error; + // Inventory identifies the inherited ceiling and its recovery independently + // of the producer's SIGXFSZ guidance. + const recovery = outcome.error.remediationAction; + expect(recovery).toContain(String(budget)); + expect(recovery).toMatch(/inherited.*file-size limit.*RLIMIT_FSIZE/iu); + expect(recovery).toMatch(/raise the file-size limit.*ulimit -f/iu); + expect(recovery).toMatch(/analysis_timeout_ms does not raise it/iu); + expect(recovery).toMatch(/unchanged request fails/iu); await expect(access(fixture.output)).rejects.toMatchObject({ code: "ENOENT", }); diff --git a/tests/process-global/macHopperReleaseMinimum.test.ts b/tests/process-global/macHopperReleaseMinimum.test.ts index 8cb5d7538a..064705a593 100644 --- a/tests/process-global/macHopperReleaseMinimum.test.ts +++ b/tests/process-global/macHopperReleaseMinimum.test.ts @@ -2,7 +2,6 @@ import { afterEach, expect, it, vi } from "vitest"; import { createDoctorHostFixture } from "../../src/application/Doctor.fixture.js"; import { HOPPER_RELEASES_URL } from "../../src/application/HopperReleaseFeed.js"; -import { macHopperHostVersionDetail } from "../../src/application/MacHopper.js"; import { setupInstallFailure } from "../../src/application/SetupInstallFailure.js"; import { runSetup } from "../../src/application/Setup.js"; import { systemSetupHost } from "../../src/application/SetupHost.js"; @@ -83,14 +82,8 @@ it.each(["12.0", "12.3"])( expect(result.remediation).toContain( setupInstallFailure("incompatible_host_version").remediation, ); - expect(result.remediation).toContain( - macHopperHostVersionDetail({ - source: "release_feed", - packageUrl: PACKAGE_URL, - minimumVersion: "12.4", - hostVersion: version, - }), - ); + expect(result.remediation).toMatch(/deployment_target/i); + expect(result.remediation).toMatch(/not downloaded/i); for (const fact of [PACKAGE_URL, "12.4", version, "HOPPER_LAUNCHER_PATH"]) expect(result.remediation).toContain(fact); expect(requests).toEqual([HOPPER_RELEASES_URL]); diff --git a/tsconfig.reverse-tools-mutation.json b/tsconfig.reverse-tools-mutation.json index 9942cb5fc7..eb0cbaf8aa 100644 --- a/tsconfig.reverse-tools-mutation.json +++ b/tsconfig.reverse-tools-mutation.json @@ -15,12 +15,10 @@ "src/rizin/RizinCommand.ts", "src/rizin/RizinDebugSessionManager.ts", "tests/boundary/providers/GdbSessionManagerCoverage.test.ts", - "tests/boundary/providers/RizinTimeoutEvidence.test.ts", "tests/boundary/mcp/rizinPendingOutput.test.ts", "tests/boundary/providers/RizinInferiorTty.test.ts", "tests/boundary/providers/RizinDecoderInputs.test.ts", "tests/boundary/providers/RizinTestInferiorTty.ts", - "tests/boundary/providers/RizinCommandTimeout.test.ts", "tests/boundary/providers/CutterBridgeClient.test.ts", "tests/boundary/providers/CutterBridgeNullIdentity.test.ts", "tests/boundary/providers/CutterBridgeService.test.ts", diff --git a/vitest.reverse-tools-semantics.config.ts b/vitest.reverse-tools-semantics.config.ts index 698deb309c..1bf76489da 100644 --- a/vitest.reverse-tools-semantics.config.ts +++ b/vitest.reverse-tools-semantics.config.ts @@ -4,8 +4,7 @@ export default defineConfig({ test: { include: [ "tests/boundary/providers/CutterBridgeClient.test.ts", - "tests/boundary/providers/RizinCommandTimeout.test.ts", - "tests/boundary/providers/RizinTimeoutEvidence.test.ts", + "tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts", "tests/boundary/mcp/rizinPendingOutput.test.ts", ], pool: "forks", diff --git a/vitest.reverse-tools.config.ts b/vitest.reverse-tools.config.ts index 3fab3790d0..756b8b510a 100644 --- a/vitest.reverse-tools.config.ts +++ b/vitest.reverse-tools.config.ts @@ -34,8 +34,6 @@ export default defineConfig({ "tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts", "tests/boundary/providers/RizinDebugSessionCoverage.part4.test.ts", "tests/boundary/providers/RizinDebugSessionCoverage.part5.test.ts", - "tests/boundary/providers/RizinTimeoutEvidence.test.ts", - "tests/boundary/providers/RizinCommandTimeout.test.ts", "tests/boundary/providers/CutterBridgeClient.test.ts", "tests/boundary/providers/CutterBridgeCapacity.test.ts", "tests/boundary/providers/cutterBridgeProducer.test.ts",