Skip to Content
Integration tests in CI

Getting started

Integration tests in CI

Run the simulator as a GitHub Actions service container . The runner pulls the image, waits on its built-in HEALTHCHECK, and exposes the port to your test job. No compose file, no CLI in CI.

One secret

gh secret set RYSTIC_LICENSE --body "$(base64 < rystic-license.json | tr -d '\n')"

The registry accepts the license JSON or its base64 as the password; the runtime reads the same value.

The workflow

# .github/workflows/integration.yml
jobs:
  integration:
    runs-on: ubuntu-latest
    services:
      kalshi-twin:
        image: registry.rystic.ai/kalshi-twin:0.0.25   # or pin by digest for repro
        credentials:
          username: license
          password: ${{ secrets.RYSTIC_LICENSE }}
        env:
          RYSTIC_LICENSE: ${{ secrets.RYSTIC_LICENSE }}
        ports: ["8080:8080"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
      - run: go test -tags integration ./...
        env:
          KALSHI_BASE_URL: http://localhost:8080/trade-api/v2
          TWIN_URL: http://localhost:8080
  1. The job starts only once the HEALTHCHECK reports healthy — no wait-for-port loop.
  2. Tags are never latest, so the workflow pins fidelity by construction.
  3. No license is baked into the image; it arrives via RYSTIC_LICENSE at run time.

The tests

One helper does the work — POST /_rystic/scenario wipes state then applies the named world, so tests are order-independent:

//go:build integration

var base = cmp.Or(os.Getenv("TWIN_URL"), "http://localhost:8080")

func scenario(t *testing.T, name string) {
	t.Helper()
	http.Post(base+"/_rystic/scenario", "application/json",
		strings.NewReader(`{"name":"`+name+`"}`))
}

func TestBotSurvivesThinBook(t *testing.T) {
	scenario(t, "thin_book")
	runBot(t, 30*time.Second)
}

Check a trade went through, on the same surfaces live Kalshi has plus the simulator’s egress log:

func TestOrderFills(t *testing.T) {
	scenario(t, "deep_book")

	resp := post(t, base+"/trade-api/v2/portfolio/events/orders", `{
	  "ticker": "KXDEMO-26-A", "side": "bid", "price": "0.40", "count": "10",
	  "time_in_force": "fill_or_kill", "self_trade_prevention_type": "taker_at_cross",
	  "client_order_id": "11111111-1111-1111-1111-111111111111"
	}`)
	want(t, resp, `"fill_count":"10.00"`)

	want(t, get(t, base+"/trade-api/v2/portfolio/fills"), `"is_taker":true`)
	want(t, get(t, base+"/trade-api/v2/portfolio/positions"), `"position_fp":"10.00"`)
	want(t, get(t, base+"/_rystic/egress?tail=50"), `"type":"fill"`)
}
  1. The first three are the real Kalshi API — the same assertions run unchanged against live.
  2. /_rystic/egress is simulator-only: it proves the simulator emitted the fill frame the WebSocket feed streams from. It catches “state moved but the frame was never produced.”
  3. One test per named world: thin_book, flash_crash, rate_limited, flaky_500, exchange_halted, underwater — 45 in all (GET /_rystic/scenarios).
  4. Determinism knobs: POST /_rystic/seed-rng and POST /_rystic/clock.

If your bot is an image

Use the compose route: rystic export compose locally, commit deploy/docker-compose.yaml, docker compose up in the job with the same secret. See Docker.

Last updated on