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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,9 @@ jobs:

- name: SDK conformance (real provider SDKs vs adapters)
run: just conformance

- name: Set up bun (Node SDK conformance)
uses: oven-sh/setup-bun@v2

- name: Node SDK conformance (stripe-node, octokit, twilio-node)
run: just conformance-node
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ All notable changes to **stunt** are documented here. The format is based on

## [Unreleased]

## [0.49.0] — 2026-08-18

### Testing

- **SDK conformance wave 3 — the Node SDKs, via bun.** New suites under
`conformance/node/` (`just conformance-node`; bun added to CI) that
boot the **real `stunt` binary** — `stunt up`, runtime file, ports —
so they double as end-to-end tests of the CLI itself:
- **stripe-node** — typed creates, `autoPagingEach` walking
`has_more`, PaymentIntent confirm, and webhook verification through
the SDK's `constructEventAsync` HMAC validator (signature verifies,
`data.object` parses, tampered payloads rejected).
- **octokit** — issue CRUD and `octokit.paginate` following the
adapter's `Link` headers page by page.
- **twilio-node** — the message lifecycle driven by SDK fetches
(`queued → sent/delivered`), the `+15005550001` magic number →
`failed`, and status callbacks verified by twilio-node's own
`validateRequest`.

### Adapters

- **github-style's `Link` pagination headers pointed at the real
`api.github.com`** — hardcoded absolute URLs. Clients that follow the
header (`octokit.paginate`) were sent to the real GitHub and 401'd;
go-github survived only because it parses the page params without
following the URL. Link targets are now built from the serving host
(`req["host"]`), which is also what a real GitHub Enterprise does.
- Known-environment note (documented in the suite): under bun,
stripe-node's fetch layer intermittently drops the `url` form param
when it names a live same-process listener — the webhook registration
in the Node suite goes via a raw POST; everything else is SDK-driven.

## [0.48.0] — 2026-08-17

### Testing
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ passes an adversarial input-safety sweep (garbage params, null/malformed bodies,
cursor/limit param names — never a 5xx), coverage-guided fuzzing of the engine's parsers and
dispatch (`just fuzz`), and conformance suites that drive **real provider SDKs** — stripe-go,
aws-sdk-go-v2, go-github, twilio-go, go-shopify, google-api-go-client — end-to-end against
the adapters (`just conformance`). Highlights:
the adapters (`just conformance`), plus Node suites driving **stripe-node, octokit, and
twilio-node** through the real `stunt` binary (`just conformance-node`). Highlights:

| Adapter | Simulates | Backing |
|---|---|---|
Expand Down
5 changes: 4 additions & 1 deletion adapters/dune-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,10 @@ def _next_uri(req, exec_id, offset, limit):
host = req.get("host", "")
if host == None:
host = ""
return ("http://" + host + "/api/v1/execution/" + exec_id
scheme = req.get("headers", {}).get("x-forwarded-proto", "")
if scheme == None or scheme == "":
scheme = "http" if host.startswith("127.0.0.1") or host.startswith("localhost") else "http"
return (scheme + "://" + host + "/api/v1/execution/" + exec_id
+ "/results?offset=" + str(offset) + "&limit=" + str(limit))

# _pad2 zero-pads to 2 digits.
Expand Down
26 changes: 24 additions & 2 deletions adapters/github-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -425,8 +425,30 @@ def _list_page(req, docs):
next_link = None
if next_cursor != None:
next_page = _to_int(next_cursor) // per_page + 1
base = "https://api.github.com" + req.get("path", "")
next_link = "<" + base + "?per_page=" + str(per_page) + "&page=" + str(next_page) + '>; rel="next"'
# The Link target must point at THIS server: clients that follow
# the header (octokit.paginate) would be sent to the real
# api.github.com otherwise. The subdomain proxy sets
# X-Forwarded-Proto; port mode is plain http on loopback. With no
# host at all, omit the Link rather than redirect to production.
host = req.get("host", "")
if host == None or host == "":
return page_docs, None
scheme = req.get("headers", {}).get("x-forwarded-proto", "")
if scheme == None or scheme == "":
scheme = "http" if host.startswith("127.0.0.1") or host.startswith("localhost") else "https"
base = scheme + "://" + host + req.get("path", "")
# Round-trip every filter/query param except page — a Link-following
# client must not lose state=closed&q=... between pages.
keep = []
q = req.get("query", {})
if q != None:
for k in q:
if k == "page":
continue
keep.append(k + "=" + q[k])
keep.append("per_page=" + str(per_page))
keep.append("page=" + str(next_page))
next_link = "<" + base + "?" + "&".join(keep) + '>; rel="next"'
return page_docs, next_link

# _gh_link_headers returns a headers dict carrying the Link rel="next" value,
Expand Down
8 changes: 7 additions & 1 deletion adapters/shopify-style/scripts/lib.star
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,13 @@ def _list_page(req, docs):
def _next_link(req, next_cursor, limit):
if next_cursor == None:
return None
scheme = "https"
# The proxy sets X-Forwarded-Proto; port mode is plain http on
# loopback. Clients that FOLLOW the page_info URL (not just parse the
# token) need the real scheme.
scheme = req.get("headers", {}).get("x-forwarded-proto", "")
if scheme == None or scheme == "":
host0 = req.get("host", "") or ""
scheme = "http" if host0.startswith("127.0.0.1") or host0.startswith("localhost") else "https"
host = req.get("host", "")
if host == None:
host = ""
Expand Down
1 change: 1 addition & 0 deletions conformance/node/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
Loading
Loading