Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4888154
conformance(ts): persist operation field on wire snapshots
jeremy May 4, 2026
61f481b
conformance(ruby): wire-replay runner + schema walker
jeremy May 4, 2026
95534ab
conformance(python): wire-replay runner + schema walker
jeremy May 4, 2026
bcf2632
conformance(go): wire-replay runner + schema walker
jeremy May 4, 2026
da711a2
conformance(kotlin): wire-replay runner + schema walker
jeremy May 4, 2026
335668d
conformance: wire-replay make targets + CONTRIBUTING + gitignore
jeremy May 4, 2026
9c52e34
conformance(ruby): align walker required-paths with `/`; fix mode-gat…
jeremy May 8, 2026
f50f22c
conformance(go): align walker required-paths with `/`
jeremy May 8, 2026
79727d8
conformance(kotlin): tighten env-var gate + narrow Throwable catches
jeremy May 8, 2026
6422d34
conformance(go,ruby): fix walker docstring drift on required-path sep…
jeremy May 12, 2026
34d4faa
conformance(python,go): preserve empty bodyText on wire snapshots
jeremy May 13, 2026
c028679
conformance: align default-response precedence with documented order
jeremy May 13, 2026
27162f1
conformance(kotlin): drop unused JsonArray and encodeToJsonElement im…
jeremy May 13, 2026
751b722
conformance: faithful empty-body decoding + Kotlin gate hardening
jeremy May 13, 2026
148b021
gitignore: stray Go binary from `go build` in conformance runner
jeremy May 13, 2026
da5f499
conformance: surface snapshot read/parse errors as gate messages
jeremy May 13, 2026
ce68aa1
conformance(python): catch UnicodeDecodeError on snapshot read
jeremy May 13, 2026
09c73f6
conformance: address four Copilot threads on PR #301
jeremy May 14, 2026
bb85531
conformance(ts): keep persisted operation authoritative in snapshot s…
jeremy May 14, 2026
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ spec/build/

# Conformance test runner artifacts
conformance/runner/go/conformance-runner
conformance/runner/go/go
conformance/runner/ruby/Gemfile.lock
conformance/runner/typescript/node_modules/

Expand Down Expand Up @@ -37,7 +38,11 @@ python/dist/
python/.pytest_cache/
python/.coverage
python/src/*.egg-info/

# Python conformance runner artifacts
conformance/runner/python/.venv/
conformance/runner/python/**/__pycache__/
conformance/runner/python/**/*.pyc
conformance/runner/python/uv.lock

# TypeScript SDK build artifacts
Expand Down
71 changes: 70 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,76 @@ gate refuses to run if any fixture operation lacks a dispatch.
Because live canary fixtures live in the shared `conformance/tests/` directory,
offline conformance runners must treat `mode` as part of the shared schema and
execute only mock tests: omitted `mode` or `mode: "mock"`. `mode: "live"` entries
belong to the TypeScript live wire-capture runner until replay runners are added.
belong to the TypeScript live wire-capture runner and the cross-language
wire-replay runners described next.

### Wire replay (cross-language)

The TypeScript live runner is the single canonical wire-capturer. When invoked
with `LIVE_RECORD_DIR=<path>`, it persists every captured response to
`<path>/<backend>/wire/<test>.json` with the snapshot format
`{ operation, pages: [{status, headers, body, bodyText, url}], pages_count }`.

The Ruby, Python, Go, and Kotlin runners each have a *wire-replay mode* that
reads those snapshots and decodes each page through their SDK. No HTTP calls,
no mock servers — the input is the canonical wire bytes captured by the TS
runner. Decode results land at
`<path>/<backend>/decode/<language>/<test>.json` with the format
`{ schema_version, operation, pages: [{decoded, decode_error, missing_required, extras_seen}] }`.

Each runner enforces three coverage gates at startup before doing any decode
work:

1. **Decoder coverage** — every operation in `live-my-surface.json` has a
decode case in this runner.
2. **Snapshot completeness** — every operation in `live-my-surface.json` has
a corresponding snapshot file at `<path>/<backend>/wire/`.
3. **Snapshot recognition** — every snapshot's `operation` field is in
`live-my-surface.json` (catches drift between TS dispatch and the shared
fixture).

Each gate prints which operations triggered it so the operator can fix the
right side: TS dispatch, the fixture, or the replay runner.

Two-stage flow:

```bash
# Step 1: TS captures canonical wire snapshots (live HTTP, requires creds).
BASECAMP_LIVE=1 \
BASECAMP_TOKEN=<token> \
BASECAMP_ACCOUNT_ID=<account> \
BASECAMP_BACKEND=bc4 \
LIVE_RECORD_DIR=tmp/canary \
make conformance-typescript-live

# Step 2: each language replays those snapshots through its SDK (offline).
for lang in ruby python go kotlin; do
WIRE_REPLAY_DIR=tmp/canary BASECAMP_BACKEND=bc4 \
make conformance-$lang-replay
done
```

Step 2 needs no credentials and no network — it's pure decode + walk. The
extras-observed output across languages is a consistency check on the
hand-rolled schema walkers (which mirror the TS validator's required + extras
algorithm in each language). Any per-language divergence in `extras_seen`
points at a walker bug in the diverging language.

`make conformance-typescript-live` is owned by PR 2; the four
`make conformance-*-replay` targets ship in PR 3. The orchestrating
`make conformance-live` and `make check-bc5-compat` targets that thread
TS capture → replay → BC4↔BC5 comparison together land in PR 4.

When the SDK gains a new operation in `live-my-surface.json`, it must be
added to:

- `conformance/runner/typescript/live-dispatch.ts` — TS dispatch case.
- `conformance/runner/ruby/replay-runner.rb` — Ruby decoder.
- `conformance/runner/python/replay_runner.py` — Python decoder.
- `conformance/runner/go/replay_runner.go` — Go decoder.
- `kotlin/conformance/src/main/kotlin/com/basecamp/sdk/conformance/ReplayRunner.kt` — Kotlin decoder.

Each runner's coverage gate refuses to start until all five are in place.

## API gap registry (`spec/api-gaps/`)

Expand Down
53 changes: 45 additions & 8 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,7 @@ py-clean:
# Conformance Test targets
#------------------------------------------------------------------------------

.PHONY: conformance conformance-go conformance-kotlin conformance-typescript conformance-typescript-live conformance-ruby conformance-python conformance-build
.PHONY: conformance conformance-go conformance-go-replay conformance-kotlin conformance-kotlin-replay conformance-typescript conformance-typescript-live conformance-ruby conformance-ruby-replay conformance-python conformance-python-replay conformance-build

# Build conformance test runner
conformance-build:
Expand All @@ -382,11 +382,27 @@ conformance-go: conformance-build
@echo "==> Running Go conformance tests..."
cd conformance/runner/go && ./conformance-runner

# Run Go wire-replay against snapshots written by the TS live runner.
# Required env: WIRE_REPLAY_DIR, BASECAMP_BACKEND. Opt-in: not in `make check`.
conformance-go-replay:
@echo "==> Running Go wire-replay runner..."
@test -n "$$WIRE_REPLAY_DIR" || (echo "WIRE_REPLAY_DIR is required" >&2; exit 1)
@test -n "$$BASECAMP_BACKEND" || (echo "BASECAMP_BACKEND is required" >&2; exit 1)
cd conformance/runner/go && go run .

# Run Kotlin conformance tests
conformance-kotlin:
@echo "==> Running Kotlin conformance tests..."
cd kotlin && ./gradlew :conformance:run

# Run Kotlin wire-replay against snapshots written by the TS live runner.
# Required env: WIRE_REPLAY_DIR, BASECAMP_BACKEND. Opt-in: not in `make check`.
conformance-kotlin-replay:
@echo "==> Running Kotlin wire-replay runner..."
@test -n "$$WIRE_REPLAY_DIR" || (echo "WIRE_REPLAY_DIR is required" >&2; exit 1)
@test -n "$$BASECAMP_BACKEND" || (echo "BASECAMP_BACKEND is required" >&2; exit 1)
cd kotlin && ./gradlew --quiet :conformance:runReplay

# Run TypeScript conformance tests
conformance-typescript:
@echo "==> Running TypeScript conformance tests..."
Expand All @@ -408,11 +424,27 @@ conformance-ruby:
@echo "==> Running Ruby conformance tests..."
cd conformance/runner/ruby && bundle install --quiet && ruby runner.rb

# Run Ruby wire-replay against snapshots written by the TS live runner.
# Required env: WIRE_REPLAY_DIR, BASECAMP_BACKEND. Opt-in: not in `make check`.
conformance-ruby-replay:
@echo "==> Running Ruby wire-replay runner..."
@test -n "$$WIRE_REPLAY_DIR" || (echo "WIRE_REPLAY_DIR is required" >&2; exit 1)
@test -n "$$BASECAMP_BACKEND" || (echo "BASECAMP_BACKEND is required" >&2; exit 1)
cd conformance/runner/ruby && bundle install --quiet && ruby replay-runner.rb

# Run Python conformance tests
conformance-python:
@echo "==> Running Python conformance tests..."
cd conformance/runner/python && uv sync && uv run python runner.py

# Run Python wire-replay against snapshots written by the TS live runner.
# Required env: WIRE_REPLAY_DIR, BASECAMP_BACKEND. Opt-in: not in `make check`.
conformance-python-replay:
@echo "==> Running Python wire-replay runner..."
@test -n "$$WIRE_REPLAY_DIR" || (echo "WIRE_REPLAY_DIR is required" >&2; exit 1)
@test -n "$$BASECAMP_BACKEND" || (echo "BASECAMP_BACKEND is required" >&2; exit 1)
cd conformance/runner/python && uv sync && uv run python replay_runner.py

# Run all conformance tests
conformance: conformance-go conformance-kotlin conformance-typescript conformance-ruby conformance-python
@echo "==> Conformance tests passed"
Expand Down Expand Up @@ -678,13 +710,18 @@ help:
@echo " swift-clean Remove Swift build artifacts"
@echo ""
@echo "Conformance:"
@echo " conformance Run all conformance tests"
@echo " conformance-go Run Go conformance tests"
@echo " conformance-kotlin Run Kotlin conformance tests"
@echo " conformance-typescript Run TypeScript conformance tests"
@echo " conformance-ruby Run Ruby conformance tests"
@echo " conformance-python Run Python conformance tests"
@echo " conformance-build Build Go conformance test runner"
@echo " conformance Run all conformance tests"
@echo " conformance-go Run Go conformance tests"
@echo " conformance-go-replay Decode TS-captured wire snapshots through Go SDK"
@echo " conformance-kotlin Run Kotlin conformance tests"
@echo " conformance-kotlin-replay Decode TS-captured wire snapshots through Kotlin SDK"
@echo " conformance-typescript Run TypeScript conformance tests"
@echo " conformance-typescript-live Run TypeScript live canary against a real backend"
@echo " conformance-ruby Run Ruby conformance tests"
@echo " conformance-ruby-replay Decode TS-captured wire snapshots through Ruby SDK"
@echo " conformance-python Run Python conformance tests"
@echo " conformance-python-replay Decode TS-captured wire snapshots through Python SDK"
@echo " conformance-build Build Go conformance test runner"
@echo ""
@echo "Ruby SDK:"
@echo " rb-generate Generate types and metadata from OpenAPI"
Expand Down
23 changes: 23 additions & 0 deletions conformance/runner/go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,29 @@ type TestResult struct {
}

func main() {
// Wire-replay mode gate: when WIRE_REPLAY_DIR is set, dispatch to
// the replay runner (replay_runner.go) and exit. The replay runner
// consumes wire snapshots written by the canonical TS live runner;
// see conformance/runner/typescript/live-runner.test.ts. Mock mode
// (the rest of this function) runs only when the gate is unset.
if dir := os.Getenv("WIRE_REPLAY_DIR"); dir != "" {
backend := os.Getenv("BASECAMP_BACKEND")
if backend == "" {
fmt.Fprintln(os.Stderr, "BASECAMP_BACKEND is required when WIRE_REPLAY_DIR is set")
os.Exit(1)
}
// Match the existing relative-path convention in this file: the
// runner is invoked with cwd = conformance/runner/go.
fixturePath := filepath.Join("..", "..", "tests", "live-my-surface.json")
openapiPath := filepath.Join("..", "..", "..", "openapi.json")
runner, err := NewReplayRunner(dir, backend, fixturePath, openapiPath)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Exit(runner.Run())
}

testsDir := filepath.Join("..", "..", "tests")

files, err := filepath.Glob(filepath.Join(testsDir, "*.json"))
Expand Down
Loading
Loading