ExDaytona.Testing (ex_daytona v0.4.0)

Copy Markdown View Source

First-class test doubles for applications built on ExDaytona — no Daytona account, API key, or network required.

client/1 returns a real ExDaytona.Client whose HTTP adapter and streaming transports are backed by scripts owned by the calling (test) process. Every facade module works against it unchanged:

test "provisions a sandbox per tenant" do
  client = ExDaytona.Testing.client()

  ExDaytona.Testing.expect(:post, "/sandbox", fn env ->
    assert %{"labels" => %{"my-app/tenant" => "user-1"}} = JSON.decode!(env.body)
    {200, ExDaytona.Testing.sandbox_json(%{id: "sb-1"})}
  end)

  # Sandbox.create polls state until "started" — stub the poll.
  ExDaytona.Testing.stub(:get, "/sandbox/sb-1", ExDaytona.Testing.sandbox_json(%{id: "sb-1"}))

  assert {:ok, sandbox} =
           MyApp.Sandboxes.provision(client, tenant: "user-1")

  assert ExDaytona.Sandbox.id(sandbox) == "sb-1"
  ExDaytona.Testing.verify!()
end

Expectations and stubs

  • expect/3 queues a one-shot response consumed FIFO per {method, path}; verify!/0 fails the test if any remain.
  • stub/3 registers a reusable fallback (state polls, repeated lookups); stubs are consulted after expectations and never consumed.
  • A path matches when it equals the request path or is a suffix of it — so "/process/execute" matches the toolbox route "/{sandbox_id}/process/execute" without spelling out the prefix.
  • Responses: {status, body} (maps/lists JSON-encoded, binaries sent raw), a bare body (status 200), a bare status, status:/body:/ headers: keywords, {:error, reason} to simulate a transport failure, or a fun of the Tesla.Env returning any of these — use a fun to assert on the request itself.

Everything is keyed by the test process, so async: true tests don't interfere; state is cleaned up when the test process exits. Requests made by processes the SDK spawns internally (pollers, log followers) resolve to the client's owner automatically.

Sandboxes without HTTP

Facades that operate on a sandbox (ExDaytona.FS, ExDaytona.Session, ExDaytona.Git, ...) take a sandbox struct — build one directly with sandbox/2 and skip the create flow entirely:

sandbox = ExDaytona.Testing.sandbox(%{id: "sb-1"})

ExDaytona.Testing.expect(:post, "/process/execute", %{exitCode: 0, result: "hi"})
assert {:ok, %{exit_code: 0}} = ExDaytona.Sandbox.exec(sandbox, "echo hi")

Streaming

HTTP chunk streams (log follows, ExDaytona.FS transfers) and websockets (ExDaytona.LogStream, ExDaytona.Pty, ExDaytona.CodeInterpreter) bypass the Tesla adapter — script them with script_http_stream/1 and script_ws/1:

ExDaytona.Testing.script_ws(frames: [{:binary, <<1, 1, 1>> <> "hello"}])

{:ok, stream} = ExDaytona.Session.open_log_stream(session, cmd_id)
assert {:ok, %{stdout: "hello"}} = ExDaytona.LogStream.collect(stream)

Summary

Functions

A ready-to-use ExDaytona.Client backed entirely by this process's scripts: the Tesla adapter answers from expect/3/stub/3, and the streaming transports from script_http_stream/1/script_ws/1.

Queue a one-shot response for the next method request whose path matches path (exactly, or as a suffix — see the module docs). Consumed FIFO per {method, path}; leftovers fail verify!/0.

A %ExDaytona.Sandbox{} struct for facades that operate on sandboxes, with no HTTP involved in building it. Defaults: id "sandbox-test", state "started", and a toolbox proxy URL — override any ExDaytona.Model.Sandbox field via attrs (snake_case conveniences :toolbox_proxy_url, :organization_id, :error_reason included).

A sandbox JSON body (string keys, camelCase — what the API sends) for use in expect/3/stub/3 responses. Same defaults and snake_case conveniences as sandbox/2.

Script the next chunked HTTP stream (build-log follows, ExDaytona.FS uploads/downloads including ExDaytona.FS.stream!/3). The script stays in effect until replaced.

Script the next websocket connection opened by a process this test started (an ExDaytona.LogStream, ExDaytona.Pty, or ExDaytona.CodeInterpreter run against a client/1). The script stays in effect until replaced.

Register a reusable response for method/path — consulted whenever no expectation matches, never consumed, never verified. The tool for endpoints the SDK polls (sandbox state, snapshot state, command status).

Raise ExDaytona.Testing.VerificationError unless every expectation queued by this process was consumed. Call it at the end of the test.

Functions

client(opts \\ [])

@spec client(keyword()) :: ExDaytona.Client.t()

A ready-to-use ExDaytona.Client backed entirely by this process's scripts: the Tesla adapter answers from expect/3/stub/3, and the streaming transports from script_http_stream/1/script_ws/1.

Options are passed through to ExDaytona.Client.new!/1 (:base_url, :middleware, ...); :api_key defaults to "dtn_test" and :retry to false.

expect(method, path, response)

@spec expect(atom() | String.t(), String.t(), term()) :: :ok

Queue a one-shot response for the next method request whose path matches path (exactly, or as a suffix — see the module docs). Consumed FIFO per {method, path}; leftovers fail verify!/0.

ExDaytona.Testing.expect(:get, "/sandbox/sb-1", ExDaytona.Testing.sandbox_json())
ExDaytona.Testing.expect(:delete, "/sandbox/sb-1", 200)
ExDaytona.Testing.expect(:post, "/snapshots", fn env ->
  assert JSON.decode!(env.body)["name"] == "base"
  {200, %{id: "snap-1", name: "base", state: "active"}}
end)

sandbox(client_or_attrs \\ nil, attrs \\ %{})

@spec sandbox(ExDaytona.Client.t() | map() | nil, map()) :: ExDaytona.Sandbox.t()

A %ExDaytona.Sandbox{} struct for facades that operate on sandboxes, with no HTTP involved in building it. Defaults: id "sandbox-test", state "started", and a toolbox proxy URL — override any ExDaytona.Model.Sandbox field via attrs (snake_case conveniences :toolbox_proxy_url, :organization_id, :error_reason included).

Pass a client as the first argument to bind the sandbox to it; otherwise a fresh client/1 is built. sandbox(attrs) is accepted as a shorthand for sandbox(nil, attrs).

sandbox_json(attrs \\ %{})

@spec sandbox_json(map()) :: map()

A sandbox JSON body (string keys, camelCase — what the API sends) for use in expect/3/stub/3 responses. Same defaults and snake_case conveniences as sandbox/2.

script_http_stream(opts)

@spec script_http_stream(keyword()) :: :ok

Script the next chunked HTTP stream (build-log follows, ExDaytona.FS uploads/downloads including ExDaytona.FS.stream!/3). The script stays in effect until replaced.

Options

  • :status — response status (default 200)
  • :chunks — body chunks to deliver in order (default [])
  • :chunk_delay — ms between chunks (default 0)
  • :headers — response headers (default [])
  • :error — after the chunks, fail the transport with this reason instead of completing

script_ws(opts)

@spec script_ws(keyword()) :: :ok

Script the next websocket connection opened by a process this test started (an ExDaytona.LogStream, ExDaytona.Pty, or ExDaytona.CodeInterpreter run against a client/1). The script stays in effect until replaced.

Options

  • :frames — frames to replay, as {:binary, data} / {:text, data} tuples (log streams use 3-byte channel markers — <<1, 1, 1>> stdout, <<2, 2, 2>> stderr; unmarked bytes surface as :output)
  • :frame_delay — ms between frames (default 0)
  • :close_reason — how the connection closes after the frames (default :normal; e.g. {:error, :closed} for a drop)
  • :hold_open — keep the connection open after the frames instead of closing (default false)
  • :connect — set to {:error, %ExDaytona.Error{...}} to fail the upgrade itself

stub(method, path, response)

@spec stub(atom() | String.t(), String.t(), term()) :: :ok

Register a reusable response for method/path — consulted whenever no expectation matches, never consumed, never verified. The tool for endpoints the SDK polls (sandbox state, snapshot state, command status).

verify!()

@spec verify!() :: :ok

Raise ExDaytona.Testing.VerificationError unless every expectation queued by this process was consumed. Call it at the end of the test.