From 08ab6ea6fc55beebfdab0e9a85603027120563c1 Mon Sep 17 00:00:00 2001 From: Ajay Krishnan Date: Wed, 20 May 2026 06:40:34 -0700 Subject: [PATCH] Add focused smoke tests for the remaining 8 gem classes + adapter-raises edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/opencode/error_reporter_test.rb | 25 ++++++++ test/opencode/impostor_test.rb | 26 ++++++++ test/opencode/message_artifacts_test.rb | 26 ++++++++ test/opencode/sandbox_file_test.rb | 62 ++++++++++++++++++ test/opencode/sandbox_test.rb | 53 ++++++++++++++++ test/opencode/session_test.rb | 27 ++++++++ test/opencode/tool_display_test.rb | 55 ++++++++++++++++ test/opencode/transform_test.rb | 69 +++++++++++++++++++++ test/opencode/turn_test.rb | 62 ++++++++++++++++++ test/opencode/uploaded_files_prompt_test.rb | 55 ++++++++++++++++ 10 files changed, 460 insertions(+) create mode 100644 test/opencode/impostor_test.rb create mode 100644 test/opencode/message_artifacts_test.rb create mode 100644 test/opencode/sandbox_file_test.rb create mode 100644 test/opencode/sandbox_test.rb create mode 100644 test/opencode/session_test.rb create mode 100644 test/opencode/tool_display_test.rb create mode 100644 test/opencode/transform_test.rb create mode 100644 test/opencode/turn_test.rb create mode 100644 test/opencode/uploaded_files_prompt_test.rb diff --git a/test/opencode/error_reporter_test.rb b/test/opencode/error_reporter_test.rb index df41fd0..987d2f7 100644 --- a/test/opencode/error_reporter_test.rb +++ b/test/opencode/error_reporter_test.rb @@ -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 diff --git a/test/opencode/impostor_test.rb b/test/opencode/impostor_test.rb new file mode 100644 index 0000000..3562063 --- /dev/null +++ b/test/opencode/impostor_test.rb @@ -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 diff --git a/test/opencode/message_artifacts_test.rb b/test/opencode/message_artifacts_test.rb new file mode 100644 index 0000000..ad8bd6c --- /dev/null +++ b/test/opencode/message_artifacts_test.rb @@ -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 diff --git a/test/opencode/sandbox_file_test.rb b/test/opencode/sandbox_file_test.rb new file mode 100644 index 0000000..78e26e9 --- /dev/null +++ b/test/opencode/sandbox_file_test.rb @@ -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 diff --git a/test/opencode/sandbox_test.rb b/test/opencode/sandbox_test.rb new file mode 100644 index 0000000..a38091a --- /dev/null +++ b/test/opencode/sandbox_test.rb @@ -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 diff --git a/test/opencode/session_test.rb b/test/opencode/session_test.rb new file mode 100644 index 0000000..0655de8 --- /dev/null +++ b/test/opencode/session_test.rb @@ -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 diff --git a/test/opencode/tool_display_test.rb b/test/opencode/tool_display_test.rb new file mode 100644 index 0000000..4ffdccf --- /dev/null +++ b/test/opencode/tool_display_test.rb @@ -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 diff --git a/test/opencode/transform_test.rb b/test/opencode/transform_test.rb new file mode 100644 index 0000000..00e91b8 --- /dev/null +++ b/test/opencode/transform_test.rb @@ -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 diff --git a/test/opencode/turn_test.rb b/test/opencode/turn_test.rb new file mode 100644 index 0000000..7e45da1 --- /dev/null +++ b/test/opencode/turn_test.rb @@ -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 diff --git a/test/opencode/uploaded_files_prompt_test.rb b/test/opencode/uploaded_files_prompt_test.rb new file mode 100644 index 0000000..d6777c1 --- /dev/null +++ b/test/opencode/uploaded_files_prompt_test.rb @@ -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