import { performance } from "node:perf_hooks"; import { type AdapterOperation, adapterOperations, type NegotiatedAdapter, } from "../protocol/types.js"; import type { PsbtTransitionFailure } from "../psbt/invariants.js"; import { redactSensitiveText } from "../runner/report.js"; import type { AdapterCapabilityRequirement, MissingCapability, ScenarioAdapterCell, ScenarioAssertionEvidence, ScenarioDefinition, ScenarioExecutionOutput, ScenarioFinding, ScenarioResult, } from "./definition.js"; const SAFE_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; const ADAPTER_OPERATIONS = new Set(adapterOperations); export class ScenarioAssertionError extends Error { override readonly name = "ScenarioAssertionError"; constructor( message: string, readonly assertions: readonly ScenarioAssertionEvidence[], ) { super(message); } } function appendMissing( output: MissingCapability[], adapter: string, kind: MissingCapability["kind"], required: readonly T[] | undefined, declared: readonly T[], ): void { for (const value of required ?? []) { if (!declared.includes(value)) { output.push({ adapter, kind, value }); } } } function missingForRequirement( requirement: AdapterCapabilityRequirement, adapters: ReadonlyMap, ): MissingCapability[] { const negotiated = adapters.get(requirement.adapter); if (!negotiated) { return [{ adapter: requirement.adapter, kind: "adapter", value: requirement.adapter }]; } const negotiatedRegistryId = negotiated.registryId ?? negotiated.implementation.name; if (negotiatedRegistryId !== requirement.adapter) { return [ { adapter: requirement.adapter, kind: "identity", value: negotiated.implementation.name, }, ]; } const missing: MissingCapability[] = []; const capabilities = negotiated.capabilities; appendMissing( missing, requirement.adapter, "operation", requirement.operations, capabilities.operations, ); appendMissing(missing, requirement.adapter, "role", requirement.roles, capabilities.roles); appendMissing( missing, requirement.adapter, "psbtVersion", requirement.psbtVersions, capabilities.psbtVersions, ); appendMissing( missing, requirement.adapter, "scriptType", requirement.scriptTypes, capabilities.scriptTypes, ); for (const operation of requirement.operations ?? []) { if ( operation === "hello" || operation === "native-parse" || !capabilities.operations.includes(operation) ) continue; for (const scriptType of requirement.scriptTypes ?? []) { if (!capabilities.scriptTypes.includes(scriptType)) continue; if (!(capabilities.operationScriptTypes?.[operation]?.includes(scriptType) ?? false)) { missing.push({ adapter: requirement.adapter, kind: "operationScriptType", value: `${operation}:${scriptType}`, }); } } } appendMissing( missing, requirement.adapter, "feature", requirement.features, capabilities.features ?? [], ); return missing; } export function findMissingCapabilities( requirements: readonly AdapterCapabilityRequirement[], adapters: ReadonlyMap, ): MissingCapability[] { return requirements.flatMap((requirement) => missingForRequirement(requirement, adapters)); } function elapsedMilliseconds(startedAt: number): number { return Math.max(0, Math.round((performance.now() - startedAt) * 1000) / 1000); } interface AdapterCellCapture { takeAdapterCells(): readonly ScenarioAdapterCell[]; } function adapterCellCapture(value: unknown): AdapterCellCapture | undefined { if (typeof value !== "object" || value === null || !("takeAdapterCells" in value)) { return undefined; } const candidate = value as { takeAdapterCells?: unknown }; return typeof candidate.takeAdapterCells === "function" ? (candidate as AdapterCellCapture) : undefined; } function takeAdapterCells(value: unknown): readonly ScenarioAdapterCell[] { return adapterCellCapture(value)?.takeAdapterCells() ?? []; } function copyFailure(failure: PsbtTransitionFailure): PsbtTransitionFailure { const location = failure.location.kind === "global" ? { kind: "global" as const } : { kind: failure.location.kind, index: failure.location.index }; return { code: failure.code, location, keyType: failure.keyType, completeKeySha256: failure.completeKeySha256, keyBytes: failure.keyBytes, ...(failure.field ? { field: { scope: failure.field.scope, keyType: failure.field.keyType, keyTypeHex: failure.field.keyTypeHex, symbol: failure.field.symbol, displayName: failure.field.displayName, ...(failure.field.bip ? { bip: failure.field.bip } : {}), kind: failure.field.kind, }, } : {}), ...(failure.guidance ? { guidance: { code: failure.guidance.code, severity: failure.guidance.severity, summary: redactSensitiveText(failure.guidance.summary), nextSteps: failure.guidance.nextSteps.map((step) => redactSensitiveText(step)), }, } : {}), ...(failure.before ? { before: { valueSha256: failure.before.valueSha256, valueBytes: failure.before.valueBytes, }, } : {}), ...(failure.after ? { after: { valueSha256: failure.after.valueSha256, valueBytes: failure.after.valueBytes, }, } : {}), }; } function copyAssertion(assertion: ScenarioAssertionEvidence): ScenarioAssertionEvidence { return { name: assertion.name, passed: assertion.passed, ...(assertion.policy !== undefined ? { policy: assertion.policy } : {}), ...(assertion.exactBytesEqual !== undefined ? { exactBytesEqual: assertion.exactBytesEqual } : {}), ...(assertion.failures !== undefined ? { failures: assertion.failures.map((failure) => copyFailure(failure)) } : {}), ...(assertion.likelyImplementation !== undefined ? { likelyImplementation: redactSensitiveText(assertion.likelyImplementation) } : {}), ...(assertion.summary !== undefined ? { summary: redactSensitiveText(assertion.summary) } : {}), }; } function copyAssertions( assertions: readonly ScenarioAssertionEvidence[], ): ScenarioAssertionEvidence[] { return assertions.map((assertion) => copyAssertion(assertion)); } function copyFindings(findings: readonly ScenarioFinding[] | undefined): ScenarioFinding[] { return (findings ?? []).map((finding) => ({ id: finding.id, ruleId: finding.ruleId, implementation: redactSensitiveText(finding.implementation), summary: redactSensitiveText(finding.summary), actual: redactSensitiveText(finding.actual), ...(finding.evidence !== undefined ? { evidence: finding.evidence.map((value) => redactSensitiveText(value)) } : {}), })); } function copyAdapterCells( cells: readonly ScenarioAdapterCell[] | undefined, ): ScenarioAdapterCell[] { return (cells ?? []).map((cell) => ({ adapter: redactSensitiveText(cell.adapter), operation: cell.operation, requestId: redactSensitiveText(cell.requestId), status: cell.status, detail: redactSensitiveText(cell.detail), durationMs: cell.durationMs, ...(cell.errorClass !== undefined ? { errorClass: redactSensitiveText(cell.errorClass) } : {}), ...(cell.restarted !== undefined ? { restarted: cell.restarted } : {}), })); } function isAdapterOperation(value: unknown): value is AdapterOperation { return typeof value === "string" && ADAPTER_OPERATIONS.has(value); } function operationForMissingCapability( missing: MissingCapability, requirements: readonly AdapterCapabilityRequirement[], ): AdapterOperation { if (missing.kind === "operation" && isAdapterOperation(missing.value)) { return missing.value; } if (missing.kind === "operationScriptType" && typeof missing.value === "string") { const [operation] = missing.value.split(":"); if (isAdapterOperation(operation)) return operation; } return ( requirements.find(({ adapter }) => adapter === missing.adapter)?.operations?.[0] ?? "hello" ); } function unsupportedAdapterCells( missingCapabilities: readonly MissingCapability[], requirements: readonly AdapterCapabilityRequirement[], ): ScenarioAdapterCell[] { return missingCapabilities.map((missing, index) => ({ adapter: missing.adapter, operation: operationForMissingCapability(missing, requirements), requestId: `unsupported-${index + 1}`, status: "unsupported", detail: `Missing ${missing.kind} capability ${missing.value}`, durationMs: 0, errorClass: `capability.${missing.kind}.missing`, })); } function assertValidCatalog(catalog: readonly ScenarioDefinition[]): void { const identifiers = new Set(); for (const [index, definition] of catalog.entries()) { if (!SAFE_IDENTIFIER.test(definition.id)) { throw new TypeError( `Scenario identifier at catalog index ${index} must be a safe identifier`, ); } if (identifiers.has(definition.id)) { throw new TypeError(`Duplicate scenario identifier: ${definition.id}`); } identifiers.add(definition.id); } } function completedResult( definition: ScenarioDefinition, output: ScenarioExecutionOutput, startedAt: number, capturedAdapterCells: readonly ScenarioAdapterCell[] = [], ): ScenarioResult { const assertions = copyAssertions(output.assertions); const adapterCells = copyAdapterCells([...capturedAdapterCells, ...(output.adapterCells ?? [])]); const passed = assertions.length > 0 && assertions.every((assertion) => assertion.passed && (assertion.failures?.length ?? 0) === 0); return { id: definition.id, title: definition.title, category: definition.category, outcome: passed ? "passed" : "failed", summary: redactSensitiveText(output.summary ?? definition.summary), durationMs: elapsedMilliseconds(startedAt), assertions, ...(adapterCells.length ? { adapterCells } : {}), ...(output.findings?.length ? { findings: copyFindings(output.findings) } : {}), ...(output.expectedFailure ? { expectedFailure: { implementation: output.expectedFailure.implementation, errorClass: output.expectedFailure.errorClass, }, } : {}), ...(output.policyAccepted !== undefined ? { policyAccepted: output.policyAccepted } : {}), ...(output.transactionId ? { transactionId: output.transactionId } : {}), }; } function infrastructureFailureResult( definition: ScenarioDefinition, error: unknown, startedAt: number, capturedAdapterCells: readonly ScenarioAdapterCell[] = [], ): ScenarioResult { const errorClass = error instanceof Error ? redactSensitiveText(error.name || error.constructor.name || "Error") : redactSensitiveText(typeof error); const message = error instanceof Error ? redactSensitiveText(error.message) : redactSensitiveText(String(error)); return { id: definition.id, title: definition.title, category: definition.category, outcome: "failed", summary: redactSensitiveText(`${definition.id} failed before producing scenario assertions.`), durationMs: elapsedMilliseconds(startedAt), ...(capturedAdapterCells.length ? { adapterCells: copyAdapterCells(capturedAdapterCells) } : {}), assertions: [ { name: "scenario-executed", passed: false, likelyImplementation: "scenario-runtime", summary: `${errorClass}: ${message}`, }, ], infrastructureError: { errorClass, message, }, }; } export async function runScenarioCatalog( catalog: readonly ScenarioDefinition[], context: Context, adapters: ReadonlyMap, ): Promise { assertValidCatalog(catalog); const results: ScenarioResult[] = []; for (const definition of catalog) { const startedAt = performance.now(); takeAdapterCells(context); const missingCapabilities = findMissingCapabilities(definition.requirements, adapters); if (missingCapabilities.length > 0) { const adapterCells = unsupportedAdapterCells(missingCapabilities, definition.requirements); results.push({ id: definition.id, title: definition.title, category: definition.category, outcome: "unsupported", summary: redactSensitiveText( `${definition.title} is unsupported by the negotiated adapter capabilities.`, ), durationMs: elapsedMilliseconds(startedAt), assertions: [], adapterCells: copyAdapterCells(adapterCells), missingCapabilities, }); continue; } const requestedSkipReason = await definition.skip?.(context); if (requestedSkipReason !== undefined) { const skipReason = redactSensitiveText(requestedSkipReason); const capturedAdapterCells = takeAdapterCells(context); results.push({ id: definition.id, title: definition.title, category: definition.category, outcome: "skipped", summary: redactSensitiveText(`${definition.title} was skipped: ${skipReason}.`), durationMs: elapsedMilliseconds(startedAt), assertions: [], skipReason, ...(capturedAdapterCells.length ? { adapterCells: copyAdapterCells(capturedAdapterCells) } : {}), }); continue; } try { const output = await definition.run(context); results.push(completedResult(definition, output, startedAt, takeAdapterCells(context))); } catch (error) { const capturedAdapterCells = takeAdapterCells(context); results.push( error instanceof ScenarioAssertionError ? { id: definition.id, title: definition.title, category: definition.category, outcome: "failed", summary: redactSensitiveText(error.message), durationMs: elapsedMilliseconds(startedAt), assertions: copyAssertions(error.assertions), ...(capturedAdapterCells.length ? { adapterCells: copyAdapterCells(capturedAdapterCells) } : {}), } : infrastructureFailureResult(definition, error, startedAt, capturedAdapterCells), ); } } return results; }