diff --git a/CHANGELOG.md b/CHANGELOG.md index e05d2aa..0596b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to the E2B Ruby SDK will be documented in this file. ## [Unreleased] +### Fixed + +- **`ProcessResult.from_connect_response` no longer raises on non-Hash input.** The defensive guard delegated `nil`/`String`/`Array` arguments to `from_hash`, which then indexed them with string keys and raised `TypeError`/`NoMethodError`. It now returns an empty, successful result for any non-Hash input. + +### Internal + +- **Substantially expanded the test suite (131 → 278 examples).** Added unit specs for previously untested units: `DockerfileParser`, all response models (`ProcessResult`, `SandboxInfo`, `EntryInfo`/`FilesystemEvent`, and the template/build models), `Configuration`, `SandboxHelpers`, the paginators, the error hierarchy, `WatchHandle`, `ReadyCmd`, `DefaultBuildLogger`, the `BaseService`/`EnvdHttpClient` auth-header and error-mapping helpers, `LiveStreamable`, and the synchronous/error paths of `CommandHandle`. +- **Added opt-in SimpleCov coverage** via `COVERAGE=true bundle exec rake spec`, with branch coverage and a minimum-coverage gate configured in `spec/spec_helper.rb`. + ## [0.4.1] - 2026-05-29 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index c74c373..521c0b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ Ruby SDK for the E2B sandbox API. The public surface lives under the `E2B` modul - `bundle exec rake` — runs `rake spec` then `rake rubocop` (the default task). Use this as the gate before commits. - `bundle exec rake spec` — RSpec only (prefer over bare `rspec` so the bundle is honored). +- `COVERAGE=true bundle exec rake spec` — same run with SimpleCov instrumentation (branch coverage on). Writes an HTML report to `coverage/` and fails if it drops below the floor in `spec/spec_helper.rb` (currently line 80% / branch 58%). Coverage is opt-in so single-file runs aren't failed by the gate; raise the floor as coverage improves. - `bundle exec rake rubocop` — style + RSpec cops; config in `.rubocop.yml`. Pre-existing waivers live in `.rubocop_todo.yml` — tighten by deleting an entry there and fixing the underlying violations, not by silencing them in the main config. - `bundle exec rubocop -a` — apply safe autofixes (no `-A`; unsafe corrections are reviewed by hand). - `bundle exec rake build` — build the gem into `pkg/`. diff --git a/e2b.gemspec b/e2b.gemspec index a18802c..4a5c8f6 100644 --- a/e2b.gemspec +++ b/e2b.gemspec @@ -35,5 +35,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "rubocop", "~> 1.72" spec.add_development_dependency "rubocop-rspec", "~> 3.0" + spec.add_development_dependency "simplecov", "~> 0.22" spec.add_development_dependency "webmock", "~> 3.0" end diff --git a/lib/e2b/models/process_result.rb b/lib/e2b/models/process_result.rb index bc7fb26..4666afb 100644 --- a/lib/e2b/models/process_result.rb +++ b/lib/e2b/models/process_result.rb @@ -42,7 +42,9 @@ def self.from_hash(data) # @param data [Hash] Connect RPC response data # @return [ProcessResult] def self.from_connect_response(data) - return from_hash(data) unless data.is_a?(Hash) + # Defensive: any non-Hash input (nil, String, Array) cannot be a Connect + # response, so return an empty result rather than crashing in from_hash. + return new unless data.is_a?(Hash) stdout = data[:stdout] || data["stdout"] || "" stderr = data[:stderr] || data["stderr"] || "" diff --git a/spec/e2b/configuration_spec.rb b/spec/e2b/configuration_spec.rb new file mode 100644 index 0000000..79615b5 --- /dev/null +++ b/spec/e2b/configuration_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe E2B::Configuration do + around do |example| + # Isolate the process environment so env-var precedence specs are deterministic. + saved = ENV.to_hash + %w[E2B_API_KEY E2B_ACCESS_TOKEN E2B_DOMAIN E2B_API_URL E2B_DEBUG].each { |k| ENV.delete(k) } + example.run + ensure + ENV.replace(saved) + end + + describe "#initialize defaults and env vars" do + it "uses built-in defaults when nothing is provided" do + config = described_class.new + expect(config.domain).to eq(described_class::DEFAULT_DOMAIN) + expect(config.api_url).to eq("https://api.#{described_class::DEFAULT_DOMAIN}") + expect(config.request_timeout).to eq(described_class::DEFAULT_REQUEST_TIMEOUT) + expect(config.debug).to be(false) + end + + it "reads credentials and domain from environment variables" do + ENV["E2B_API_KEY"] = "env-key" + ENV["E2B_ACCESS_TOKEN"] = "env-token" + ENV["E2B_DOMAIN"] = "custom.dev" + + config = described_class.new + expect(config.api_key).to eq("env-key") + expect(config.access_token).to eq("env-token") + expect(config.domain).to eq("custom.dev") + expect(config.api_url).to eq("https://api.custom.dev") + end + + it "prefers explicit arguments over environment variables" do + ENV["E2B_API_KEY"] = "env-key" + config = described_class.new(api_key: "explicit-key") + expect(config.api_key).to eq("explicit-key") + end + + it "enables debug from E2B_DEBUG=true and points api_url at localhost" do + ENV["E2B_DEBUG"] = "true" + config = described_class.new + expect(config.debug).to be(true) + expect(config.api_url).to eq("http://localhost:3000") + end + + it "honours an explicit E2B_API_URL over the domain-derived URL" do + ENV["E2B_API_URL"] = "https://proxy.internal" + expect(described_class.new.api_url).to eq("https://proxy.internal") + end + end + + describe "#validate!" do + it "raises ConfigurationError when neither api_key nor access_token is set" do + expect { described_class.new.validate! } + .to raise_error(E2B::ConfigurationError, /API key is required/) + end + + it "does not raise when an api_key is present" do + expect { described_class.new(api_key: "k").validate! }.not_to raise_error + end + + it "does not raise when only an access_token is present" do + expect { described_class.new(access_token: "t").validate! }.not_to raise_error + end + end + + describe "#valid?" do + it "is true with credentials and false without" do + expect(described_class.new(api_key: "k")).to be_valid + expect(described_class.new(access_token: "t")).to be_valid + expect(described_class.new).not_to be_valid + expect(described_class.new(api_key: "")).not_to be_valid + end + end + + describe ".default_api_url" do + it "returns localhost in debug mode and the domain URL otherwise" do + expect(described_class.default_api_url("e2b.app", debug: true)).to eq("http://localhost:3000") + expect(described_class.default_api_url("e2b.app")).to eq("https://api.e2b.app") + end + end +end diff --git a/spec/e2b/dockerfile_parser_spec.rb b/spec/e2b/dockerfile_parser_spec.rb new file mode 100644 index 0000000..8400a03 --- /dev/null +++ b/spec/e2b/dockerfile_parser_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "spec_helper" +require "tempfile" + +# Minimal stand-in for the Template builder. Records every call the parser makes +# (via method_missing, so it mirrors the builder's setter-style interface +# without re-declaring those method names) instead of building a real template. +class RecordingBuilder + attr_reader :calls + + def initialize + @calls = [] + end + + def calls_of(name) + @calls.select { |c| c.first == name } + end + + def respond_to_missing?(_name, _include_private = false) = true + + def method_missing(name, *args, **kwargs) + entry = [name, *args] + entry << kwargs unless kwargs.empty? + @calls << entry + nil + end +end + +RSpec.describe E2B::DockerfileParser do + # Records every builder call so specs can assert on the translated template. + let(:builder) { RecordingBuilder.new } + + describe ".parse base image handling" do + it "returns the base image from a single FROM instruction" do + expect(described_class.parse("FROM ubuntu:22.04", builder)).to eq("ubuntu:22.04") + end + + it "strips an `AS stage` alias from the base image" do + expect(described_class.parse("FROM ubuntu:22.04 AS build", builder)).to eq("ubuntu:22.04") + end + + it "raises when there is no FROM instruction" do + expect { described_class.parse("RUN echo hi", builder) } + .to raise_error(E2B::TemplateError, /must contain a FROM instruction/) + end + + it "raises on multi-stage Dockerfiles" do + dockerfile = <<~DOCKER + FROM ubuntu:22.04 AS build + RUN make + FROM ubuntu:22.04 + COPY --from=build /app /app + DOCKER + + expect { described_class.parse(dockerfile, builder) } + .to raise_error(E2B::TemplateError, /Multi-stage Dockerfiles are not supported/) + end + end + + describe ".parse user/workdir defaults" do + it "defaults user to 'user' and workdir to '/home/user' when not overridden" do + described_class.parse("FROM ubuntu", builder) + + # Parser primes root/'/' first, then applies defaults at the end. + expect(builder.calls_of(:set_user)).to eq([[:set_user, "root"], [:set_user, "user"]]) + expect(builder.calls_of(:set_workdir)).to eq([[:set_workdir, "/"], [:set_workdir, "/home/user"]]) + end + + it "does not re-apply defaults when USER and WORKDIR are present" do + dockerfile = <<~DOCKER + FROM ubuntu + USER app + WORKDIR /srv + DOCKER + + described_class.parse(dockerfile, builder) + + expect(builder.calls_of(:set_user)).to eq([[:set_user, "root"], [:set_user, "app"]]) + expect(builder.calls_of(:set_workdir)).to eq([[:set_workdir, "/"], [:set_workdir, "/srv"]]) + end + end + + describe ".parse RUN handling" do + it "collapses internal whitespace and joins line continuations" do + dockerfile = <<~DOCKER + FROM ubuntu + RUN apt-get update && \\ + apt-get install -y curl + DOCKER + + described_class.parse(dockerfile, builder) + + expect(builder.calls_of(:run_cmd)).to eq([[:run_cmd, "apt-get update && apt-get install -y curl"]]) + end + + it "ignores comment and blank lines" do + dockerfile = <<~DOCKER + FROM ubuntu + # a comment + RUN echo hi + + DOCKER + + described_class.parse(dockerfile, builder) + + expect(builder.calls_of(:run_cmd)).to eq([[:run_cmd, "echo hi"]]) + end + end + + describe ".parse COPY/ADD handling" do + it "extracts src, dest and a --chown owner" do + described_class.parse("FROM ubuntu\nCOPY --chown=app:app ./src /app", builder) + + expect(builder.calls_of(:copy)).to eq([[:copy, "./src", "/app", { user: "app:app" }]]) + end + + it "treats ADD the same as COPY and leaves user nil without --chown" do + described_class.parse("FROM ubuntu\nADD file.txt /dest/file.txt", builder) + + expect(builder.calls_of(:copy)).to eq([[:copy, "file.txt", "/dest/file.txt", { user: nil }]]) + end + + it "skips COPY instructions without at least a src and dest" do + described_class.parse("FROM ubuntu\nCOPY onlyone", builder) + + expect(builder.calls_of(:copy)).to be_empty + end + end + + describe ".parse ENV/ARG handling" do + it "parses `ENV KEY value` (space form)" do + described_class.parse("FROM ubuntu\nENV FOO bar", builder) + + expect(builder.calls_of(:set_envs)).to eq([[:set_envs, { "FOO" => "bar" }]]) + end + + it "parses multiple `KEY=value` pairs on one line" do + described_class.parse("FROM ubuntu\nENV A=1 B=2", builder) + + expect(builder.calls_of(:set_envs)).to eq([[:set_envs, { "A" => "1", "B" => "2" }]]) + end + + it "treats a bare ARG name as an empty-valued env" do + described_class.parse("FROM ubuntu\nARG VERSION", builder) + + expect(builder.calls_of(:set_envs)).to eq([[:set_envs, { "VERSION" => "" }]]) + end + + it "does not emit envs for a bare ENV name" do + described_class.parse("FROM ubuntu\nENV LONELY", builder) + + expect(builder.calls_of(:set_envs)).to be_empty + end + end + + describe ".parse CMD/ENTRYPOINT handling" do + it "joins a JSON exec-form array into a single command" do + described_class.parse(%(FROM ubuntu\nCMD ["nginx", "-g", "daemon off;"]), builder) + + start_cmds = builder.calls_of(:set_start_cmd) + expect(start_cmds.length).to eq(1) + expect(start_cmds.first[1]).to eq("nginx -g daemon off;") + end + + it "passes a shell-form command through verbatim" do + described_class.parse("FROM ubuntu\nENTRYPOINT /usr/bin/start.sh", builder) + + expect(builder.calls_of(:set_start_cmd).first[1]).to eq("/usr/bin/start.sh") + end + end + + describe ".read_dockerfile" do + it "reads contents when given a real file path" do + Tempfile.create(["Dockerfile", ""]) do |f| + f.write("FROM alpine") + f.flush + expect(described_class.read_dockerfile(f.path)).to eq("FROM alpine") + end + end + + it "returns the input unchanged when it is inline content, not a path" do + expect(described_class.read_dockerfile("FROM alpine\nRUN echo hi")) + .to eq("FROM alpine\nRUN echo hi") + end + end +end diff --git a/spec/e2b/errors_spec.rb b/spec/e2b/errors_spec.rb new file mode 100644 index 0000000..6b5adac --- /dev/null +++ b/spec/e2b/errors_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe E2B::E2BError do + it "carries an optional status code and headers" do + error = described_class.new("boom", status_code: 500, headers: { "x" => "y" }) + expect(error.message).to eq("boom") + expect(error.status_code).to eq(500) + expect(error.headers).to eq("x" => "y") + end + + it "defaults headers to an empty hash even when passed nil" do + expect(described_class.new("boom", headers: nil).headers).to eq({}) + end + + it "aliases SandboxError to E2BError" do + expect(E2B::SandboxError).to be(described_class) + end + + describe "the error hierarchy" do + it "roots HTTP and domain errors at E2BError" do + [E2B::NotFoundError, E2B::RateLimitError, E2B::AuthenticationError, + E2B::ConfigurationError, E2B::ConflictError, E2B::TimeoutError, + E2B::InvalidArgumentError, E2B::SandboxStateError].each do |klass| + expect(klass.ancestors).to include(described_class) + end + end + + it "nests git and build error subclasses correctly" do + expect(E2B::GitAuthError.ancestors).to include(E2B::AuthenticationError) + expect(E2B::FileUploadError.ancestors).to include(E2B::BuildError) + end + end + + describe E2B::CommandExitError do + it "builds a message from exit code, error and stderr" do + error = described_class.new(stdout: "out", stderr: "bad", exit_code: 2, error: "failure") + expect(error.exit_code).to eq(2) + expect(error.stdout).to eq("out") + expect(error.stderr).to eq("bad") + expect(error.command_error).to eq("failure") + expect(error.message).to include("Command exited with code 2") + expect(error.message).to include("failure") + expect(error.message).to include("Stderr: bad") + end + + it "omits the stderr line when stderr is empty" do + expect(described_class.new(exit_code: 1).message).not_to include("Stderr:") + end + + it "is never successful" do + expect(described_class.new(exit_code: 0)).not_to be_success + end + end + + describe E2B::TemplateError do + it "records a source location and sets the backtrace from it" do + error = described_class.new("bad template", source_location: "Dockerfile:3") + expect(error.source_location).to eq("Dockerfile:3") + expect(error.backtrace).to eq(["Dockerfile:3"]) + end + end + + describe E2B::BuildError do + it "records the failing step and source location" do + error = described_class.new("build failed", step: "RUN make", source_location: "step:2") + expect(error.step).to eq("RUN make") + expect(error.source_location).to eq("step:2") + expect(error.backtrace).to eq(["step:2"]) + end + end +end diff --git a/spec/e2b/models/entry_info_spec.rb b/spec/e2b/models/entry_info_spec.rb new file mode 100644 index 0000000..705d594 --- /dev/null +++ b/spec/e2b/models/entry_info_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "spec_helper" +require "time" + +# EntryInfo and FilesystemEvent are sibling models defined in entry_info.rb. +RSpec.describe E2B::Models do + describe E2B::Models::EntryInfo do + describe ".from_hash" do + it "returns nil for non-Hash input" do + expect(described_class.from_hash("nope")).to be_nil + end + + it "maps numeric protobuf file types to constants" do + expect(described_class.from_hash("type" => 1).type).to eq(E2B::Models::FileType::FILE) + expect(described_class.from_hash("type" => 2).type).to eq(E2B::Models::FileType::DIRECTORY) + end + + it "maps string enum names to constants" do + expect(described_class.from_hash("type" => "FILE_TYPE_DIRECTORY").type) + .to eq(E2B::Models::FileType::DIRECTORY) + end + + it "reads camelCase and snake_case keys and coerces numeric fields" do + entry = described_class.from_hash( + "name" => "f.txt", + "type" => 1, + "path" => "/home/user/f.txt", + "size" => "13", + "mode" => "420", + "permissions" => "0644", + "owner" => "user", + "group" => "user", + "symlinkTarget" => "/elsewhere" + ) + + expect(entry.name).to eq("f.txt") + expect(entry.size).to eq(13) + expect(entry.mode).to eq(420) + expect(entry.symlink_target).to eq("/elsewhere") + expect(entry.file?).to be(true) + expect(entry.directory?).to be(false) + end + + it "parses a protobuf Timestamp hash for modified_time" do + seconds = 1_700_000_000 + entry = described_class.from_hash("type" => 1, "modifiedTime" => { "seconds" => seconds }) + expect(entry.modified_time).to eq(Time.at(seconds)) + end + + it "parses an ISO string modified_time" do + entry = described_class.from_hash("type" => 1, "modified_time" => "2026-01-01T00:00:00Z") + expect(entry.modified_time).to be_a(Time) + end + end + end + + describe E2B::Models::FilesystemEvent do + describe ".from_hash" do + it "maps numeric event types to constants" do + expect(described_class.from_hash("name" => "a", "type" => 1).type) + .to eq(E2B::Models::FilesystemEventType::CREATE) + expect(described_class.from_hash("name" => "a", "type" => 3).type) + .to eq(E2B::Models::FilesystemEventType::REMOVE) + end + + it "maps string enum names to constants and reads the name" do + event = described_class.from_hash("name" => "b.txt", "type" => "EVENT_TYPE_WRITE") + expect(event.name).to eq("b.txt") + expect(event.type).to eq(E2B::Models::FilesystemEventType::WRITE) + end + + it "falls back to the raw value string for unknown types" do + expect(described_class.from_hash("name" => "a", "type" => "WEIRD").type).to eq("WEIRD") + end + end + end +end diff --git a/spec/e2b/models/process_result_spec.rb b/spec/e2b/models/process_result_spec.rb new file mode 100644 index 0000000..95d8a72 --- /dev/null +++ b/spec/e2b/models/process_result_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "spec_helper" +require "base64" + +RSpec.describe E2B::Models::ProcessResult do + describe ".from_hash" do + it "reads camelCase, snake_case and symbol keys with sensible defaults" do + result = described_class.from_hash("stdout" => "out", "stderr" => "err", "exitCode" => 3) + expect([result.stdout, result.stderr, result.exit_code]).to eq(["out", "err", 3]) + + symbol_result = described_class.from_hash(stdout: "o", exitCode: 5) + expect(symbol_result.exit_code).to eq(5) + + expect(described_class.from_hash({}).exit_code).to eq(0) + end + end + + describe ".from_connect_response" do + it "extracts accumulated stdout/stderr/exit_code from a flat response" do + result = described_class.from_connect_response(stdout: "hi", exit_code: 0) + expect(result.stdout).to eq("hi") + expect(result).to be_success + end + + it "decodes and accumulates output from streaming events when stdout is empty" do + data = { + stdout: "", + events: [ + { "event" => { "Stdout" => { "data" => Base64.strict_encode64("hello ") } } }, + { "event" => { "stdout" => { "data" => Base64.strict_encode64("world") } } }, + { "event" => { "Stderr" => { "data" => Base64.strict_encode64("oops") } } }, + { "event" => { "Exit" => { "exitCode" => 7 } } } + ] + } + + result = described_class.from_connect_response(data) + expect(result.stdout).to eq("hello world") + expect(result.stderr).to eq("oops") + expect(result.exit_code).to eq(7) + end + + it "returns an empty, successful result for non-Hash input" do + [nil, "oops", [1, 2]].each do |bad| + result = described_class.from_connect_response(bad) + expect(result).to be_a(described_class) + expect(result.stdout).to eq("") + expect(result.exit_code).to eq(0) + expect(result).to be_success + end + end + end + + describe ".parse_exit_code" do + it "handles nil, integers, numeric strings and 'exit status N'" do + expect(described_class.parse_exit_code(nil)).to eq(0) + expect(described_class.parse_exit_code(42)).to eq(42) + expect(described_class.parse_exit_code("13")).to eq(13) + expect(described_class.parse_exit_code("exit status 137")).to eq(137) + end + end + + describe "#success? and #output" do + it "is successful only with exit code 0 and no error" do + expect(described_class.new(exit_code: 0)).to be_success + expect(described_class.new(exit_code: 1)).not_to be_success + expect(described_class.new(exit_code: 0, error: "boom")).not_to be_success + end + + it "concatenates stdout and stderr for #output and aliases #result to stdout" do + result = described_class.new(stdout: "a", stderr: "b") + expect(result.output).to eq("ab") + expect(result.result).to eq("a") + end + end +end diff --git a/spec/e2b/models/sandbox_info_spec.rb b/spec/e2b/models/sandbox_info_spec.rb new file mode 100644 index 0000000..05f5fa9 --- /dev/null +++ b/spec/e2b/models/sandbox_info_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "spec_helper" +require "time" + +RSpec.describe E2B::Models::SandboxInfo do + describe ".from_hash" do + it "maps camelCase API keys and parses timestamps" do + info = described_class.from_hash( + "sandboxID" => "sbx_1", + "templateID" => "tmpl_1", + "alias" => "my-box", + "clientID" => "client_1", + "startedAt" => "2026-01-01T00:00:00Z", + "endAt" => "2026-01-01T01:00:00Z", + "cpuCount" => 2, + "memoryMB" => 512, + "metadata" => { "k" => "v" }, + "state" => "running", + "domain" => "e2b.app", + "envdVersion" => "0.4.0" + ) + + expect(info.sandbox_id).to eq("sbx_1") + expect(info.template_id).to eq("tmpl_1") + expect(info.alias_name).to eq("my-box") + expect(info.started_at).to be_a(Time) + expect(info.cpu_count).to eq(2) + expect(info.metadata).to eq("k" => "v") + expect(info.envd_version).to eq("0.4.0") + end + + it "defaults metadata to an empty hash" do + expect(described_class.from_hash("sandboxID" => "x").metadata).to eq({}) + end + end + + describe "#running?" do + it "is false when state is paused" do + info = described_class.new(sandbox_id: "x", template_id: "t", state: "paused") + expect(info.running?).to be(false) + end + + it "is true when there is no end time" do + info = described_class.new(sandbox_id: "x", template_id: "t") + expect(info.running?).to be(true) + end + + it "is false once end_at is in the past" do + info = described_class.new(sandbox_id: "x", template_id: "t", end_at: Time.now - 60) + expect(info.running?).to be(false) + end + end + + describe "#time_remaining" do + it "returns 0 when expired or end_at is nil" do + expect(described_class.new(sandbox_id: "x", template_id: "t").time_remaining).to eq(0) + expect(described_class.new(sandbox_id: "x", template_id: "t", end_at: Time.now - 5).time_remaining).to eq(0) + end + + it "returns positive seconds when still running" do + info = described_class.new(sandbox_id: "x", template_id: "t", end_at: Time.now + 120) + expect(info.time_remaining).to be > 0 + end + end + + describe ".parse_time" do + it "passes Time through, parses strings, and returns nil on garbage" do + now = Time.now + expect(described_class.parse_time(now)).to eq(now) + expect(described_class.parse_time("2026-01-01T00:00:00Z")).to be_a(Time) + expect(described_class.parse_time("not-a-time")).to be_nil + expect(described_class.parse_time(nil)).to be_nil + end + end +end diff --git a/spec/e2b/models/template_models_spec.rb b/spec/e2b/models/template_models_spec.rb new file mode 100644 index 0000000..a80c838 --- /dev/null +++ b/spec/e2b/models/template_models_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "spec_helper" +require "time" + +# Template-related models, all defined under E2B::Models. +RSpec.describe E2B::Models do + describe E2B::Models::SnapshotInfo do + it "reads the snapshot id from camelCase, snake_case or symbol keys" do + expect(described_class.from_hash("snapshotID" => "snap_1").snapshot_id).to eq("snap_1") + expect(described_class.from_hash("snapshot_id" => "snap_2").snapshot_id).to eq("snap_2") + expect(described_class.from_hash(snapshotID: "snap_3").snapshot_id).to eq("snap_3") + end + end + + describe E2B::Models::TemplateTagInfo do + it "maps build id and defaults tags to an empty array" do + info = described_class.from_hash("buildID" => "b1", "tags" => %w[latest v1]) + expect(info.build_id).to eq("b1") + expect(info.tags).to eq(%w[latest v1]) + + expect(described_class.from_hash("build_id" => "b2").tags).to eq([]) + end + end + + describe E2B::Models::TemplateTag do + it "maps tag/build id and parses created_at" do + tag = described_class.from_hash("tag" => "latest", "buildID" => "b1", "createdAt" => "2026-01-01T00:00:00Z") + expect(tag.tag).to eq("latest") + expect(tag.build_id).to eq("b1") + expect(tag.created_at).to be_a(Time) + end + + it "returns nil created_at for unparseable timestamps" do + expect(described_class.from_hash("tag" => "t", "buildID" => "b", "createdAt" => "garbage").created_at).to be_nil + end + end + + describe E2B::Models::BuildInfo do + it "maps fields and defaults tags / build_step_origins to arrays" do + info = described_class.from_hash( + "alias" => "my-template", + "name" => "n", + "templateID" => "tmpl_1", + "buildID" => "b1" + ) + expect(info.alias_name).to eq("my-template") + expect(info.template_id).to eq("tmpl_1") + expect(info.tags).to eq([]) + expect(info.build_step_origins).to eq([]) + end + + it "compacts nil entries out of build_step_origins" do + info = described_class.from_hash("buildStepOrigins" => [1, nil, 2]) + expect(info.build_step_origins).to eq([1, 2]) + end + end + + describe E2B::Models::TemplateLogEntry do + it "maps level/message, parses the timestamp and strips ANSI codes" do + entry = described_class.from_hash( + "timestamp" => "2026-01-01T00:00:00Z", + "level" => "info", + "message" => "\e[31mred\e[0m text" + ) + expect(entry.level).to eq("info") + expect(entry.message).to eq("red text") + expect(entry.timestamp).to be_a(Time) + end + + it "renders to_s with timestamp, level and message" do + entry = described_class.new(timestamp: Time.utc(2026, 1, 1), level: "warn", message: "hi") + expect(entry.to_s).to include("[warn]").and include("hi") + end + + describe "Start/End subclasses" do + it "default to debug level" do + expect(E2B::Models::TemplateLogEntryStart.new(timestamp: nil, message: "start").level).to eq("debug") + expect(E2B::Models::TemplateLogEntryEnd.new(timestamp: nil, message: "end").level).to eq("debug") + end + end + end + + describe E2B::Models::BuildStatusReason do + it "returns nil when data is nil" do + expect(described_class.from_hash(nil)).to be_nil + end + + it "maps message/step and builds nested log entries" do + reason = described_class.from_hash( + "message" => "build failed", + "step" => "RUN make", + "logEntries" => [{ "level" => "error", "message" => "boom" }] + ) + expect(reason.message).to eq("build failed") + expect(reason.step).to eq("RUN make") + expect(reason.log_entries.first).to be_a(E2B::Models::TemplateLogEntry) + expect(reason.log_entries.first.message).to eq("boom") + end + end + + describe E2B::Models::TemplateBuildStatusResponse do + it "maps fields, nested log entries and a nested reason" do + response = described_class.from_hash( + "buildID" => "b1", + "templateID" => "tmpl_1", + "status" => "error", + "logEntries" => [{ "level" => "info", "message" => "step 1" }], + "logs" => ["raw log"], + "reason" => { "message" => "nope" } + ) + expect(response.build_id).to eq("b1") + expect(response.status).to eq("error") + expect(response.log_entries.first.message).to eq("step 1") + expect(response.logs).to eq(["raw log"]) + expect(response.reason).to be_a(E2B::Models::BuildStatusReason) + expect(response.reason.message).to eq("nope") + end + + it "leaves reason nil when absent" do + expect(described_class.from_hash("buildID" => "b1", "status" => "building").reason).to be_nil + end + end +end diff --git a/spec/e2b/paginator_spec.rb b/spec/e2b/paginator_spec.rb new file mode 100644 index 0000000..9a17e41 --- /dev/null +++ b/spec/e2b/paginator_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe E2B::BasePaginator do + it "fetches pages, advances the token, and stops when the token is blank" do + pages = [ + [%w[a b], "token-2"], + [%w[c d], ""] + ] + paginator = described_class.new(limit: 2) do |limit:, next_token:| + expect(limit).to eq(2) + pages.shift.tap { |page| expect(page).not_to(be_nil, "fetched more pages than expected (next_token=#{next_token})") } + end + + expect(paginator.has_next?).to be(true) + expect(paginator.next_items).to eq(%w[a b]) + expect(paginator.next_token).to eq("token-2") + expect(paginator.has_next?).to be(true) + + expect(paginator.next_items).to eq(%w[c d]) + expect(paginator.has_next?).to be(false) + end + + it "raises once exhausted" do + paginator = described_class.new(limit: 1) { |**| [[], nil] } + paginator.next_items + expect { paginator.next_items }.to raise_error(E2B::E2BError, /No more items/) + end + + describe E2B::SandboxPaginator do + let(:response) do + instance_double( + E2B::API::HttpClient::DetailedResponse, + body: { "sandboxes" => [{ "sandboxID" => "sbx_1" }] }, + headers: { "x-next-token" => "next-1" } + ) + end + let(:http_client) { instance_double(E2B::API::HttpClient) } + + it "builds SandboxInfo objects and reads the next token from headers" do + expect(http_client).to receive(:get) + .with("/v2/sandboxes", params: { limit: 100 }, detailed: true) + .and_return(response) + + paginator = described_class.new(http_client: http_client) + items = paginator.next_items + + expect(items.first).to be_a(E2B::Models::SandboxInfo) + expect(items.first.sandbox_id).to eq("sbx_1") + expect(paginator.next_token).to eq("next-1") + end + + it "encodes metadata and state query params" do + expect(http_client).to receive(:get) do |_path, params:, detailed:| + expect(detailed).to be(true) + expect(params[:metadata]).to eq("env=prod") + expect(params[:state]).to eq(["running"]) + response + end + + described_class.new( + http_client: http_client, + query: { metadata: { env: "prod" }, state: "running" } + ).next_items + end + + it "handles a bare-array response body" do + array_response = instance_double( + E2B::API::HttpClient::DetailedResponse, + body: [{ "sandboxID" => "sbx_2" }], + headers: {} + ) + allow(http_client).to receive(:get).and_return(array_response) + + items = described_class.new(http_client: http_client).next_items + expect(items.first.sandbox_id).to eq("sbx_2") + end + + describe ".encode_metadata" do + it "encodes metadata pairs into a query string" do + expect(described_class.encode_metadata({ "env" => "prod" })).to eq("env=prod") + expect(described_class.encode_metadata({ env: "prod", tier: "free" })).to eq("env=prod&tier=free") + end + end + end + + describe E2B::SnapshotPaginator do + it "builds SnapshotInfo objects and passes the sandbox id filter" do + response = instance_double( + E2B::API::HttpClient::DetailedResponse, + body: [{ "snapshotID" => "snap_1" }], + headers: { "x-next-token" => nil } + ) + http_client = instance_double(E2B::API::HttpClient) + + expect(http_client).to receive(:get) + .with("/snapshots", params: { limit: 100, sandboxID: "sbx_1" }, detailed: true) + .and_return(response) + + paginator = described_class.new(http_client: http_client, sandbox_id: "sbx_1") + items = paginator.next_items + + expect(items.first).to be_a(E2B::Models::SnapshotInfo) + expect(items.first.snapshot_id).to eq("snap_1") + expect(paginator.has_next?).to be(false) + end + end +end diff --git a/spec/e2b/ready_cmd_spec.rb b/spec/e2b/ready_cmd_spec.rb new file mode 100644 index 0000000..ec13a82 --- /dev/null +++ b/spec/e2b/ready_cmd_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe E2B::ReadyCmd do + it "exposes the wrapped command via get_cmd" do + expect(described_class.new("echo hi").get_cmd).to eq("echo hi") + end + + describe "E2B ready command helpers" do + it "builds a port-listening check" do + expect(E2B.wait_for_port(8080).get_cmd).to eq("ss -tuln | grep :8080") + end + + it "builds a URL status-code check" do + cmd = E2B.wait_for_url("http://localhost:3000").get_cmd + expect(cmd).to include("curl").and include("http://localhost:3000").and include('grep -q "200"') + end + + it "allows a custom expected status code" do + expect(E2B.wait_for_url("http://x", 204).get_cmd).to include('grep -q "204"') + end + + it "builds a process check" do + expect(E2B.wait_for_process("nginx").get_cmd).to eq("pgrep nginx > /dev/null") + end + + it "builds a file-existence check" do + expect(E2B.wait_for_file("/tmp/ready").get_cmd).to eq("[ -f /tmp/ready ]") + end + + describe ".wait_for_timeout" do + it "converts milliseconds to whole seconds" do + expect(E2B.wait_for_timeout(5000).get_cmd).to eq("sleep 5") + end + + it "clamps to a minimum of one second" do + expect(E2B.wait_for_timeout(100).get_cmd).to eq("sleep 1") + end + end + end +end diff --git a/spec/e2b/sandbox_helpers_spec.rb b/spec/e2b/sandbox_helpers_spec.rb new file mode 100644 index 0000000..975a0cb --- /dev/null +++ b/spec/e2b/sandbox_helpers_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe E2B::SandboxHelpers do + # The helpers are private instance methods shared between Sandbox and Client. + # This host exposes them for direct unit testing. + let(:host_class) do + Class.new do + include E2B::SandboxHelpers + + # Re-export the private helpers as public for the specs. + public(*E2B::SandboxHelpers.private_instance_methods) + end + end + let(:host) { host_class.new } + + around do |example| + saved = ENV.to_hash + %w[E2B_API_KEY E2B_ACCESS_TOKEN E2B_DOMAIN E2B_API_URL].each { |k| ENV.delete(k) } + example.run + ensure + ENV.replace(saved) + end + + describe "#resolved_template" do + it "returns an explicitly provided template unchanged" do + expect(host.resolved_template("custom", mcp: false)).to eq("custom") + end + + it "returns the MCP default template when mcp is requested and none given" do + expect(host.resolved_template(nil, mcp: true)).to eq(E2B::Sandbox::DEFAULT_MCP_TEMPLATE) + end + + it "falls back to the configured default, then 'base'" do + expect(host.resolved_template(nil, mcp: false)).to eq("base") + + E2B.configure { |c| c.default_template = "configured" } + expect(host.resolved_template("", mcp: false)).to eq("configured") + end + end + + describe "#normalized_lifecycle" do + it "defaults to kill-on-timeout with no auto-resume" do + expect(host.normalized_lifecycle(lifecycle: nil, auto_pause: false)) + .to eq(on_timeout: "kill", auto_resume: false) + end + + it "uses pause-on-timeout when auto_pause is set" do + expect(host.normalized_lifecycle(lifecycle: nil, auto_pause: true)) + .to eq(on_timeout: "pause", auto_resume: false) + end + + it "honours an explicit lifecycle hash and keeps auto_resume only for pause" do + expect(host.normalized_lifecycle(lifecycle: { on_timeout: "pause", auto_resume: true }, auto_pause: false)) + .to eq(on_timeout: "pause", auto_resume: true) + + # auto_resume is forced false when the sandbox is killed on timeout + expect(host.normalized_lifecycle(lifecycle: { on_timeout: "kill", auto_resume: true }, auto_pause: false)) + .to eq(on_timeout: "kill", auto_resume: false) + end + + it "raises on an invalid on_timeout value" do + expect { host.normalized_lifecycle(lifecycle: { on_timeout: "explode" }, auto_pause: false) } + .to raise_error(ArgumentError, /must be 'kill' or 'pause'/) + end + end + + describe "#resolve_credentials" do + it "raises when no credentials can be resolved" do + expect { host.resolve_credentials(api_key: nil, access_token: nil) } + .to raise_error(E2B::ConfigurationError, /credentials are required/) + end + + it "prefers explicit arguments" do + expect(host.resolve_credentials(api_key: "explicit", access_token: nil)) + .to eq(api_key: "explicit", access_token: nil) + end + + it "falls back to configuration then environment" do + E2B.configure { |c| c.api_key = "config-key" } + expect(host.resolve_credentials(api_key: nil, access_token: nil)[:api_key]).to eq("config-key") + + E2B.reset_configuration! + ENV["E2B_ACCESS_TOKEN"] = "env-token" + expect(host.resolve_credentials(api_key: nil, access_token: nil)[:access_token]).to eq("env-token") + end + end + + describe "#resolve_domain" do + it "prefers argument, then config, then env, then default" do + expect(host.resolve_domain("arg.dev")).to eq("arg.dev") + + E2B.configure { |c| c.domain = "config.dev" } + expect(host.resolve_domain(nil)).to eq("config.dev") + + E2B.reset_configuration! + ENV["E2B_DOMAIN"] = "env.dev" + expect(host.resolve_domain(nil)).to eq("env.dev") + + ENV.delete("E2B_DOMAIN") + expect(host.resolve_domain(nil)).to eq(E2B::Configuration::DEFAULT_DOMAIN) + end + end + + describe "#ensure_supported_envd_version!" do + let(:http_client) { instance_double(E2B::API::HttpClient) } + + it "does nothing when no envd version is reported" do + expect { host.ensure_supported_envd_version!({}, http_client) }.not_to raise_error + end + + it "does nothing for a supported envd version" do + expect { host.ensure_supported_envd_version!({ "envdVersion" => "0.2.0" }, http_client) } + .not_to raise_error + end + + it "deletes the sandbox and raises for an unsupported (old) envd version" do + expect(http_client).to receive(:delete).with("/sandboxes/sbx_old") + + expect do + host.ensure_supported_envd_version!( + { "envdVersion" => "0.0.1", "sandboxID" => "sbx_old" }, http_client + ) + end.to raise_error(E2B::TemplateError, /update the template/) + end + + it "tolerates a NotFoundError while cleaning up the sandbox" do + allow(http_client).to receive(:delete).and_raise(E2B::NotFoundError, "gone") + + expect do + host.ensure_supported_envd_version!( + { "envdVersion" => "0.0.1", "sandboxID" => "sbx_old" }, http_client + ) + end.to raise_error(E2B::TemplateError) + end + end + + describe "#build_http_client" do + it "constructs an API::HttpClient pointed at the domain-derived URL" do + client = host.build_http_client(api_key: "k", access_token: nil, domain: "e2b.app") + expect(client).to be_a(E2B::API::HttpClient) + end + end +end diff --git a/spec/e2b/services/base_service_spec.rb b/spec/e2b/services/base_service_spec.rb new file mode 100644 index 0000000..95733ae --- /dev/null +++ b/spec/e2b/services/base_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "spec_helper" +require "base64" + +RSpec.describe E2B::Services::BaseService do + # Subclass that exposes the protected auth/version helpers for unit testing. + let(:service_class) do + Class.new(described_class) do + def auth_headers(user) = user_auth_headers(user) + def username(user) = resolve_username(user) + def legacy_user? = legacy_default_user? + def recursive_watch? = supports_recursive_watch? + end + end + + def build_service(envd_version: nil) + service_class.new( + sandbox_id: "sbx_1", + sandbox_domain: "e2b.app", + api_key: "key", + envd_version: envd_version + ) + end + + describe "#user_auth_headers" do + it "builds a Basic auth header for an explicit user" do + headers = build_service.auth_headers("alice") + expect(headers).to eq("Authorization" => "Basic #{Base64.strict_encode64("alice:")}") + end + + it "returns nil for a modern envd when no user is given" do + expect(build_service(envd_version: "0.4.0").auth_headers(nil)).to be_nil + end + + it "uses the default 'user' for a legacy envd when no user is given" do + headers = build_service(envd_version: "0.3.9").auth_headers(nil) + expect(headers).to eq("Authorization" => "Basic #{Base64.strict_encode64("user:")}") + end + end + + describe "#resolve_username" do + it "returns the given user, the legacy default, or nil" do + expect(build_service.username("bob")).to eq("bob") + expect(build_service(envd_version: "0.3.0").username(nil)).to eq("user") + expect(build_service(envd_version: "0.4.0").username(nil)).to be_nil + end + end + + describe "#legacy_default_user?" do + it "is false without a version, true below 0.4.0, false at/above" do + expect(build_service.legacy_user?).to be(false) + expect(build_service(envd_version: "0.3.9").legacy_user?).to be(true) + expect(build_service(envd_version: "0.4.0").legacy_user?).to be(false) + end + + it "is false for an unparseable version" do + expect(build_service(envd_version: "not-a-version").legacy_user?).to be(false) + end + end + + describe "#supports_recursive_watch?" do + it "is true without a version, false below 0.1.4, true at/above" do + expect(build_service.recursive_watch?).to be(true) + expect(build_service(envd_version: "0.1.3").recursive_watch?).to be(false) + expect(build_service(envd_version: "0.1.4").recursive_watch?).to be(true) + end + + it "is true (permissive) for an unparseable version" do + expect(build_service(envd_version: "weird").recursive_watch?).to be(true) + end + end +end diff --git a/spec/e2b/services/command_handle_spec.rb b/spec/e2b/services/command_handle_spec.rb index ceb8053..f7e6e99 100644 --- a/spec/e2b/services/command_handle_spec.rb +++ b/spec/e2b/services/command_handle_spec.rb @@ -63,4 +63,132 @@ def command_handle_end_event(exit_code:, error: nil) producer.join end + + describe "delegation to handle procs" do + it "forwards kill and send_stdin to their procs" do + sent = [] + handle = described_class.new( + pid: 7, + handle_kill: -> { :killed }, + handle_send_stdin: ->(data) { sent << data }, + events_proc: ->(&_block) {} + ) + + expect(handle.pid).to eq(7) + expect(handle.kill).to eq(:killed) + handle.send_stdin("input\n") + expect(sent).to eq(["input\n"]) + end + end + + describe "materialized (synchronous) result" do + def materialized_handle(result) + described_class.new( + pid: 1, + handle_kill: -> { true }, + handle_send_stdin: ->(_d) {}, + result: result + ) + end + + it "iterates pre-materialized events separating stdout, stderr and pty" do + handle = materialized_handle( + events: [ + command_handle_start_event(1), + command_handle_data_event(stdout: "out"), + command_handle_data_event(stderr: "err"), + command_handle_data_event(pty: "tty"), + command_handle_end_event(exit_code: 0) + ] + ) + + chunks = [] + handle.each { |stdout, stderr, pty| chunks << [stdout, stderr, pty] } + + expect(chunks).to eq([["out", nil, nil], [nil, "err", nil], [nil, nil, "tty"]]) + end + + it "returns a successful CommandResult and invokes output callbacks" do + handle = materialized_handle( + events: [ + command_handle_data_event(stdout: "hello "), + command_handle_data_event(stdout: "world"), + command_handle_end_event(exit_code: 0) + ] + ) + + streamed = [] + result = handle.wait(on_stdout: ->(chunk) { streamed << chunk }) + + expect(result).to be_a(E2B::Services::CommandResult) + expect(result.stdout).to eq("hello world") + expect(result.exit_code).to eq(0) + expect(result).to be_success + expect(streamed).to eq(["hello ", "world"]) + end + + it "raises CommandExitError with captured output on a non-zero exit" do + handle = materialized_handle( + events: [ + command_handle_data_event(stdout: "partial"), + command_handle_data_event(stderr: "boom"), + command_handle_end_event(exit_code: 2, error: "command failed") + ] + ) + + expect { handle.wait }.to raise_error(E2B::CommandExitError) do |error| + expect(error.stdout).to eq("partial") + expect(error.stderr).to eq("boom") + expect(error.exit_code).to eq(2) + expect(error.command_error).to eq("command failed") + end + end + + it "parses an 'exit status N' end event into an integer exit code" do + handle = materialized_handle( + events: [command_handle_end_event(exit_code: "exit status 137")] + ) + + expect { handle.wait }.to raise_error(E2B::CommandExitError) do |error| + expect(error.exit_code).to eq(137) + end + end + + it "falls back to the result hash stdout/exit_code when events are not iterated" do + handle = materialized_handle(stdout: "cached", stderr: "", exit_code: 0, events: []) + expect(handle.wait.stdout).to eq("cached") + end + end + + describe "#disconnect" do + it "stops streaming iteration and lets wait return accumulated output" do + handle = described_class.new( + pid: 9, + handle_kill: -> { true }, + handle_send_stdin: ->(_d) {}, + handle_disconnect: -> { stream.close(discard_pending: true) }, + events_proc: ->(&block) { stream.each(&block) } + ) + + producer = Thread.new do + stream.push(command_handle_start_event(9)) + stream.push(command_handle_data_event(stdout: "before disconnect")) + # Keep the stream open; disconnect should be what ends iteration. + sleep 0.05 + stream.close + end + + received = nil + handle.each do |stdout, _stderr, _pty| + received = stdout if stdout + handle.disconnect + end + + result = handle.wait + expect(received).to eq("before disconnect") + expect(result.stdout).to eq("before disconnect") + + producer.join + end + end end diff --git a/spec/e2b/services/envd_http_client_spec.rb b/spec/e2b/services/envd_http_client_spec.rb index aa06e4e..eb89767 100644 --- a/spec/e2b/services/envd_http_client_spec.rb +++ b/spec/e2b/services/envd_http_client_spec.rb @@ -98,4 +98,45 @@ expect(messages).to eq([body]) end end + + def rpc_response(status:, body:, headers: {}) + described_class::RpcResponse.new(status: status, body: body, headers: headers) + end + + describe "RpcResponse#success?" do + it "is true for 2xx and false otherwise" do + expect(rpc_response(status: 204, body: nil).success?).to be(true) + expect(rpc_response(status: 500, body: nil).success?).to be(false) + end + end + + describe "#handle_error mapping" do + it "maps status codes to the matching error classes" do + { + 401 => E2B::AuthenticationError, + 403 => E2B::AuthenticationError, + 404 => E2B::NotFoundError, + 429 => E2B::RateLimitError, + 500 => E2B::E2BError + }.each do |status, klass| + expect { client.send(:handle_error, rpc_response(status: status, body: {})) } + .to raise_error(klass) { |error| expect(error.status_code).to eq(status) } + end + end + + it "extracts the message from a JSON body and carries headers" do + resp = rpc_response(status: 404, body: { "message" => "missing sandbox" }, headers: { "x" => "y" }) + expect { client.send(:handle_error, resp) } + .to raise_error(E2B::NotFoundError, "missing sandbox") { |error| expect(error.headers).to eq("x" => "y") } + end + end + + describe "#extract_error_message" do + it "prefers message, then error, then a string body, then a generic fallback" do + expect(client.send(:extract_error_message, rpc_response(status: 400, body: { "message" => "m" }))).to eq("m") + expect(client.send(:extract_error_message, rpc_response(status: 400, body: { "error" => "e" }))).to eq("e") + expect(client.send(:extract_error_message, rpc_response(status: 400, body: "raw"))).to eq("raw") + expect(client.send(:extract_error_message, rpc_response(status: 400, body: nil))).to eq("HTTP 400 error") + end + end end diff --git a/spec/e2b/services/live_streamable_spec.rb b/spec/e2b/services/live_streamable_spec.rb new file mode 100644 index 0000000..e69e163 --- /dev/null +++ b/spec/e2b/services/live_streamable_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe E2B::Services::LiveStreamable do + # build_live_handle is exercised end-to-end through Commands#run/#connect. + # These specs pin the helper's event-parsing edge cases directly. + let(:host_class) do + Class.new do + include E2B::Services::LiveStreamable + + public(*E2B::Services::LiveStreamable.private_instance_methods) + end + end + let(:host) { host_class.new } + + describe "#extract_pid_from_event" do + it "extracts the pid from a Start event (PascalCase)" do + expect(host.extract_pid_from_event("event" => { "Start" => { "pid" => 99 } })).to eq(99) + end + + it "extracts the pid from a start event (lowercase)" do + expect(host.extract_pid_from_event("event" => { "start" => { "pid" => "42" } })).to eq(42) + end + + it "returns nil for non-start events and malformed input" do + expect(host.extract_pid_from_event("event" => { "Data" => {} })).to be_nil + expect(host.extract_pid_from_event("event" => {})).to be_nil + expect(host.extract_pid_from_event({})).to be_nil + expect(host.extract_pid_from_event(nil)).to be_nil + expect(host.extract_pid_from_event("not a hash")).to be_nil + end + end + + describe "#disconnect_live_stream" do + it "closes the stream discarding pending events and kills a live thread" do + stream = E2B::Services::LiveEventStream.new + thread = instance_double(Thread, alive?: true) + allow(thread).to receive(:kill) + + host.disconnect_live_stream(thread, stream) + + expect(thread).to have_received(:kill) + # A closed stream yields nothing further. + expect(stream.each.to_a).to be_empty + end + + it "does not kill a thread that has already finished" do + stream = E2B::Services::LiveEventStream.new + thread = instance_double(Thread, alive?: false) + + expect { host.disconnect_live_stream(thread, stream) }.not_to raise_error + end + end +end diff --git a/spec/e2b/services/watch_handle_spec.rb b/spec/e2b/services/watch_handle_spec.rb new file mode 100644 index 0000000..b6293e2 --- /dev/null +++ b/spec/e2b/services/watch_handle_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe E2B::Services::WatchHandle do + let(:rpc_calls) { [] } + let(:rpc_response) { { "events" => [] } } + let(:envd_rpc_proc) do + lambda do |service, method, body:, headers:| + rpc_calls << { service: service, method: method, body: body, headers: headers } + rpc_response + end + end + let(:handle) do + described_class.new(watcher_id: "w1", envd_rpc_proc: envd_rpc_proc, headers: { "Authorization" => "Basic x" }) + end + + describe "#get_new_events" do + it "polls GetWatcherEvents and maps results to FilesystemEvent" do + stub_response = { "events" => [{ "name" => "a.txt", "type" => 1 }] } + allow(envd_rpc_proc).to receive(:call).and_return(stub_response) + + handle = described_class.new(watcher_id: "w1", envd_rpc_proc: envd_rpc_proc) + events = handle.get_new_events + + expect(events.first).to be_a(E2B::Models::FilesystemEvent) + expect(events.first.name).to eq("a.txt") + expect(events.first.type).to eq(E2B::Models::FilesystemEventType::CREATE) + end + + it "sends the watcher id and headers to the RPC" do + handle.get_new_events + + call = rpc_calls.last + expect(call[:service]).to eq("filesystem.Filesystem") + expect(call[:method]).to eq("GetWatcherEvents") + expect(call[:body]).to eq(watcherId: "w1") + expect(call[:headers]).to eq("Authorization" => "Basic x") + end + + it "returns an empty array when the response has no events key" do + allow(envd_rpc_proc).to receive(:call).and_return({}) + handle = described_class.new(watcher_id: "w1", envd_rpc_proc: envd_rpc_proc) + expect(handle.get_new_events).to eq([]) + end + + it "raises once the watcher has been stopped" do + handle.stop + expect { handle.get_new_events }.to raise_error(E2B::E2BError, /stopped/) + end + end + + describe "#stop" do + it "calls RemoveWatcher and marks the handle stopped" do + handle.stop + + expect(rpc_calls.last[:method]).to eq("RemoveWatcher") + expect(rpc_calls.last[:body]).to eq(watcherId: "w1") + expect(handle).to be_stopped + end + + it "is a no-op when already stopped (only one RemoveWatcher call)" do + handle.stop + handle.stop + expect(rpc_calls.count { |c| c[:method] == "RemoveWatcher" }).to eq(1) + end + + it "swallows cleanup errors and still marks the handle stopped" do + allow(envd_rpc_proc).to receive(:call).and_raise(E2B::E2BError, "already gone") + handle = described_class.new(watcher_id: "w1", envd_rpc_proc: envd_rpc_proc) + + expect { handle.stop }.not_to raise_error + expect(handle).to be_stopped + end + end +end diff --git a/spec/e2b/template_logger_spec.rb b/spec/e2b/template_logger_spec.rb new file mode 100644 index 0000000..4075152 --- /dev/null +++ b/spec/e2b/template_logger_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" +require "stringio" +require "time" + +RSpec.describe E2B::DefaultBuildLogger do + let(:io) { StringIO.new } + + def entry(level:, message:, timestamp: Time.utc(2026, 1, 1, 12, 0, 0)) + E2B::Models::TemplateLogEntry.new(timestamp: timestamp, level: level, message: message) + end + + it "writes entries at or above the minimum level" do + logger = described_class.new(min_level: "info", io: io) + logger.logger(entry(level: "info", message: "shown")) + + expect(io.string).to include("shown") + expect(io.string).to include("INFO") + expect(io.string).to include("12:00:00") + end + + it "filters out entries below the minimum level" do + logger = described_class.new(min_level: "warn", io: io) + logger.logger(entry(level: "info", message: "hidden")) + + expect(io.string).to be_empty + end + + it "tracks elapsed time using start/end marker entries" do + logger = described_class.new(min_level: "debug", io: io) + + logger.logger(E2B::Models::TemplateLogEntryStart.new(timestamp: Time.now, message: "start")) + logger.logger(entry(level: "info", message: "during build")) + logger.logger(E2B::Models::TemplateLogEntryEnd.new(timestamp: Time.now, message: "end")) + + # The Start/End markers themselves are not printed; only the info line is. + expect(io.string.lines.count).to eq(1) + expect(io.string).to match(/\d+\.\ds/) + end + + it "defaults the minimum level to info" do + logger = described_class.new(io: io) + logger.logger(entry(level: "debug", message: "hidden")) + logger.logger(entry(level: "error", message: "shown")) + + expect(io.string).to include("shown") + expect(io.string).not_to include("hidden") + end + + describe "E2B.default_build_logger" do + it "returns a callable proc that logs entries" do + io = StringIO.new + callback = E2B.default_build_logger(min_level: "debug", io: io) + + callback.call(E2B::Models::TemplateLogEntry.new(timestamp: Time.now, level: "info", message: "via proc")) + + expect(callback).to respond_to(:call) + expect(io.string).to include("via proc") + end + end +end diff --git a/spec/e2b/template_spec.rb b/spec/e2b/template_spec.rb index a830b00..533f8c1 100644 --- a/spec/e2b/template_spec.rb +++ b/spec/e2b/template_spec.rb @@ -395,7 +395,7 @@ expect do template.copy("../secrets.txt", "/app/") end.to raise_error(E2B::TemplateError) { |error| - expect(error.message).to match(/path escapes the context directory/) + expect(error.message).to include("path escapes the context directory") expect(error.source_location).to include(__FILE__) expect(error.backtrace).to eq([error.source_location]) } @@ -407,7 +407,7 @@ expect do template.to_dockerfile end.to raise_error(E2B::TemplateError) { |error| - expect(error.message).to match(/Cannot convert template built from another template to Dockerfile/) + expect(error.message).to include("Cannot convert template built from another template to Dockerfile") expect(error.source_location).to include(__FILE__) } end @@ -513,7 +513,7 @@ expect do template.add_mcp_server("exa") end.to raise_error(E2B::BuildError) { |error| - expect(error.message).to match(/MCP servers can only be added to mcp-gateway template/) + expect(error.message).to include("MCP servers can only be added to mcp-gateway template") expect(error.source_location).to include(__FILE__) } end @@ -886,7 +886,7 @@ expect do described_class.new.from_dockerfile(dockerfile) end.to raise_error(E2B::TemplateError) { |error| - expect(error.message).to match(/Multi-stage Dockerfiles are not supported/) + expect(error.message).to include("Multi-stage Dockerfiles are not supported") expect(error.source_location).to include(__FILE__) } end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 100ef71..57b3af6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,29 @@ # frozen_string_literal: true require "bundler/setup" + +# Coverage is opt-in via COVERAGE=true so that single-file runs (which only +# exercise a slice of the gem) are not failed by the minimum-coverage gate. +# Must start before "e2b" is required so every file is tracked. +if ENV["COVERAGE"] == "true" + require "simplecov" + + SimpleCov.start do + enable_coverage :branch + + add_filter "/spec/" + add_filter "/vendor/" + + add_group "Models", "lib/e2b/models" + add_group "Services", "lib/e2b/services" + add_group "API", "lib/e2b/api" + + # Floor below the measured baseline (≈82% line / ≈60% branch); raise it as + # coverage improves rather than letting it drift down. + minimum_coverage line: 80, branch: 58 + end +end + require "webmock/rspec" require "e2b"