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
38 changes: 38 additions & 0 deletions spec/e2b/api/http_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,44 @@
end
end

describe "#post" do
it "sends a JSON body and parses the JSON response" do
stub_request(:post, "#{base_url}/sandboxes")
.with(body: { templateID: "base" }.to_json)
.to_return(
status: 201,
body: '{"sandboxID":"sbx_new"}',
headers: { "Content-Type" => "application/json" }
)

expect(client.post("/sandboxes", body: { templateID: "base" })).to eq("sandboxID" => "sbx_new")
end

it "omits the request body when none is given" do
stub_request(:post, "#{base_url}/sandboxes/sbx_1/pause")
.to_return(status: 204, body: "")

client.post("/sandboxes/sbx_1/pause")

expect(a_request(:post, "#{base_url}/sandboxes/sbx_1/pause")
.with { |req| req.body.nil? || req.body.empty? }).to have_been_made
end
end

describe "#put" do
it "sends a JSON body via PUT" do
stub_request(:put, "#{base_url}/templates/abc")
.with(body: { alias: "latest" }.to_json)
.to_return(
status: 200,
body: '{"ok":true}',
headers: { "Content-Type" => "application/json" }
)

expect(client.put("/templates/abc", body: { alias: "latest" })).to eq("ok" => true)
end
end

describe "#delete" do
it "sends JSON bodies when provided" do
stub_request(:delete, "#{base_url}/templates/tags")
Expand Down
49 changes: 49 additions & 0 deletions spec/e2b/services/base_service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,53 @@ def build_service(envd_version: nil)
expect(build_service(envd_version: "weird").recursive_watch?).to be(true)
end
end

describe "envd client construction and proxy bypass" do
around do |example|
saved = ENV.to_hash.slice("no_proxy", "NO_PROXY")
ENV.delete("no_proxy")
ENV.delete("NO_PROXY")
begin
example.run
ensure
ENV.delete("no_proxy")
ENV.delete("NO_PROXY")
saved.each { |k, v| ENV[k] = v }
end
end

it "builds an EnvdHttpClient and memoizes it" do
svc = build_service
client = svc.send(:envd_client)

expect(client).to be_a(E2B::Services::EnvdHttpClient)
expect(svc.send(:envd_client)).to be(client)
end

it "appends the sandbox domain to no_proxy/NO_PROXY so envd traffic bypasses the proxy" do
build_service.send(:ensure_no_proxy_for_domain!, "e2b.app")

expect(ENV.fetch("no_proxy", nil)).to include("e2b.app")
expect(ENV.fetch("NO_PROXY", nil)).to include("e2b.app")
end

it "does not duplicate a domain that is already present" do
ENV["no_proxy"] = "e2b.app"
build_service.send(:ensure_no_proxy_for_domain!, "e2b.app")

expect(ENV.fetch("no_proxy", nil)).to eq("e2b.app")
end

it "appends onto an existing no_proxy list" do
ENV["no_proxy"] = "localhost"
build_service.send(:ensure_no_proxy_for_domain!, "e2b.app")

expect(ENV.fetch("no_proxy", nil)).to eq("localhost,e2b.app")
end

it "is a no-op for a blank domain" do
build_service.send(:ensure_no_proxy_for_domain!, "")
expect(ENV.fetch("no_proxy", nil)).to be_nil
end
end
end
225 changes: 225 additions & 0 deletions spec/e2b/services/envd_http_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,229 @@ def rpc_response(status:, body:, headers: {})
expect(client.send(:extract_error_message, rpc_response(status: 400, body: nil))).to eq("HTTP 400 error")
end
end

# Frame one or more JSON strings into a binary Connect envelope stream, the
# wire format handle_rpc_response decodes.
def connect_stream(*messages)
body = "".b
messages.each { |m| body << "\x00".b << [m.bytesize].pack("N") << m.b }
body
end

describe "#handle_rpc_response" do
it "decodes Data events, parses the End exit code, and collects every event" do
body = connect_stream(
%({"event":{"Data":{"stdout":"#{Base64.strict_encode64("out")}","stderr":"#{Base64.strict_encode64("err")}"}}}),
%({"event":{"End":{"exitCode":0}}})
)

result = client.send(:handle_rpc_response, "process.Process", "Start") do
rpc_response(status: 200, body: body)
end

expect(result[:stdout]).to eq("out")
expect(result[:stderr]).to eq("err")
expect(result[:exit_code]).to eq(0)
expect(result[:events].size).to eq(2)
end

it "unwraps a result envelope and reads top-level stdout and a string exit code" do
body = connect_stream(
%({"result":{"stdout":"#{Base64.strict_encode64("hi")}","exitCode":"exit status 3"}})
)

result = client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 200, body: body) }

expect(result[:stdout]).to eq("hi")
expect(result[:exit_code]).to eq(3)
end

it "decodes multibyte UTF-8 process output without raising" do
body = connect_stream(
%({"event":{"Data":{"stdout":"#{Base64.strict_encode64("héllo 日本語")}"}}})
)

result = client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 200, body: body) }

expect(result[:stdout]).to eq("héllo 日本語")
end

it "skips unparseable frames instead of raising" do
body = connect_stream("{not json", %({"event":{"End":{"exitCode":0}}}))

result = client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 200, body: body) }

expect(result[:exit_code]).to eq(0)
expect(result[:events].size).to eq(1)
end

it "returns an empty hash for an empty body" do
result = client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 200, body: "") }
expect(result).to eq({})
end

it "raises a mapped error for a non-success response" do
expect do
client.send(:handle_rpc_response, "s", "m") { rpc_response(status: 404, body: { "message" => "nope" }) }
end.to raise_error(E2B::NotFoundError, "nope")
end
end

describe "#with_retry" do
it "returns the block result on success" do
expect(client.send(:with_retry, "op") { 42 }).to eq(42)
end

it "retries transient network errors, then succeeds" do
allow(client).to receive(:sleep) # avoid real 2**n backoff waits
attempts = 0

result = client.send(:with_retry, "op") do
attempts += 1
raise Errno::ECONNRESET if attempts < 3

"ok"
end

expect(result).to eq("ok")
expect(attempts).to eq(3)
end

it "raises after exhausting retries" do
allow(client).to receive(:sleep)

expect do
client.send(:with_retry, "op", max_retries: 2) { raise Net::ReadTimeout }
end.to raise_error(E2B::E2BError, /failed after 2 retries/)
end

it "does not retry when abort_if is true (observable side effects already happened)" do
calls = 0

expect do
client.send(:with_retry, "op", abort_if: -> { true }) do
calls += 1
raise Errno::ECONNRESET
end
end.to raise_error(E2B::E2BError, /failed after partial response/)

expect(calls).to eq(1)
end

it "does not retry when max_retries is 0 (non-idempotent /Start)" do
expect do
client.send(:with_retry, "op", max_retries: 0) { raise EOFError }
end.to raise_error(E2B::E2BError, /failed after 0 retries/)
end

it "propagates errors it does not classify as transient" do
expect { client.send(:with_retry, "op") { raise ArgumentError, "boom" } }
.to raise_error(ArgumentError, "boom")
end
end

describe "#resolve_proxy" do
around do |example|
keys = %w[no_proxy NO_PROXY http_proxy HTTP_PROXY https_proxy HTTPS_PROXY]
saved = ENV.to_hash.slice(*keys)
keys.each { |k| ENV.delete(k) }
begin
example.run
ensure
keys.each { |k| ENV.delete(k) }
saved.each { |k, v| ENV[k] = v }
end
end

let(:https_url) { URI.parse("https://49983-sbx.e2b.app/path") }

it "returns nil when no proxy is configured" do
expect(client.send(:resolve_proxy, https_url)).to be_nil
end

it "returns the https proxy URI for an https URL" do
ENV["https_proxy"] = "http://proxy.local:3128"
proxy = client.send(:resolve_proxy, https_url)
expect(proxy.host).to eq("proxy.local")
expect(proxy.port).to eq(3128)
end

it "bypasses the proxy when the host suffix matches no_proxy" do
ENV["https_proxy"] = "http://proxy.local:3128"
ENV["no_proxy"] = "e2b.app"
expect(client.send(:resolve_proxy, https_url)).to be_nil
end

it "bypasses the proxy for a wildcard no_proxy" do
ENV["https_proxy"] = "http://proxy.local:3128"
ENV["no_proxy"] = "*"
expect(client.send(:resolve_proxy, https_url)).to be_nil
end

it "returns nil for an unparseable proxy URL" do
client # build the Faraday connection before poisoning the proxy env var
ENV["https_proxy"] = "http://["
expect(client.send(:resolve_proxy, https_url)).to be_nil
end
end

describe "#normalize_path" do
it "strips leading slashes so paths join cleanly onto the base URL" do
expect(client.send(:normalize_path, "/files")).to eq("files")
expect(client.send(:normalize_path, "///a/b")).to eq("a/b")
expect(client.send(:normalize_path, "files")).to eq("files")
end
end

describe "#apply_custom_headers" do
it "sets each header on the request and is a no-op for nil" do
request = Net::HTTP::Post.new("/")
client.send(:apply_custom_headers, request, { "X-Foo" => "bar" })
expect(request["X-Foo"]).to eq("bar")

expect { client.send(:apply_custom_headers, request, nil) }.not_to raise_error
end
end

describe "#handle_response" do
# A minimal stand-in for the Faraday::Response that handle_response inspects.
def faraday_like(success:, body:, headers: {}, status: 200)
resp = Object.new
resp.define_singleton_method(:success?) { success }
resp.define_singleton_method(:body) { body }
resp.define_singleton_method(:headers) { headers }
resp.define_singleton_method(:status) { status }
resp
end

it "parses a JSON string body" do
resp = faraday_like(success: true, body: '{"ok":true}', headers: { "content-type" => "application/json" })
expect(client.send(:handle_response) { resp }).to eq("ok" => true)
end

it "returns a non-JSON string body unchanged" do
resp = faraday_like(success: true, body: "plain text", headers: { "content-type" => "text/plain" })
expect(client.send(:handle_response) { resp }).to eq("plain text")
end

it "returns an already-parsed Hash body as-is" do
resp = faraday_like(success: true, body: { "x" => 1 })
expect(client.send(:handle_response) { resp }).to eq("x" => 1)
end

it "raises a mapped error for an unsuccessful response" do
resp = faraday_like(success: false, body: { "message" => "bad" }, status: 500)
expect { client.send(:handle_response) { resp } }.to raise_error(E2B::E2BError, "bad")
end

it "maps Faraday::TimeoutError to E2B::TimeoutError" do
expect { client.send(:handle_response) { raise Faraday::TimeoutError } }
.to raise_error(E2B::TimeoutError)
end

it "maps Faraday::ConnectionFailed to E2B::E2BError" do
expect { client.send(:handle_response) { raise Faraday::ConnectionFailed, "nope" } }
.to raise_error(E2B::E2BError, /Connection to sandbox failed/)
end
end
end
Loading
Loading