From 4bec966bca8c93ef2f4a92984e79ba515721e2a4 Mon Sep 17 00:00:00 2001 From: Mohammad Wahbeh Date: Sun, 20 Sep 2026 14:20:02 +0300 Subject: [PATCH 01/15] feat: the synchronous net-http transport and the conformance gem (phase 8a) dexpace-transport-net_http: Dexpace::Transport::NetHTTP with .build over a fresh-per-call Net::HTTP and .using over a caller's own, the Adapter, the per-response ResponsePump with its head-adaptation handshake, RequestMapper and ResponseMapper, Deadline, Failures, TLSSettings, ProxyRoute, the require-time registration under :net_http, and the gemspec's net-http >= 0.4. dexpace-conformance: the assertion protocol phase 0 postponed (Failure, Vacuous, Assertion, Result, Report), the twenty-eight-assertion TransportSuite, TransportCase with its SettleOnly guard, BorrowedPair, the WireServer fixture and its Scripts, MinitestDriver, the opt-in RSpecDriver, and the RecordingSpan and Allocations doubles. dexpace-core: Dexpace::TransportError < ::IOError, Configuration::Keys::REQUEST_TIMEOUT and Instrumentation::Events::TRANSPORT_HEADER_DROPPED, with the pins the two constants moved. Repository: tempfile on the require allowlist with its fixture, BUNDLE_PATH scoped in the clean-bundle gate, socket and tempfile on the conformance Steep target, the surface manifests regenerated once, the net-http Timeout-thread warm-up both adapter gems' test helpers require, and the bare-require helper core's repaired seam-surface test needs. --- Steepfile | 3 + .../lib/dexpace/conformance.rb | 32 ++- .../lib/dexpace/conformance/allocations.rb | 89 +++++++ .../lib/dexpace/conformance/assertion.rb | 57 +++++ .../lib/dexpace/conformance/borrowed_pair.rb | 47 ++++ .../lib/dexpace/conformance/failure.rb | 32 +++ .../dexpace/conformance/minitest_driver.rb | 87 +++++++ .../lib/dexpace/conformance/recording_span.rb | 94 +++++++ .../lib/dexpace/conformance/report.rb | 96 +++++++ .../lib/dexpace/conformance/result.rb | 47 ++++ .../lib/dexpace/conformance/rspec_driver.rb | 62 +++++ .../lib/dexpace/conformance/scripts.rb | 234 +++++++++++++++++ .../lib/dexpace/conformance/transport_case.rb | 178 +++++++++++++ .../dexpace/conformance/transport_suite.rb | 104 ++++++++ .../conformance/transport_suite/checks.rb | 222 ++++++++++++++++ .../conformance/transport_suite/inbound.rb | 109 ++++++++ .../conformance/transport_suite/lifecycle.rb | 182 ++++++++++++++ .../conformance/transport_suite/outbound.rb | 184 ++++++++++++++ .../conformance/transport_suite/resilience.rb | 187 ++++++++++++++ .../conformance/transport_suite/streaming.rb | 106 ++++++++ .../lib/dexpace/conformance/vacuous.rb | 23 ++ .../lib/dexpace/conformance/wire_server.rb | 179 +++++++++++++ .../wire_server/recorded_request.rb | 60 +++++ .../conformance/wire_server/request_reader.rb | 76 ++++++ .../sig/dexpace/conformance/allocations.rbs | 16 ++ .../sig/dexpace/conformance/assertion.rbs | 20 ++ .../sig/dexpace/conformance/borrowed_pair.rbs | 18 ++ .../sig/dexpace/conformance/failure.rbs | 13 + .../dexpace/conformance/minitest_driver.rbs | 15 ++ .../dexpace/conformance/recording_span.rbs | 23 ++ .../sig/dexpace/conformance/report.rbs | 29 +++ .../sig/dexpace/conformance/result.rbs | 19 ++ .../sig/dexpace/conformance/rspec_driver.rbs | 13 + .../sig/dexpace/conformance/scripts.rbs | 34 +++ .../dexpace/conformance/transport_case.rbs | 61 +++++ .../dexpace/conformance/transport_suite.rbs | 21 ++ .../conformance/transport_suite/checks.rbs | 33 +++ .../conformance/transport_suite/inbound.rbs | 20 ++ .../conformance/transport_suite/lifecycle.rbs | 28 +++ .../conformance/transport_suite/outbound.rbs | 28 +++ .../transport_suite/resilience.rbs | 22 ++ .../conformance/transport_suite/streaming.rbs | 22 ++ .../sig/dexpace/conformance/vacuous.rbs | 10 + .../sig/dexpace/conformance/wire_server.rbs | 41 +++ .../wire_server/recorded_request.rbs | 22 ++ .../wire_server/request_reader.rbs | 19 ++ .../test/dexpace/conformance_test.rb | 36 ++- gems/dexpace-conformance/test/test_helper.rb | 3 + gems/dexpace-core/lib/dexpace.rb | 8 + .../lib/dexpace/configuration/keys.rb | 11 +- .../lib/dexpace/error/transport_error.rb | 49 ++++ .../lib/dexpace/instrumentation/keys.rb | 8 +- .../sig/dexpace/configuration/keys.rbs | 1 + .../sig/dexpace/error/transport_error.rbs | 12 + .../sig/dexpace/instrumentation/keys.rbs | 3 +- .../test/dexpace/configuration/keys_test.rb | 9 +- .../downstream_wirings_test.rb | 3 +- .../test/dexpace/instrumentation/keys_test.rb | 5 +- .../test/dexpace/transport_test.rb | 28 +-- gems/dexpace-core/test/dexpace_test.rb | 4 + .../dexpace-core/test/support/bare_require.rb | 29 +++ .../dexpace-transport-net_http.gemspec | 15 +- .../lib/dexpace/transport/net_http.rb | 145 ++++++++++- .../lib/dexpace/transport/net_http/adapter.rb | 236 ++++++++++++++++++ .../dexpace/transport/net_http/deadline.rb | 64 +++++ .../dexpace/transport/net_http/failures.rb | 59 +++++ .../dexpace/transport/net_http/proxy_route.rb | 72 ++++++ .../transport/net_http/request_mapper.rb | 137 ++++++++++ .../transport/net_http/response_mapper.rb | 121 +++++++++ .../transport/net_http/response_pump.rb | 223 +++++++++++++++++ .../transport/net_http/tls_settings.rb | 58 +++++ .../sig/dexpace/transport/net_http.rbs | 15 ++ .../dexpace/transport/net_http/adapter.rbs | 52 ++++ .../dexpace/transport/net_http/deadline.rbs | 26 ++ .../dexpace/transport/net_http/failures.rbs | 17 ++ .../transport/net_http/proxy_route.rbs | 25 ++ .../transport/net_http/request_mapper.rbs | 26 ++ .../transport/net_http/response_mapper.rbs | 25 ++ .../transport/net_http/response_pump.rbs | 44 ++++ .../transport/net_http/tls_settings.rbs | 15 ++ .../test/dexpace/transport/net_http_test.rb | 61 ++++- .../test/test_helper.rb | 3 + tasks/gates.rake | 7 +- .../gates/require_allowlist/allowed.rb | 4 +- test/fixtures/surface/dexpace-conformance.txt | 98 ++++++++ test/fixtures/surface/dexpace-core.txt | 5 + .../surface/dexpace-transport-net_http.txt | 15 ++ test/support/net_http_warmup.rb | 32 +++ tools/require_allowlist.rb | 8 + 89 files changed, 4788 insertions(+), 46 deletions(-) create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/allocations.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/assertion.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/borrowed_pair.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/failure.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/minitest_driver.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/report.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/result.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/rspec_driver.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/scripts.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/transport_case.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/transport_suite.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/checks.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/inbound.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/lifecycle.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/outbound.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/resilience.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/transport_suite/streaming.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/vacuous.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/wire_server.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/wire_server/recorded_request.rb create mode 100644 gems/dexpace-conformance/lib/dexpace/conformance/wire_server/request_reader.rb create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/allocations.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/assertion.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/borrowed_pair.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/failure.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/minitest_driver.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/recording_span.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/report.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/result.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/rspec_driver.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/scripts.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/transport_case.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/transport_suite.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/transport_suite/checks.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/transport_suite/inbound.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/transport_suite/lifecycle.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/transport_suite/outbound.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/transport_suite/resilience.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/transport_suite/streaming.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/vacuous.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/wire_server.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/wire_server/recorded_request.rbs create mode 100644 gems/dexpace-conformance/sig/dexpace/conformance/wire_server/request_reader.rbs create mode 100644 gems/dexpace-core/lib/dexpace/error/transport_error.rb create mode 100644 gems/dexpace-core/sig/dexpace/error/transport_error.rbs create mode 100644 gems/dexpace-core/test/support/bare_require.rb create mode 100644 gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/adapter.rb create mode 100644 gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/deadline.rb create mode 100644 gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/failures.rb create mode 100644 gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/proxy_route.rb create mode 100644 gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/request_mapper.rb create mode 100644 gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/response_mapper.rb create mode 100644 gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/response_pump.rb create mode 100644 gems/dexpace-transport-net_http/lib/dexpace/transport/net_http/tls_settings.rb create mode 100644 gems/dexpace-transport-net_http/sig/dexpace/transport/net_http/adapter.rbs create mode 100644 gems/dexpace-transport-net_http/sig/dexpace/transport/net_http/deadline.rbs create mode 100644 gems/dexpace-transport-net_http/sig/dexpace/transport/net_http/failures.rbs create mode 100644 gems/dexpace-transport-net_http/sig/dexpace/transport/net_http/proxy_route.rbs create mode 100644 gems/dexpace-transport-net_http/sig/dexpace/transport/net_http/request_mapper.rbs create mode 100644 gems/dexpace-transport-net_http/sig/dexpace/transport/net_http/response_mapper.rbs create mode 100644 gems/dexpace-transport-net_http/sig/dexpace/transport/net_http/response_pump.rbs create mode 100644 gems/dexpace-transport-net_http/sig/dexpace/transport/net_http/tls_settings.rbs create mode 100644 test/support/net_http_warmup.rb diff --git a/Steepfile b/Steepfile index 7fd878e..219c3b0 100644 --- a/Steepfile +++ b/Steepfile @@ -60,5 +60,8 @@ end target :conformance do check "gems/dexpace-conformance/lib" signature "gems/dexpace-conformance/sig", "gems/dexpace-core/sig" + # rbs's own stdlib signature sets for the two features this gem's lib/ requires beyond core's + # allowlist: `socket` for the wire fixture (P8-14) and `tempfile` for TRANSPORT-28's file body. + library "socket", "tempfile" configure_code_diagnostics(D::Ruby.default) end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance.rb b/gems/dexpace-conformance/lib/dexpace/conformance.rb index 2ef86f7..e493639 100644 --- a/gems/dexpace-conformance/lib/dexpace/conformance.rb +++ b/gems/dexpace-conformance/lib/dexpace/conformance.rb @@ -1,12 +1,38 @@ # frozen_string_literal: true # SPDX-License-Identifier: MIT +# The whole of dexpace-core: this gem declares it and reaches every Dexpace:: constant its +# assertions name through the one entry point, exactly as a consumer of the suite would. +require "dexpace" require_relative "conformance/version" +# The assertion protocol's five value types (design §9.3, 8a's R7), in dependency-free order. +require_relative "conformance/failure" +require_relative "conformance/vacuous" +require_relative "conformance/assertion" +require_relative "conformance/result" +require_relative "conformance/report" +# The TCPServer fixture and its named scripts (design §9.3; `socket` is permitted to this gem +# alone by the require allowlist's scoped denial, P8-14). +require_relative "conformance/scripts" +require_relative "conformance/wire_server" +# The case an assertion receives, the borrowed pair, and the runner (8a's R16 suite contract). +require_relative "conformance/borrowed_pair" +require_relative "conformance/transport_case" +require_relative "conformance/transport_suite" +# The Minitest driver, and deliberately NOT the RSpec one: an RSpec-only consumer requires +# "dexpace/conformance/rspec_driver" itself, so a Minitest-only consumer never loads a file naming +# the other framework (design §9.3). Neither requires its framework. +require_relative "conformance/minitest_driver" +# The two observability doubles phases 5c and 5b assigned to this gem (OBS-21, OBS-25). +require_relative "conformance/recording_span" +require_relative "conformance/allocations" module Dexpace - # The conformance suite every adapter is proven against. Phase 0 ships the namespace and - # VERSION only; the assertion objects and their Minitest and RSpec drivers land in phase 8a, - # and phase 9 adds the remaining suites. + # The conformance suite every adapter is proven against (design §9.3): the assertion protocol + # phase 0 postponed to phase 8a -- Failure, Vacuous, Assertion, Result and Report -- with the + # transport suite, its TCPServer fixture and the two thin drivers landing beside them; phase 9 + # adds the remaining suites. Declares dexpace-core and nothing else, by design: neither driver + # requires its framework, so Minitest is never a runtime constraint on a consumer. module Conformance end end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/allocations.rb b/gems/dexpace-conformance/lib/dexpace/conformance/allocations.rb new file mode 100644 index 0000000..f59d775 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/allocations.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "dexpace/error/invalid_argument_error" + +module Dexpace + module Conformance + # OBS-25's "Selecting a no-op path MUST NOT allocate per call", measured the one way that is + # insensitive to the caller (5b's R8): the block is driven `iterations` and then + # `2 * iterations` times under GC.disable, and the per-call figure is the difference of the two + # deltas over `iterations` -- so a fixed cost (a method cache, an inline cache warming) cancels + # and only a per-iteration cost survives. What makes it insensitive to the CALLER is the + # precondition the block must meet, not the arithmetic: every argument the block passes must be + # one that cannot allocate -- a frozen constant, a Symbol, an Integer, nil -- because an inline + # String or Hash literal at the call site allocates per iteration whether or not the callee + # does. The file's `frozen_string_literal` is a rule of this repository, not this measurement's + # precondition. + # + # The figure returned is the one two consecutive measurements AGREE on -- core's own + # `AllocationDelta` shape, which this copies rather than the single loop 8a's plan sketched. + # On the 3.2.11 floor a one-time interpreter cost of 7 or 28 objects can land inside a measured + # block after the warm-up, once per process, and a single measurement came back NEGATIVE about + # one whole-file run in fifteen; an integer division of that is -1 and an exact zero assertion + # fails on it. A one-time cost cannot appear in two consecutive measurements, while a real + # per-call cost is exactly what every clean measurement returns, so the measurement repeats + # until two in a row agree (at most ATTEMPTS, then the last figure is returned and the + # assertion reports it) -- which keeps the assertion exact rather than clamping a negative + # figure or widening the delta, either of which would also hide a real fractional cost. + module Allocations + extend self + + # How many measurements may disagree before the helper gives up and reports the last. + ATTEMPTS = 5 + + # The iterations run before the first measurement, so the first call's one-time costs -- + # block object creation, method dispatch caches -- do not land in the measured block. + # Measured: `GC.stat(:total_allocated_objects)` around an empty block reports 2 and around + # `{ nil }` 3 or 4 on the four supported interpreters, so the harness's own overhead is + # caller-shaped and the two-loop difference is what removes it. + WARMUP_ITERATIONS = 100 + private_constant :WARMUP_ITERATIONS + + # Objects allocated per call of the block, to one thousandth: the figure two consecutive + # two-loop measurements agree on. `iterations:` is REQUIRED (8a's open question 5): OBS-25's + # "MUST NOT allocate per call" is a per-iteration claim, and dividing by an unstated count is + # not a claim about anything. + # + # @param iterations [Integer] the first loop's count; the second loop runs twice as many + # @yield the call under measurement, over arguments that cannot allocate + # @return [Float] objects per call + # @raise [Dexpace::InvalidArgumentError] unless iterations is a positive Integer + def delta(iterations:, &block) + unless iterations.is_a?(::Integer) && iterations.positive? + raise ::Dexpace::InvalidArgumentError, "iterations must be a positive Integer" + end + + ::GC.disable + previous = measure(iterations, &block) + ATTEMPTS.times do + current = measure(iterations, &block) + return current if current == previous + + previous = current + end + previous + ensure + ::GC.enable + end + + private + + # One two-loop measurement: the warm-up, then `iterations` and `2 * iterations` calls, the + # difference of the two deltas over `iterations`. + # + # `{ yield }` and never `times(&)`: Integer#times hands its index to the block it is given, + # and the caller's block -- a lambda, say -- may take no argument. + # rubocop:disable-next Style/ExplicitBlockArgument -- see above + def measure(iterations) + WARMUP_ITERATIONS.times { yield } + first = ::GC.stat(:total_allocated_objects) + iterations.times { yield } + second = ::GC.stat(:total_allocated_objects) + (iterations * 2).times { yield } + third = ::GC.stat(:total_allocated_objects) + ((third - second) - (second - first)) / iterations.to_f + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/assertion.rb b/gems/dexpace-conformance/lib/dexpace/conformance/assertion.rb new file mode 100644 index 0000000..c7537a9 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/assertion.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "dexpace/model" +require "dexpace/error/invalid_argument_error" + +module Dexpace + module Conformance + # One conformance check: the requirement IDs it exercises -- a waiver matches by these, never + # by #name -- a human name, and a callable body taking one subject (a TransportCase for the + # transport suite; phase 9 adds suites whose subject is not a transport, which is why the body + # is typed `^(untyped) -> void` and not against a named interface). + # + # Public API crossing into a consumer's own suite, so it follows the phase-1 construction + # pattern: `.new` private, a validating keyword `.build`, `Model.required!`'s one message form + # (SEAM-29), the ids copied through `Model.own` so a caller's later mutation cannot reach them, + # and `#with` through `.build` on every interpreter (Model#with). + class Assertion < Data.define(:ids, :name, :body) + include Model + + private_class_method :new + + # The validating factory every construction path goes through. + # + # @param ids [Enumerable] the requirement IDs this assertion exercises, non-empty + # @param name [String] a human name, used for the generated test method's name + # @param body [#call] the check, taking one subject + # @return [Assertion] frozen + # @raise [Dexpace::InvalidArgumentError] naming the member, on any invalid value + def self.build(ids:, name:, body:) + new(ids: ids, name: name, body: body) + end + + def initialize(ids:, name:, body:) + required = Model.required!("ids", ids).to_a + unless !required.empty? && required.all?(::String) + raise InvalidArgumentError, "ids must be a non-empty collection of requirement-ID Strings" + end + raise InvalidArgumentError, "body must respond to #call" unless + Model.required!("body", body).respond_to?(:call) + + super(ids: Model.own(required), name: Model.frozen_string(Model.required!("name", name)), + body: body,) + end + + # Runs the check against one subject, returning whatever the body returns; the protocol's + # signal is what it RAISES -- nothing, a Failure or a Vacuous -- and TransportSuite.run + # maps that onto a Result. + # + # @param subject [Object] what the suite hands each assertion + # @return [Object] the body's return value, of no significance to the protocol + def call(subject) + body.call(subject) + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/borrowed_pair.rb b/gems/dexpace-conformance/lib/dexpace/conformance/borrowed_pair.rb new file mode 100644 index 0000000..f270f47 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/borrowed_pair.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require "dexpace/model" +require "dexpace/error/invalid_argument_error" + +module Dexpace + module Conformance + # What a driver's `borrow:` factory returns (suite contract 4a): the transport wrapping the + # caller's own native client, and a probe answering "is that client still usable?". The probe + # is the adapter's, because only the adapter knows what using its native client looks like -- + # a Net::HTTP round trip on one, an Async::HTTP::Client one on the other -- and that is what + # keeps TRANSPORT-15's borrowed half portable across adapters that share no client class, and + # what keeps a native client class out of this gem's lib/ altogether. + class BorrowedPair < Data.define(:transport, :probe) + include Model + + private_class_method :new + + # The validating factory every construction path goes through. + # + # @param transport [Object] the borrowing transport, wrapping the caller's own client + # @param probe [#call] answers truthy while the caller's client still works + # @return [BorrowedPair] frozen + # @raise [Dexpace::InvalidArgumentError] naming the member + def self.build(transport:, probe:) + new(transport: transport, probe: probe) + end + + def initialize(transport:, probe:) + Model.required!("transport", transport) + raise InvalidArgumentError, "probe must respond to #call" unless + Model.required!("probe", probe).respond_to?(:call) + + super + end + + # Whether the caller's own client still works, per the adapter's probe -- asked after the + # borrowing transport was closed, which is TRANSPORT-15's whole borrowed clause. + # + # @return [Boolean] + def still_usable? + probe.call ? true : false + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/failure.rb b/gems/dexpace-conformance/lib/dexpace/conformance/failure.rb new file mode 100644 index 0000000..5e75dd4 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/failure.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + module Conformance + # The assertion protocol's failure (design §9.3): what an assertion raises instead of returning + # cleanly, carrying the expected and actual values it compared and the requirement IDs it was + # checking. A TEST RESULT, not an SDK error -- P8-8 records why this is a ::StandardError that + # does NOT include Dexpace::Error: phase 1 made that module the root every SDK error includes so + # a caller can `rescue Dexpace::Error` broadly, and an adapter author's broad rescue around a + # send must not swallow the assertion that the send was wrong. + class Failure < ::StandardError + # @return [Object] what the assertion expected + attr_reader :expected + # @return [Object] what it found + attr_reader :actual + # @return [Array] the requirement IDs the assertion was checking, frozen + attr_reader :requirement_ids + + # @param message [String] the failure, in words + # @param expected [Object] what the assertion expected + # @param actual [Object] what it found + # @param requirement_ids [Array] the IDs it was checking + def initialize(message, expected:, actual:, requirement_ids:) + @expected = expected + @actual = actual + @requirement_ids = requirement_ids.dup.freeze + super(message) + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/minitest_driver.rb b/gems/dexpace-conformance/lib/dexpace/conformance/minitest_driver.rb new file mode 100644 index 0000000..433fdba --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/minitest_driver.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +require_relative "failure" +require_relative "vacuous" +require_relative "transport_case" + +module Dexpace + module Conformance + # The thin Minitest driver (design §9.3): `extend` it into a test class and call + # `conformance(suite, build:)`, and one test method per assertion is defined. Each generated + # test runs its OWN assertion directly, in a fresh TransportCase built from the same settings + # every other assertion gets -- so a Minitest `-n` filter exercises exactly the code path a + # full run does, never a replay of a cached Result -- and tears the case down in an ensure. A + # Vacuous is reported as a skip naming the reason, a Failure as a flunk naming the ids, and a + # waived id as a skip naming it, which is design §9.3's "the gap stays visible" in Minitest's + # own summary; anything else propagates as an error and is never silently a failure. + # + # Named MinitestDriver and not Minitest: inside module Dexpace::Conformance a constant named + # Minitest would shadow ::Minitest for every bare reference in the namespace, this file's + # included (5a's P5-3 reasoning, applied a second time). Nothing here requires or names the + # framework: `skip` and `flunk` are sent to the test instance the consumer's class already is, + # so this gem stays at `dexpace-core` and nothing else (NFR-2) and a Minitest that is a bundled + # gem (verified fact 17) is never a runtime constraint on a consumer. + # + # The consumer's test class should inherit the repository's own base where it has one -- the + # first-party driver inherits DexpaceTestCase, whose teardown counts threads, so a leaked + # fixture thread fails the generated test that leaked it. + module MinitestDriver + # @param suite [#assertions] anything shaped like TransportSuite: an ordered #assertions + # @param build [#call] the keyword-taking factory building the SDK-managed transport + # @param borrow [#call, nil] the port-taking factory returning a BorrowedPair, or nil + # @param waive [Array] requirement IDs recorded as skipped and never run + # @param settle [#call, nil] suite contract clause 8's send primitive; nil takes the default + # @param around [#call, nil] clause 9's wrapper around each assertion's invocation + # @param wire [#call, nil] clause 11's fixture factory; nil takes WireServer + # @return [void] + def conformance(suite, build:, borrow: nil, waive: [], settle: nil, around: nil, wire: nil) + case_options = { build: build, borrow: borrow, settle: settle || TransportCase::DEFAULT_SETTLE, + wire: wire || TransportCase::DEFAULT_WIRE, }.freeze + suite.assertions.each do |assertion| + define_method(MinitestDriver.method_name_for(assertion)) do + MinitestDriver.drive(self, assertion, around, case_options, waive: waive) + end + end + end + + # The generated method's name: `test_` plus the assertion's name with every non-word run + # collapsed to one underscore, so `-n` can select it and Minitest's naming rule holds. + # + # @param assertion [Assertion] + # @return [String] + def self.method_name_for(assertion) + "test_#{assertion.name.gsub(/\W+/, "_").gsub(/\A_|_\z/, "")}" + end + + # One assertion in one fresh case on the given test instance, torn down whatever happened + # -- or a skip naming the waived id, before any case is built. Clause 9: the driver INVOKES + # the assertion, so it may wrap it -- how an async driver opens the reactor the assertion's + # body reads a streamed response inside. + # + # @api private + def self.drive(test, assertion, around, case_options, waive:) + waived = assertion.ids & waive + return test.skip("waived: #{waived.join(", ")}") unless waived.empty? + + kase = build_case(case_options) + around ? around.call { assertion.call(kase) } : assertion.call(kase) + rescue Vacuous => error + test.skip("vacuous: #{error.reason}") + rescue Failure => error + test.flunk("#{assertion.ids.join(", ")}: #{error.message}") + ensure + kase&.teardown + end + + # The fresh case each generated test builds from the driver's frozen options. + # + # @api private + # @return [TransportCase] + def self.build_case(case_options) + TransportCase.new(build: case_options.fetch(:build), borrow: case_options[:borrow], + settle: case_options.fetch(:settle), wire: case_options.fetch(:wire),) + end + end + end +end diff --git a/gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb b/gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb new file mode 100644 index 0000000..7fcaae5 --- /dev/null +++ b/gems/dexpace-conformance/lib/dexpace/conformance/recording_span.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +# SPDX-License-Identifier: MIT + +module Dexpace + module Conformance + # OBS-21's RECORDING branch, which core's own NO_SPAN cannot demonstrate because it is always + # non-recording: a real in-memory span over phase 5c's `_Span` protocol -- `recording?`, the + # three mutators, `status=`, `finish(end_timestamp:)` and `context` -- that records every + # mutation while live and goes inert after #finish, which is OBS-21's idempotence clause and + # its post-finish-mutation clause in one object. Shipped here rather than in core's test tree + # because phase 5c assigned it to the conformance gem and a third-party adapter author + # asserting OBS-21 needs it to ship; `recording: false` is the non-recording variant OBS-23's + # delegation assertions need. + # + # `finished_at` is an Array, not a value: "call end() twice and assert no duplicate export" is + # assertable only against something that could have exported twice, and a one-element array + # after two #finish calls is that assertion. + class RecordingSpan + # @return [Hash{String => Object}] every attribute set while recording + attr_reader :attributes + # @return [Array] every event added while recording, as `{name:, attributes:}` + attr_reader :events + # @return [Array] every error recorded while recording, as `{error:, attributes:}` + attr_reader :errors + # @return [Object, nil] the last status set while recording + attr_reader :status + # @return [Array