Skip to content

Provision a single shared sandbox app per test run#705

Open
SimonWoolf wants to merge 6 commits into
mainfrom
refactor/shared-test-app
Open

Provision a single shared sandbox app per test run#705
SimonWoolf wants to merge 6 commits into
mainfrom
refactor/shared-test-app

Conversation

@SimonWoolf

@SimonWoolf SimonWoolf commented Jun 9, 2026

Copy link
Copy Markdown
Member

Summary

Integration tests previously provisioned a fresh sandbox app for every test (~150 per run) via the per-call-site NewSandbox/NewRealtime/NewREST helpers. Each provision creates a whole account and app server-side, which is expensive and puts needless load on the sandbox environment — and no other Ably SDK does this (ably-js provisions one app per suite from a shared appspec).

This PR provisions one shared app per test run instead, modelled on ably-js's approach.

Commits

  1. fix: reset presence decoder destination between paginated pages — a pre-existing SDK bug (not test-only). fullPresenceDecoder reused the same destination slice across pages in the Items() auto-paging iterator; under msgpack the codec reuses existing slice elements, so page 2+ decoded into PresenceMessages whose Data still held the previous page's decoded values. With non-uniform Data types this corrupted decoding (msgpack decode error: invalid byte descriptor for decoding bytes). Fixed by resetting the destination to nil before decoding. Affects any user paginating presence via Items() over msgpack with mixed data.
  2. chore: bump ably-common submodule — to a revision whose test-app-setup.json includes the mutable namespace (needed by the message-update tests) alongside the shared presence fixtures.
  3. test: provision a single shared sandbox app per run — the refactor itself.

What the refactor does (ablytest/)

  • sandbox.go: drops the dynamic Config/Key/Namespace/Presence/Channel request-body structs (no test ever passed a custom config) and POSTs the static ably-common appspec verbatim, located via runtime.Caller. A minimal Config decodes only appId and keys from the response.
  • Adds an Ably-Agent header (RSC7d) to the /apps POST and DELETE, reusing the client's agent string via the newly exported ably.AgentIdentifier / ably.AblyAgentHeaderName.
  • GetSharedApp provisions the shared app once (sync.Once); NewSandbox(nil), MustSandbox, NewRealtime, NewREST all return it. Sandbox.Close is a no-op unless the app is owned; teardown happens once via CloseSharedApp from a new TestMain.
  • KeyParts selects the key carrying the [*]* wildcard capability rather than keys[0], since the shared appspec's first key has only the default capability (which doesn't grant qualified/derived channels).
  • Adds ablytest.ChannelName for process-unique channel names, used in the history/presence tests that assert on aggregate channel state and can no longer rely on per-app isolation.
  • PresenceFixtures now matches the appspec's six members, including the AES-encrypted client_encoded, decoded in the presence-fixtures read tests via the new PresenceFixturesCipher helper.

Testing

The full integration suite passes under both the JSON and msgpack protocols.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Fixed paginated presence-history decoding that could leak stale message data across pages.
  • Improvements

    • Exposed SDK agent header name and helper to surface client agent info.
    • Introduced a shared test sandbox and process-unique channel naming to reduce test interference.
    • Improved presence-fixture decoding for JSON and encrypted fixtures.
    • Added many new error code messages for clearer diagnostics.

SimonWoolf and others added 3 commits June 9, 2026 11:47
fullPresenceDecoder reused the same destination slice across pages in the
Items() auto-paging iterator. When decoding msgpack, the codec reuses the
existing slice elements, so page 2+ decoded wire bytes into PresenceMessages
whose Data field already held the previous page's decoded values. With
non-uniform Data types across messages this corrupted decoding and produced
errors such as "msgpack decode error: invalid byte descriptor for decoding
bytes".

Reset the destination slice to nil before decoding in both CodecDecodeSelf
and UnmarshalJSON.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Updates the ably-common submodule to a revision whose
test-resources/test-app-setup.json includes the mutable namespace (required by
the message-update tests) alongside the shared presence fixtures. Consumed by
the shared-sandbox-app test setup.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Integration tests previously provisioned a fresh sandbox app for every test
(~150 per run) via the per-call-site NewSandbox/NewRealtime/NewREST helpers.
Each provision creates a whole account and app server-side, which is expensive
and puts needless load on the sandbox environment; no other Ably SDK does this.

Provision one shared app per test run instead:

- ablytest/sandbox.go: drop the dynamic Config/Key/Namespace/Presence/Channel
  structs used to build the request body (no test ever passed a custom config)
  and POST the static ably-common appspec (test-app-setup.json) verbatim,
  located relative to the source via runtime.Caller. A minimal Config decodes
  only the appId and keys from the response.
- Add an Ably-Agent header (RSC7d) to the /apps POST and DELETE, reusing the
  client's agent string via the newly exported ably.AgentIdentifier and
  ably.AblyAgentHeaderName.
- GetSharedApp provisions the shared app once (sync.Once); NewSandbox(nil),
  MustSandbox, NewRealtime and NewREST all return it. Sandbox.Close is a no-op
  unless the app is owned; the shared app is torn down once via CloseSharedApp
  from TestMain.
- KeyParts now selects the key carrying the [*]* wildcard capability rather
  than keys[0], since the shared appspec's first key has only the default
  capability (which does not grant qualified/derived channels).
- Add ablytest.ChannelName for process-unique channel names, and use it in the
  history/presence tests that assert on aggregate channel state, which can no
  longer rely on per-app isolation.
- PresenceFixtures now matches the appspec's six members, including the
  AES-encrypted client_encoded member, decoded in the presence-fixtures read
  tests via the new PresenceFixturesCipher helper.

The full integration suite passes under both the JSON and msgpack protocols.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 20808e62-c8ec-4439-add2-8078b978d464

📥 Commits

Reviewing files that changed from the base of the PR and between 7634555 and bde2cbf.

📒 Files selected for processing (1)
  • ablytest/sandbox.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • ablytest/sandbox.go

Walkthrough

Refactors the test sandbox to a single shared app per process, fixes presence pagination decoding, exposes Ably-Agent helpers, adds process-unique channel naming, updates presence fixture decoding, and expands ErrorCode string mappings.

Changes

Sandbox and Presence Test Infrastructure

Layer / File(s) Summary
Agent header and identifier exports
ably/proto_http.go
Exports AblyAgentHeaderName constant and AgentIdentifier(agents) function for agent-identifier construction.
Sandbox data structure refactoring and appspec loading
ablytest/sandbox.go
Simplifies Key, Config, and Presence types to match appspec fields; adds appspec parsing helpers and base64 support; exposes PresenceFixtures() and PresenceFixturesCipher().
Shared sandbox lifecycle and provisioning
ablytest/sandbox.go, ably/main_integration_test.go
Implements a process-wide shared sandbox via sync.OnceValues, provisions from a static appspec payload, makes per-test Close() a no-op, adds CloseSharedApp() and a TestMain entrypoint for one-time cleanup, and sets the Ably-Agent header on provisioning/deletion.
Sandbox key selection and request changes
ablytest/sandbox.go
Selects API key by wildcard capability with fallback and includes Ably-Agent header on POST/DELETE to /apps.
Process-unique channel naming helper
ablytest/ablytest.go, ably/rest_channel_spec_integration_test.go
Adds atomic counter and ChannelName(base string) to generate unique channel names per test and applies it to persisted-history tests.
Presence pagination data integrity
ably/rest_presence.go
Resets the destination slice in fullPresenceDecoder.UnmarshalJSON and CodecDecodeSelf before decoding to prevent stale Data reuse across pages.
Presence test fixtures with cipher decoding
ably/rest_presence_spec_integration_test.go
Adds imports and helpers to decode cipher-backed presence fixtures; replaces direct channel access with presenceFixturesChannel() and computes expected decoded Data for assertions.
Expanded ErrorCode mappings
ably/errors.go
Extends ErrorCode.String() with many new error code→message mappings across multiple domains.
Tests: sandbox constructor updates
ably/*_integration_test.go, ably/*_spec_integration_test.go
Replaces usages of MustSandbox(nil)/NewSandbox(nil)/NewSandboxWithEndpoint(nil, ...) with the shared/default MustSandbox()/NewSandbox() and updates test channel names to use ChannelName where applicable.
Common submodule update
common
Bumps common submodule reference to a new commit SHA.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 One sandbox to test them all,
Shared app spun up to heed the call,
Presence pages now clean and bright,
Channels named unique each night,
Fixtures decoded — tests take flight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.64% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Provision a single shared sandbox app per test run' clearly and accurately reflects the main change: migrating from per-test sandbox provisioning to a shared app approach per test run, which is the primary focus of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/shared-test-app

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@SimonWoolf SimonWoolf requested a review from lmars June 9, 2026 12:09
@github-actions github-actions Bot temporarily deployed to staging/pull/705/godoc June 9, 2026 12:10 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/705/features June 9, 2026 12:10 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/705/godoc June 9, 2026 12:12 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/705/features June 9, 2026 12:12 Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
ablytest/sandbox.go (1)

269-269: 💤 Low value

Use http.NewRequestWithContext for the DELETE request.

The static analysis correctly identifies that http.NewRequest should be replaced with http.NewRequestWithContext for proper context propagation. While this is test infrastructure, using contexts allows for proper cancellation and timeout handling.

Proposed fix
-	req, err := http.NewRequest("DELETE", app.URL("apps", app.Config.AppID), nil)
+	req, err := http.NewRequestWithContext(context.Background(), "DELETE", app.URL("apps", app.Config.AppID), nil)

Note: You'll need to add "context" to the imports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ablytest/sandbox.go` at line 269, Replace the call to http.NewRequest in the
DELETE request creation with http.NewRequestWithContext to propagate
cancellation/timeouts; update the statement using an existing context value
(e.g. ctx) or context.Background() if no context is available, and add the
"context" import. Locate the line that constructs the request (the req, err :=
http.NewRequest("DELETE", app.URL("apps", app.Config.AppID), nil) call) and
change it to use http.NewRequestWithContext(ctx, "DELETE", app.URL(...), nil)
and ensure the ctx variable is supplied or created.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@ablytest/sandbox.go`:
- Line 269: Replace the call to http.NewRequest in the DELETE request creation
with http.NewRequestWithContext to propagate cancellation/timeouts; update the
statement using an existing context value (e.g. ctx) or context.Background() if
no context is available, and add the "context" import. Locate the line that
constructs the request (the req, err := http.NewRequest("DELETE",
app.URL("apps", app.Config.AppID), nil) call) and change it to use
http.NewRequestWithContext(ctx, "DELETE", app.URL(...), nil) and ensure the ctx
variable is supplied or created.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ccab2521-343b-410c-b891-ea0c1c3cc25b

📥 Commits

Reviewing files that changed from the base of the PR and between 875b247 and 3c532b7.

📒 Files selected for processing (8)
  • ably/main_integration_test.go
  • ably/proto_http.go
  • ably/rest_channel_spec_integration_test.go
  • ably/rest_presence.go
  • ably/rest_presence_spec_integration_test.go
  • ablytest/ablytest.go
  • ablytest/sandbox.go
  • common

@SimonWoolf

Copy link
Copy Markdown
Member Author

Re the nitpick about using http.NewRequestWithContext for the DELETE at ablytest/sandbox.go:269 — not taking this one.

It was flagged only because line 269 is the line this PR touched, but there are three identical http.NewRequest calls in the file (the POST at 221 and the JWT GET at 398 too). Fixing just one would make them inconsistent.

More importantly, Sandbox.Close() has no context in scope (signature Close() error, called as defer app.Close() from ~100 sites). The proposed fix passes context.Background(), which propagates no cancellation or timeout — it's purely cosmetic. Genuine propagation would mean threading a ctx through Close() and every caller. This is test infra and the HTTP client already has a fixed 1-minute timeout, so there's no behavioural gap to close here.

Leaving all three as-is for consistency.

— Claude

Comment thread ablytest/sandbox.go Outdated
Comment thread ablytest/sandbox.go Outdated
Comment thread ablytest/sandbox.go Outdated
Comment thread ablytest/sandbox.go Outdated
Comment thread ablytest/sandbox.go Outdated
Comment thread ablytest/sandbox.go Outdated
Comment thread ablytest/sandbox.go Outdated
Comment thread ablytest/sandbox.go Outdated
Comment thread ablytest/sandbox.go Outdated
Address review feedback on the shared-sandbox setup:

- Read the presence fixtures directly from the appspec JSON instead of
  duplicating them in Go, removing the "must stay in sync" hazard.
- Drop the unused config argument from NewSandbox and MustSandbox, and remove
  NewSandboxWithEndpoint (both callers passed the default endpoint); update the
  call sites accordingly.
- Use sync.OnceValue / sync.OnceValues for the appspec load and the shared app
  instead of hand-rolled sync.Once + package vars.
- Remove the dead `owned` field and dedicated-app code path: no test provisions
  a non-shared app. Close is now unconditionally a no-op and the shared app is
  deleted via an internal delete method from CloseSharedApp.

No behavioural change; the full integration suite passes under both the JSON
and msgpack protocols.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@github-actions github-actions Bot temporarily deployed to staging/pull/705/godoc June 11, 2026 09:26 Inactive
@github-actions github-actions Bot temporarily deployed to staging/pull/705/features June 11, 2026 09:26 Inactive

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ablytest/sandbox.go (1)

214-220: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Close 504 responses before retrying.

When Line 214 takes the retry path for a 504, the response body stays open. A few consecutive provisioning timeouts will leak sockets and prevent connection reuse in the same run.

Suggested patch
-		if err != nil || (resp != nil && resp.StatusCode == 504) { // gateway timeout
+		if err != nil || (resp != nil && resp.StatusCode == http.StatusGatewayTimeout) {
+			if resp != nil && resp.Body != nil {
+				resp.Body.Close()
+			}
 			// Timeout. Back off before allowing another attempt.
 			log.Println("warn: request timeout, attempting retry")
 			time.Sleep(retryInterval)
 			retryInterval *= 2
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ablytest/sandbox.go` around lines 214 - 220, When the retry branch (err !=
nil || (resp != nil && resp.StatusCode == 504)) is taken we must close the
response body to avoid leaking sockets; update the branch so that if resp != nil
you close (and optionally drain) resp.Body before sleeping and doubling
retryInterval. Locate the conditional that checks err and resp.StatusCode == 504
and add logic to call resp.Body.Close() (or drain then close) for the 504 case
prior to time.Sleep(retryInterval) so the existing defer in the else branch
remains correct.
🧹 Nitpick comments (1)
ablytest/sandbox.go (1)

285-292: ⚡ Quick win

Match the wildcard capability semantically.

Line 286 compares the server-provided capability JSON by exact string value. Any harmless reformatting of that JSON makes the wildcard key invisible and silently falls back to Keys[0], which is the wrong behavior for the shared-app tests that need [*]*.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ablytest/sandbox.go` around lines 285 - 292, The code currently does a string
equality check against wildcardCapability; instead parse the capability JSON in
each key (k.Capability) and semantically detect the wildcard form (unmarshal
into map[string][]string and check that there is an entry with key "*" whose
value slice contains "*" ), returning that key when matched; update the loop
that inspects app.Config.Keys (and the wildcardCapability reference) to attempt
json.Unmarshal for each k and continue on unmarshal errors, so harmless
formatting changes in the JSON won’t cause a silent fallback to
app.Config.Keys[0].
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ably/rest_channel_integration_test.go`:
- Around line 28-30: Tests use a shared sandbox from NewSandbox() but still use
fixed channel names which causes cross-test state leakage; update every subtest
that asserts exact history/state (where it checks len(history), dedupe results,
fallback publish counts, etc.) to derive per-test unique channel names by
calling ablytest.ChannelName(...) instead of hard-coded strings (replace
occurrences in the asserting subtests around the ranges noted, e.g., the blocks
starting ~lines 35-99, 154-185, 188-224, 244-265, 267-294, 351-379) so each test
uses a unique channel name when creating channels and publishing/reading
history.

In `@ablytest/sandbox.go`:
- Around line 178-183: CloseSharedApp currently calls sharedApp()
unconditionally and swallows errors; change logic so teardown never triggers
initialization and surface provisioning errors: introduce/ensure a boolean flag
sharedAppInitialized that the initializer (e.g., NewSandbox or the wrapper that
first calls sharedApp()) sets to true when a real provisioning attempt started;
in CloseSharedApp check sharedAppInitialized first and return nil immediately if
false (do not call sharedApp()), otherwise call sharedApp() and if it returns an
error propagate that error instead of returning nil, then call app.delete() on
the valid app.

---

Outside diff comments:
In `@ablytest/sandbox.go`:
- Around line 214-220: When the retry branch (err != nil || (resp != nil &&
resp.StatusCode == 504)) is taken we must close the response body to avoid
leaking sockets; update the branch so that if resp != nil you close (and
optionally drain) resp.Body before sleeping and doubling retryInterval. Locate
the conditional that checks err and resp.StatusCode == 504 and add logic to call
resp.Body.Close() (or drain then close) for the 504 case prior to
time.Sleep(retryInterval) so the existing defer in the else branch remains
correct.

---

Nitpick comments:
In `@ablytest/sandbox.go`:
- Around line 285-292: The code currently does a string equality check against
wildcardCapability; instead parse the capability JSON in each key (k.Capability)
and semantically detect the wildcard form (unmarshal into map[string][]string
and check that there is an entry with key "*" whose value slice contains "*" ),
returning that key when matched; update the loop that inspects app.Config.Keys
(and the wildcardCapability reference) to attempt json.Unmarshal for each k and
continue on unmarshal errors, so harmless formatting changes in the JSON won’t
cause a silent fallback to app.Config.Keys[0].
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0c61c382-8738-4060-a89f-b523125a5cf9

📥 Commits

Reviewing files that changed from the base of the PR and between b896a25 and 7634555.

📒 Files selected for processing (12)
  • ably/auth_integration_test.go
  • ably/http_paginated_response_integration_test.go
  • ably/main_integration_test.go
  • ably/message_updates_integration_test.go
  • ably/realtime_channel_integration_test.go
  • ably/realtime_channel_spec_integration_test.go
  • ably/realtime_client_integration_test.go
  • ably/realtime_conn_spec_integration_test.go
  • ably/rest_channel_integration_test.go
  • ably/rest_channel_spec_integration_test.go
  • ably/rest_client_integration_test.go
  • ablytest/sandbox.go
✅ Files skipped from review due to trivial changes (1)
  • ably/message_updates_integration_test.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • ably/main_integration_test.go

Comment thread ably/rest_channel_integration_test.go
Comment thread ablytest/sandbox.go
The sync.OnceValues rewrite dropped the guard that made CloseSharedApp a no-op
when no test ever requested the shared app: it called sharedApp()
unconditionally, which would provision an app on teardown just to delete it (for
a test run that touches no sandbox). It also returned nil when provisioning had
failed, masking a real error.

Track whether NewSandbox was ever called and short-circuit CloseSharedApp when
it wasn't; otherwise propagate any provisioning error.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants