Add focused smoke tests for the remaining 8 gem classes + adapter-raises edge

Sandi S2 (smoke coverage was 4/12) + Tobi T7 (adapter exception path
untested) consensus actions. 38 new tests across 9 files take total
from 15 -> 53 tests, 50 -> 134 assertions.

Per-class breakdown:

  session_test.rb                (2 tests)
    Contract on initialize parameters (positional record + keyword
    callables) and public surface (ensure!/recreate!/abort!/just_created?).
    AR fixtures stay in the host suite.

  turn_test.rb                   (4 tests)
    Required + optional keyword arg contracts (locks the 9 required
    + 8 optional keys against drift). Public surface = [:call] only.
    Result struct exercised as a value object with status predicates
    and cost/token delegation.

  message_artifacts_test.rb      (2 tests)
    Contract on initialize parameters; public surface = [:attach_from].

  impostor_test.rb               (3 tests)
    Initialize keyword contract; delegation to ActiveStorage attachment
    via a Struct double on #filename.

  sandbox_test.rb                (5 tests)
    Real tmpdir instantiation: #path / #exists? / #files (Enumerator
    when block-less, yields SandboxFile values when files present) /
    #file lookup-by-basename returns nil for missing.

  sandbox_file_test.rb           (4 tests)
    Real tmpdir + file: basic readers, marcel-backed content_type
    detection, #safe? size-cap rejection, #as_artifact identity
    conversion returns Opencode::Artifact.

  transform_test.rb              (8 tests)
    Documents the abstract contract (source_filename / destination_filename /
    render all raise NotImplementedError). A trivial concrete subclass
    inside the test exercises the default implementations of
    #applies_to?, #trusted?, #owned_filenames, and #purge_impostors?
    that delegate to the two abstract filename methods.

  tool_display_test.rb           (5 tests)
    Known tool canonicalization, status predicates (running/completed/
    errored/in_flight/terminal), unknown-tool fallback, nil-part
    tolerance (callers sometimes pass non-tool parts_json entries).

  uploaded_files_prompt_test.rb  (3 tests)
    Initialize keyword contract; #text returns raw content + empty
    sandbox_file_names map when no files attached; public surface check.

Plus 2 new tests in error_reporter_test.rb (C4):
  - test_adapter_exceptions_propagate: adapter that raises must
    propagate, not silently swallow — operators need to know the
    error tracker is broken.
  - test_report_returns_adapter_return_value: report passes through
    the adapter's return value verbatim (Rails.error.report returns
    the error itself; callers can chain).

Faithful to actual implementations: every test was first written from
the API I expected, then corrected against the class internals when
errors surfaced. The corrections themselves document the contract:
SandboxFile expects a String sandbox_prefix with trailing separator
(not a Pathname), Transform's filename methods are abstract not
nil-defaulting, etc.
This commit is contained in:
2026-05-20 06:40:34 -07:00
parent df01387124
commit 08ab6ea6fc
10 changed files with 460 additions and 0 deletions

View File

@@ -47,4 +47,29 @@ class Opencode::ErrorReporterTest < Minitest::Test
Opencode::ErrorReporter.report(RuntimeError.new("kaboom"))
assert invoked, "Adapter should be invoked even with no kwargs"
end
def test_adapter_exceptions_propagate
# If the host's adapter itself raises (Honeybadger HTTP failure,
# Sentry quota error, etc.) the gem must propagate — silently
# swallowing the adapter's own errors would hide an outage from
# operators who think their error tracker is healthy.
Opencode::ErrorReporter.adapter = ->(_error, **_opts) {
raise StandardError, "adapter blew up"
}
raised = assert_raises(StandardError) do
Opencode::ErrorReporter.report(RuntimeError.new("original"))
end
assert_equal "adapter blew up", raised.message
end
def test_report_returns_adapter_return_value
# Useful for hosts wanting Rails.error.report's standard return
# (the error itself). Verifies the call shape doesn't transform it.
sentinel = Object.new
Opencode::ErrorReporter.adapter = ->(_error, **_opts) { sentinel }
result = Opencode::ErrorReporter.report(StandardError.new("x"))
assert_same sentinel, result
end
end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Impostor. Wraps an ActiveStorage
# attachment that's been replaced by a Transform-rendered artifact.
# Behavioral tests against real ActiveStorage::Attachment live in
# the host application.
class Opencode::ImpostorTest < Minitest::Test
def test_initialize_takes_attachment_keyword
params = Opencode::Impostor.instance_method(:initialize).parameters
assert_includes params, [ :keyreq, :attachment ],
"Impostor must require an attachment: keyword (ActiveStorage::Attachment-like)"
end
def test_public_api
assert_equal %i[filename purge!].sort,
Opencode::Impostor.instance_methods(false).sort
end
def test_filename_delegates_to_attachment
attachment_double = Struct.new(:filename).new("legacy.html")
impostor = Opencode::Impostor.new(attachment: attachment_double)
assert_equal "legacy.html", impostor.filename
end
end

View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::MessageArtifacts (the idempotent
# ActiveStorage-backed artifact attachment pipeline). Behavioral
# coverage — including ActiveStorage attachment, Transform application,
# error reporting via Opencode::ErrorReporter — lives in the host app.
class Opencode::MessageArtifactsTest < Minitest::Test
def test_initialize_takes_message_feature_and_optional_transforms
params = Opencode::MessageArtifacts.instance_method(:initialize).parameters
by_kind = params.group_by(&:first).transform_values { |list| list.map(&:last) }
assert_includes by_kind[:keyreq], :message,
"MessageArtifacts must require a message: keyword"
assert_includes by_kind[:keyreq], :feature,
"MessageArtifacts must require a feature: keyword (used in error reports)"
assert_includes by_kind[:key] || [], :transforms,
"MessageArtifacts must accept an optional transforms: keyword"
end
def test_public_api_is_attach_from
assert_equal [ :attach_from ], Opencode::MessageArtifacts.instance_methods(false),
"MessageArtifacts's only public verb is #attach_from"
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "tmpdir"
require "marcel" # SandboxFile#content_type uses Marcel::MimeType.for
# Smoke test for Opencode::SandboxFile: instantiate against a real
# pathname, verify basename/size/content/content_type readers and the
# identity conversion to Opencode::Artifact via #as_artifact.
class Opencode::SandboxFileTest < Minitest::Test
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-file-test-")
@path = File.join(@tmpdir, "notes.md")
File.write(@path, "# hello\nworld\n")
# SandboxFile uses `start_with?` against this prefix to detect path
# escape; it expects a String with trailing separator so that
# /sandbox-1 doesn't false-positive on /sandbox-10/foo.
@sandbox_prefix = File.join(@tmpdir, "")
end
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
end
def test_basic_readers
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
assert_equal "notes.md", file.basename
assert file.size.positive?
assert_equal "# hello\nworld\n", file.content
assert file.safe?, "small text file inside sandbox should be safe"
end
def test_content_type_detection_via_marcel
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
# Marcel detects .md as text/markdown.
assert_match(/markdown|text/, file.content_type)
end
def test_safe_rejects_files_over_size_cap
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 5
)
refute file.safe?, "file larger than max_bytes must be unsafe"
end
def test_as_artifact_returns_opencode_artifact_value
file = Opencode::SandboxFile.new(
path: @path, sandbox_prefix: @sandbox_prefix, max_bytes: 10_000
)
artifact = file.as_artifact
assert_instance_of Opencode::Artifact, artifact
assert_equal "notes.md", artifact.filename
assert_equal "# hello\nworld\n", artifact.content
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "tmpdir"
require "marcel" # SandboxFile (yielded by Sandbox#files) needs marcel
# Smoke test for Opencode::Sandbox: instantiate against a real tmpdir,
# verify #path, #exists?, and that #files / #file return empty / nil
# when no sandbox files are present. Behavioral coverage of actual file
# enumeration lives in the host application where AIGL + Blackline +
# Raven sandbox configurations exercise the path.
class Opencode::SandboxTest < Minitest::Test
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-sandbox-test-")
end
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
end
def test_path_and_exists_when_directory_present
sandbox = Opencode::Sandbox.new(path: @tmpdir)
assert_equal @tmpdir, sandbox.path
assert sandbox.exists?
end
def test_exists_false_when_path_missing
sandbox = Opencode::Sandbox.new(path: File.join(@tmpdir, "missing"))
refute sandbox.exists?
end
def test_files_returns_enumerator_yielding_nothing_when_empty
sandbox = Opencode::Sandbox.new(path: @tmpdir)
# No block given => Enumerator.
assert_kind_of Enumerator, sandbox.files
assert_equal [], sandbox.files.to_a
end
def test_files_yields_sandbox_files_for_real_entries
File.write(File.join(@tmpdir, "notes.md"), "x")
File.write(File.join(@tmpdir, "map.md"), "y")
sandbox = Opencode::Sandbox.new(path: @tmpdir)
basenames = sandbox.files.map(&:basename).sort
assert_equal %w[map.md notes.md], basenames
end
def test_file_returns_nil_for_missing_relative_name
sandbox = Opencode::Sandbox.new(path: @tmpdir)
assert_nil sandbox.file("nope.txt")
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Session. Behavioral coverage (idempotent
# ensure!/recreate!/abort! with row-level locking, race-safety,
# permissions_for callable handoff) lives in the host application
# where AR fixtures + a real ActiveRecord row exist.
class Opencode::SessionTest < Minitest::Test
def test_initialize_takes_record_and_two_keyword_callables
params = Opencode::Session.instance_method(:initialize).parameters
assert_includes params, [ :req, :record ],
"Session must take a positional record (an AR row with #with_lock, #title, etc.)"
assert_includes params, [ :keyreq, :permissions_for ],
"Session must require a permissions_for: callable (host-injected per-product permissions)"
assert_includes params, [ :key, :on_error ],
"Session must accept an optional on_error: callable for adapter-style error reporting"
end
def test_public_api_is_ensure_recreate_abort_just_created
methods = Opencode::Session.instance_methods(false).sort
assert_equal %i[abort! ensure! just_created? recreate!].sort, methods.sort,
"Session's public surface should be exactly: ensure!/recreate!/abort!/just_created?. " \
"Found: #{methods.inspect}"
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
require "test_helper"
# Smoke tests for Opencode::ToolDisplay — the view-model that converts
# raw tool-part hashes into Turbo-Stream-friendly props. We exercise
# the predicate surface for the canonical 'read' tool plus the
# unknown-tool fallback. Exhaustive per-tool render tests live in the
# host where the renderer templates are exercised.
class Opencode::ToolDisplayTest < Minitest::Test
def test_known_read_tool_canonicalization
display = Opencode::ToolDisplay.new(
"type" => "tool", "tool" => "read", "status" => "completed",
"input" => { "filePath" => "/sandbox/notes.md" }
)
assert_equal "read", display.canonical_tool
assert display.known?
assert display.completed?
assert display.terminal?
refute display.errored?
refute display.in_flight?
end
def test_running_status
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "read", "status" => "running")
assert display.in_flight?
refute display.terminal?
refute display.completed?
end
def test_errored_status
display = Opencode::ToolDisplay.new(
"type" => "tool", "tool" => "edit", "status" => "error", "error" => "permission denied"
)
assert display.errored?
assert display.terminal?
refute display.completed?
end
def test_unknown_tool_falls_back_gracefully
display = Opencode::ToolDisplay.new("type" => "tool", "tool" => "wat", "status" => "completed")
refute display.known?,
"Unknown tools must not claim to be known — host renderer dispatches a fallback view"
refute_nil display.canonical_tool,
"Unknown tools still need a canonical_tool so DOM ids stay stable"
end
def test_nil_part_initializes_safely
# ToolDisplay tolerates a nil part because callers sometimes pass
# message.parts_json entries that aren't tool parts.
display = Opencode::ToolDisplay.new(nil)
refute display.known?
end
end

View File

@@ -0,0 +1,69 @@
# frozen_string_literal: true
require "test_helper"
# Smoke test for Opencode::Transform — the base class for
# content-rewriting transforms. It is intentionally abstract:
# #source_filename, #destination_filename, and #render all raise
# NotImplementedError. Subclasses (host-side) provide the meat.
# These tests document the abstract contract.
class Opencode::TransformTest < Minitest::Test
def test_source_filename_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.source_filename }
assert_match(/must implement #source_filename/, err.message)
end
def test_destination_filename_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.destination_filename }
assert_match(/must implement #destination_filename/, err.message)
end
def test_render_is_abstract
err = assert_raises(NotImplementedError) { Opencode::Transform.new.render(Object.new) }
assert_match(/must implement #render/, err.message)
end
def test_purge_impostors_defaults_to_false
refute Opencode::Transform.new.purge_impostors?,
"Default #purge_impostors? must be false — conservative opt-in by subclasses"
end
# A trivial concrete subclass exercises the defaults that DO exist
# (#applies_to?, #trusted?, #owned_filenames all delegate to the
# two abstract filename methods).
class FakeTransform < Opencode::Transform
def source_filename = "agent-output.json"
def destination_filename = "rendered.html"
end
Attachment = Struct.new(:filename, keyword_init: true)
Basenamed = Struct.new(:basename, keyword_init: true)
def test_applies_to_matches_source_filename_by_default
transform = FakeTransform.new
matching = Basenamed.new(basename: "agent-output.json")
other = Basenamed.new(basename: "something-else.json")
assert transform.applies_to?(matching)
refute transform.applies_to?(other)
end
def test_trusted_matches_destination_filename_by_default
transform = FakeTransform.new
trusted = Attachment.new(filename: "rendered.html")
untrusted = Attachment.new(filename: "agent-output.json")
assert transform.trusted?(trusted)
refute transform.trusted?(untrusted)
end
def test_owned_filenames_is_source_and_destination
assert_equal %w[agent-output.json rendered.html],
FakeTransform.new.owned_filenames
end
def test_error_is_a_subclass_of_standarderror
assert_operator Opencode::Transform::Error, :<, StandardError,
"Transform::Error must be rescuable by `rescue StandardError`"
end
end

View File

@@ -0,0 +1,62 @@
# frozen_string_literal: true
require "test_helper"
# Contract smoke for Opencode::Turn (the orchestrator) and its inner
# Result value object. Behavioral coverage (the full send -> stream ->
# recover -> finalize loop) lives in the host application — Turn needs
# an Opencode::Client, an AR Message, a subject record, etc., which are
# all integration-level concerns.
class Opencode::TurnTest < Minitest::Test
REQUIRED_INIT_KEYS = %i[
message subject query_text client session_for observer_factory
system_context agent_name tracer
].freeze
OPTIONAL_INIT_KEYS = %i[
on_finalized on_turn_finished on_activity_tick
empty_stream_retry_delay final_exchange_timeout
final_exchange_retry_delay error_fallback_content error_feature
].freeze
def test_required_keyword_arguments
params = Opencode::Turn.instance_method(:initialize).parameters
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
assert_equal REQUIRED_INIT_KEYS.sort, required,
"Turn's required keyword args drifted. Expected: #{REQUIRED_INIT_KEYS.sort}, got: #{required}"
end
def test_optional_keyword_arguments_match_documented_surface
params = Opencode::Turn.instance_method(:initialize).parameters
optional = params.select { |kind, _| kind == :key }.map(&:last).sort
assert_equal OPTIONAL_INIT_KEYS.sort, optional,
"Turn's optional keyword args drifted. Expected: #{OPTIONAL_INIT_KEYS.sort}, got: #{optional}"
end
def test_public_surface_is_call_only
# Turn is an orchestrator; the only public verb is #call. Everything
# else is internal. Locking this prevents helpers from accidentally
# bleeding into the public API.
assert_equal [ :call ], Opencode::Turn.instance_methods(false)
end
def test_result_is_a_value_object_with_status_predicates
fake_message = Struct.new(:cost, :input_tokens, :output_tokens, keyword_init: true).new(
cost: 0.012, input_tokens: 100, output_tokens: 50
)
result = Opencode::Turn::Result.new(
status: :completed, message: fake_message, duration_ms: 1234
)
assert result.completed?
refute result.cancelled?
refute result.errored?
refute result.failed?
assert_equal 1234, result.duration_ms
assert_equal 0.012, result.cost
assert_equal 100, result.input_tokens
assert_equal 50, result.output_tokens
end
end

View File

@@ -0,0 +1,55 @@
# frozen_string_literal: true
require "test_helper"
require "fileutils"
require "tmpdir"
# Smoke test for Opencode::UploadedFilesPrompt. The sandbox_path:
# inversion (per D14 in the design doc) means we can exercise the
# happy path against a tmpdir without needing Rails / AR / ActiveStorage
# fixtures. Behavioral tests covering ActiveStorage attached_blob
# enumeration live in the host application.
class Opencode::UploadedFilesPromptTest < Minitest::Test
# Minimal stub for a user message: #content (the raw user text) and
# #files (an ActiveStorage-like collection with #attached?). Real
# behavior is exercised in the host's test suite.
FakeMessage = Struct.new(:content, :files, keyword_init: true)
EmptyFiles = Struct.new(:attached) do
def attached? = attached
end
def setup
@tmpdir = Dir.mktmpdir("opencode-rails-uploaded-prompt-test-")
end
def teardown
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
end
def test_initialize_takes_three_required_keywords
params = Opencode::UploadedFilesPrompt.instance_method(:initialize).parameters
required = params.select { |kind, _| kind == :keyreq }.map(&:last).sort
assert_equal %i[sandbox_name_for sandbox_path user_message], required,
"UploadedFilesPrompt requires user_message:, sandbox_path:, sandbox_name_for:"
end
def test_text_returns_raw_content_when_no_files_attached
message = FakeMessage.new(content: "hello world", files: EmptyFiles.new(false))
prompt = Opencode::UploadedFilesPrompt.new(
user_message: message,
sandbox_path: @tmpdir,
sandbox_name_for: ->(file) { file.filename.to_s }
)
assert_equal "hello world", prompt.text,
"No attached files => text is the raw user content"
assert_equal({}, prompt.sandbox_file_names,
"No attached files => sandbox_file_names map stays empty")
end
def test_public_surface
assert_equal %i[sandbox_file_names text].sort,
Opencode::UploadedFilesPrompt.instance_methods(false).sort
end
end