Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`.
Expand Down
1 change: 1 addition & 0 deletions e2b.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 3 additions & 1 deletion lib/e2b/models/process_result.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"] || ""
Expand Down
85 changes: 85 additions & 0 deletions spec/e2b/configuration_spec.rb
Original file line number Diff line number Diff line change
@@ -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
187 changes: 187 additions & 0 deletions spec/e2b/dockerfile_parser_spec.rb
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions spec/e2b/errors_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading