From d284db5ef138146e360646db1c0187e3817a3dfc Mon Sep 17 00:00:00 2001 From: "localstack-platform[bot]" <301951621+localstack-platform[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:53:25 +0000 Subject: [PATCH 1/9] Add LocalStack CI workflow (localstack-app-emulators) --- .github/workflows/localstack-ci.yml | 72 +++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 .github/workflows/localstack-ci.yml diff --git a/.github/workflows/localstack-ci.yml b/.github/workflows/localstack-ci.yml new file mode 100644 index 0000000..7bafa95 --- /dev/null +++ b/.github/workflows/localstack-ci.yml @@ -0,0 +1,72 @@ +# Generated by the LocalStack platform for project "localstack-app-emulators". +# localstack-project-id: proj_5f62a1e8 +# Manage it from https://v2.app.localstack.cloud/projects/proj_5f62a1e8 - +# "Update CI workflow" there opens a fresh PR when the config changes. +# +# Requires a LOCALSTACK_AUTH_TOKEN repository secret - use a CI key +# from https://app.localstack.cloud/workspace/auth-tokens +name: LocalStack CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Install the lstk CLI + run: npm install -g @localstack/lstk + + - name: Install lstk-project + run: pip install "https://localstack-artifacts-internal.s3.eu-central-1.amazonaws.com/packages/lstk-project/lstk_project-0.1.1-py3-none-any.whl" + + - name: "Start LocalStack for localstack-app-emulators" + env: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + run: | + mkdir -p "$RUNNER_TEMP/lstk-stage" && exec > >(tee -a "$RUNNER_TEMP/lstk-stage/start.log") 2>&1 + lstk project start "localstack-app-emulators" + + # No test command configured on the platform yet - this step just + # verifies the emulator came up. Set one on the project, then use + # "Update CI workflow" to render it here. + - name: Verify LocalStack is healthy + run: curl -sf localhost:4566/_localstack/health + + - name: Show LocalStack logs + if: always() + run: docker logs --tail 200 localstack-aws || true + + - name: Generate a diagnostic report + if: failure() + run: curl -s localhost:4566/_localstack/diagnose | gzip -cf > diagnose.json.gz || true + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: localstack-diagnose + path: diagnose.json.gz + + - name: "Shut down LocalStack for localstack-app-emulators" + if: always() + continue-on-error: true + env: + LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + run: lstk project shutdown "localstack-app-emulators" --status ${{ job.status }} From 406d5c1eaec99455795279cb2013bb3b34616340 Mon Sep 17 00:00:00 2001 From: "localstack-platform[bot]" <301951621+localstack-platform[bot]@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:57:20 +0000 Subject: [PATCH 2/9] Update LocalStack CI workflow (localstack-app-emulators) --- .github/workflows/localstack-ci.yml | 36 ++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/.github/workflows/localstack-ci.yml b/.github/workflows/localstack-ci.yml index 7bafa95..309d347 100644 --- a/.github/workflows/localstack-ci.yml +++ b/.github/workflows/localstack-ci.yml @@ -3,8 +3,8 @@ # Manage it from https://v2.app.localstack.cloud/projects/proj_5f62a1e8 - # "Update CI workflow" there opens a fresh PR when the config changes. # -# Requires a LOCALSTACK_AUTH_TOKEN repository secret - use a CI key -# from https://app.localstack.cloud/workspace/auth-tokens +# Authenticates via GitHub Actions OIDC - no repository secret +# needed. Configured from the project's settings on the platform. name: LocalStack CI on: @@ -13,12 +13,40 @@ on: pull_request: workflow_dispatch: +permissions: + id-token: write + contents: read + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Authenticate to LocalStack via GitHub OIDC + env: + LOCALSTACK_OIDC_AUDIENCE: https://v2.api.localstack.cloud/github-oidc + LOCALSTACK_PLATFORM_API_URL: https://v2.api.localstack.cloud + LOCALSTACK_ORG_ID: "1e09b0c1" + LOCALSTACK_PROJECT: "proj_5f62a1e8" + run: | + OIDC_TOKEN=$(curl -sSf \ + -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${LOCALSTACK_OIDC_AUDIENCE}" \ + | jq -r .value) + + PAYLOAD=$(jq -n \ + --arg token "$OIDC_TOKEN" \ + --arg orgId "$LOCALSTACK_ORG_ID" \ + --arg project "$LOCALSTACK_PROJECT" \ + '{token: $token, orgId: $orgId, project: $project}') + + RESPONSE=$(curl -sSf -X POST "${LOCALSTACK_PLATFORM_API_URL}/v1/auth/github-oidc" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + + echo "LOCALSTACK_AUTH_TOKEN=$(echo "$RESPONSE" | jq -r .localstackAuthToken)" >> "$GITHUB_ENV" + - uses: actions/setup-python@v5 with: python-version: "3.12" @@ -38,8 +66,6 @@ jobs: run: pip install "https://localstack-artifacts-internal.s3.eu-central-1.amazonaws.com/packages/lstk-project/lstk_project-0.1.1-py3-none-any.whl" - name: "Start LocalStack for localstack-app-emulators" - env: - LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} run: | mkdir -p "$RUNNER_TEMP/lstk-stage" && exec > >(tee -a "$RUNNER_TEMP/lstk-stage/start.log") 2>&1 lstk project start "localstack-app-emulators" @@ -67,6 +93,4 @@ jobs: - name: "Shut down LocalStack for localstack-app-emulators" if: always() continue-on-error: true - env: - LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} run: lstk project shutdown "localstack-app-emulators" --status ${{ job.status }} From d17739373491b225049550091e3cae98b4706d7e Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Mon, 9 Nov 2026 22:52:44 +0100 Subject: [PATCH 3/9] ci: fold the LocalStack job into ci.yml and add integration tests The generated localstack-ci.yml becomes an integration job in ci.yml, with the same pinned actions and fork guard. The job drives the demo over HTTP against LocalStack: seed facts, the full apply to settle flow, out of order steps, recorded vendor calls and reset. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 136 ++++++++++ .github/workflows/localstack-ci.yml | 96 ------- .../integration/integration_test.go | 235 ++++++++++++++++++ 3 files changed, 371 insertions(+), 96 deletions(-) delete mode 100644 .github/workflows/localstack-ci.yml create mode 100644 demos/acme-commerce-lenders/integration/integration_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5fbad4..3413aa3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,3 +135,139 @@ jobs: curl -sf -o /dev/null "http://127.0.0.1:18080$script" # A missing file is a 404, not the app. test "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/assets/missing.js)" = 404 + + # Runs the demo against LocalStack and drives the whole flow over HTTP: seed + # the world, apply, accept, fund and settle. Needs the LocalStack platform's + # GitHub OIDC login, which a fork's pull request is not given. + # localstack-project-id: proj_5f62a1e8 (https://v2.app.localstack.cloud/projects/proj_5f62a1e8) + # Runtime config (emulator image, env vars, extensions) is fetched by + # `lstk project start` at run time, so change it on the platform, not here. + integration: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + id-token: write + contents: read + env: + KIT_ADDR: 127.0.0.1:18081 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version-file: web/.node-version + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.work + cache: false + + - name: Authenticate to LocalStack via GitHub OIDC + env: + LOCALSTACK_OIDC_AUDIENCE: https://v2.api.localstack.cloud/github-oidc + LOCALSTACK_PLATFORM_API_URL: https://v2.api.localstack.cloud + LOCALSTACK_ORG_ID: "1e09b0c1" + LOCALSTACK_PROJECT: "proj_5f62a1e8" + run: | + set -euo pipefail + OIDC_TOKEN=$(curl -sSf \ + -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${LOCALSTACK_OIDC_AUDIENCE}" \ + | jq -r .value) + + PAYLOAD=$(jq -n \ + --arg token "$OIDC_TOKEN" \ + --arg orgId "$LOCALSTACK_ORG_ID" \ + --arg project "$LOCALSTACK_PROJECT" \ + '{token: $token, orgId: $orgId, project: $project}') + + RESPONSE=$(curl -sSf -X POST "${LOCALSTACK_PLATFORM_API_URL}/v1/auth/github-oidc" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD") + + TOKEN=$(echo "$RESPONSE" | jq -r .localstackAuthToken) + echo "::add-mask::$TOKEN" + echo "LOCALSTACK_AUTH_TOKEN=$TOKEN" >> "$GITHUB_ENV" + + - name: Install lstk and lstk-project + run: | + npm install -g @localstack/lstk + pip install "https://localstack-artifacts-internal.s3.eu-central-1.amazonaws.com/packages/lstk-project/lstk_project-0.1.1-py3-none-any.whl" + + - name: Start LocalStack + run: | + mkdir -p "$RUNNER_TEMP/lstk-stage" && exec > >(tee -a "$RUNNER_TEMP/lstk-stage/start.log") 2>&1 + lstk project start "localstack-app-emulators" + + - name: Verify LocalStack is healthy + run: curl -sf localhost:4566/_localstack/health + + # The demo calls Stripe, Plaid and Xero. Any LocalStack does not host yet + # is built from WonderTwin and run beside it, the same as the usage guide + # says to do locally. An app emulator installs on its first call, so the + # health check below can take a while. + - name: Run the app emulators LocalStack does not host + run: | + set -euo pipefail + missing="" + for app in plaid xero; do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 120 \ + -H "Host: $app.localhost.localstack.cloud:4566" http://127.0.0.1:4566/admin/health || true)" + echo "$app on LocalStack: HTTP $code" + [ "$code" = 200 ] || missing="$missing $app" + done + [ -n "$missing" ] || exit 0 + git clone --depth 1 https://github.com/WonderTwin-AI/wondertwin "$RUNNER_TEMP/wondertwin" + port=5101 + for app in $missing; do + (cd "$RUNNER_TEMP/wondertwin" && go build -o "$RUNNER_TEMP/twin-$app" "./twin-$app/cmd/twin-$app") + "$RUNNER_TEMP/twin-$app" --port=$port > "$RUNNER_TEMP/twin-$app.log" 2>&1 & + echo "$(echo "$app" | tr a-z A-Z)_EMULATOR_URL=http://127.0.0.1:$port" >> "$GITHUB_ENV" + port=$((port + 1)) + done + + - name: Build the demo + run: | + set -euo pipefail + go build -o "$RUNNER_TEMP/kit" ./demos/acme-commerce-lenders/cmd/kit + + - name: Check the app emulators are ready + run: | + "$RUNNER_TEMP/kit" ready --state "$RUNNER_TEMP/state.json" + # ready may have to wait out a first-call install, so give it every chance + # before the flow starts. + + - name: Run the demo and its integration tests + run: | + set -euo pipefail + "$RUNNER_TEMP/kit" serve --addr "$KIT_ADDR" --state "$RUNNER_TEMP/state.json" > "$RUNNER_TEMP/kit.log" 2>&1 & + trap 'kill $!' EXIT + for _ in $(seq 1 40); do + curl -sf "http://$KIT_ADDR/api/status" >/dev/null && break + sleep 0.5 + done + cd demos/acme-commerce-lenders + KIT_URL="http://$KIT_ADDR" go test -tags integration -count=1 -v ./integration + + - name: Show the demo log and LocalStack logs + if: always() + run: | + cat "$RUNNER_TEMP/kit.log" || true + docker logs --tail 200 localstack-aws || true + + - name: Generate a LocalStack diagnostic report + if: failure() + run: curl -s localhost:4566/_localstack/diagnose | gzip -cf > diagnose.json.gz || true + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: failure() + with: + name: localstack-diagnose + path: diagnose.json.gz + if-no-files-found: ignore + + - name: Shut down LocalStack + if: always() + continue-on-error: true + run: lstk project shutdown "localstack-app-emulators" --status ${{ job.status }} diff --git a/.github/workflows/localstack-ci.yml b/.github/workflows/localstack-ci.yml deleted file mode 100644 index 309d347..0000000 --- a/.github/workflows/localstack-ci.yml +++ /dev/null @@ -1,96 +0,0 @@ -# Generated by the LocalStack platform for project "localstack-app-emulators". -# localstack-project-id: proj_5f62a1e8 -# Manage it from https://v2.app.localstack.cloud/projects/proj_5f62a1e8 - -# "Update CI workflow" there opens a fresh PR when the config changes. -# -# Authenticates via GitHub Actions OIDC - no repository secret -# needed. Configured from the project's settings on the platform. -name: LocalStack CI - -on: - push: - branches: [main] - pull_request: - workflow_dispatch: - -permissions: - id-token: write - contents: read - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Authenticate to LocalStack via GitHub OIDC - env: - LOCALSTACK_OIDC_AUDIENCE: https://v2.api.localstack.cloud/github-oidc - LOCALSTACK_PLATFORM_API_URL: https://v2.api.localstack.cloud - LOCALSTACK_ORG_ID: "1e09b0c1" - LOCALSTACK_PROJECT: "proj_5f62a1e8" - run: | - OIDC_TOKEN=$(curl -sSf \ - -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ - "${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=${LOCALSTACK_OIDC_AUDIENCE}" \ - | jq -r .value) - - PAYLOAD=$(jq -n \ - --arg token "$OIDC_TOKEN" \ - --arg orgId "$LOCALSTACK_ORG_ID" \ - --arg project "$LOCALSTACK_PROJECT" \ - '{token: $token, orgId: $orgId, project: $project}') - - RESPONSE=$(curl -sSf -X POST "${LOCALSTACK_PLATFORM_API_URL}/v1/auth/github-oidc" \ - -H "Content-Type: application/json" \ - -d "$PAYLOAD") - - echo "LOCALSTACK_AUTH_TOKEN=$(echo "$RESPONSE" | jq -r .localstackAuthToken)" >> "$GITHUB_ENV" - - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - - uses: actions/setup-node@v4 - with: - node-version: "22" - - - uses: actions/setup-go@v5 - with: - go-version: "1.26" - - - name: Install the lstk CLI - run: npm install -g @localstack/lstk - - - name: Install lstk-project - run: pip install "https://localstack-artifacts-internal.s3.eu-central-1.amazonaws.com/packages/lstk-project/lstk_project-0.1.1-py3-none-any.whl" - - - name: "Start LocalStack for localstack-app-emulators" - run: | - mkdir -p "$RUNNER_TEMP/lstk-stage" && exec > >(tee -a "$RUNNER_TEMP/lstk-stage/start.log") 2>&1 - lstk project start "localstack-app-emulators" - - # No test command configured on the platform yet - this step just - # verifies the emulator came up. Set one on the project, then use - # "Update CI workflow" to render it here. - - name: Verify LocalStack is healthy - run: curl -sf localhost:4566/_localstack/health - - - name: Show LocalStack logs - if: always() - run: docker logs --tail 200 localstack-aws || true - - - name: Generate a diagnostic report - if: failure() - run: curl -s localhost:4566/_localstack/diagnose | gzip -cf > diagnose.json.gz || true - - - uses: actions/upload-artifact@v4 - if: failure() - with: - name: localstack-diagnose - path: diagnose.json.gz - - - name: "Shut down LocalStack for localstack-app-emulators" - if: always() - continue-on-error: true - run: lstk project shutdown "localstack-app-emulators" --status ${{ job.status }} diff --git a/demos/acme-commerce-lenders/integration/integration_test.go b/demos/acme-commerce-lenders/integration/integration_test.go new file mode 100644 index 0000000..926c8b2 --- /dev/null +++ b/demos/acme-commerce-lenders/integration/integration_test.go @@ -0,0 +1,235 @@ +//go:build integration + +// Package integration drives a running demo end to end over HTTP, against real +// app emulators (hosted by LocalStack, or run by you). It is what CI runs after +// starting LocalStack. +// +// Run it with: +// +// make kit APP=acme-commerce-lenders ARGS=serve & # with LocalStack up +// KIT_URL=http://127.0.0.1:8080 go test -tags integration ./integration +// +// The tests share one seeded world, so they run in order and the last one +// resets it. Without KIT_URL they skip. +package integration + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/lender" + "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/world" +) + +var client = &http.Client{Timeout: 3 * time.Minute} + +func baseURL(t *testing.T) string { + t.Helper() + u := os.Getenv("KIT_URL") + if u == "" { + t.Skip("KIT_URL is not set; start the demo and set it to run the integration tests") + } + return strings.TrimRight(u, "/") +} + +// call sends a request to the demo and decodes a JSON response into out, which +// may be nil. It returns the HTTP status. +func call(t *testing.T, method, path string, out any) int { + t.Helper() + req, err := http.NewRequest(method, baseURL(t)+path, bytes.NewReader(nil)) + if err != nil { + t.Fatal(err) + } + resp, err := client.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if out != nil { + if err := json.Unmarshal(body, out); err != nil { + t.Fatalf("%s %s: status %d, body is not JSON: %s", method, path, resp.StatusCode, body) + } + } + return resp.StatusCode +} + +type status struct { + Ready bool `json:"ready"` + Seeded bool `json:"seeded"` + Emulators []struct { + Vendor string `json:"vendor"` + State string `json:"state"` + } `json:"emulators"` + Facts *world.Facts `json:"facts"` +} + +// resetWorld puts every app emulator back at the seeded start. +func resetWorld(t *testing.T) world.Facts { + t.Helper() + var f world.Facts + if code := call(t, "POST", "/api/reset", &f); code != http.StatusOK { + t.Fatalf("reset: status %d", code) + } + return f +} + +func TestSeededWorldIsReadyAndMatchesTheDocs(t *testing.T) { + baseURL(t) + resetWorld(t) + + var st status + if code := call(t, "GET", "/api/status", &st); code != http.StatusOK { + t.Fatalf("status: %d", code) + } + if !st.Ready || !st.Seeded || st.Facts == nil { + t.Fatalf("want a ready, seeded world, got %+v", st) + } + if len(st.Emulators) != 3 { + t.Fatalf("want stripe, plaid and xero, got %+v", st.Emulators) + } + + // The numbers in docs/usage.md. If an app emulator changes behavior, they move. + f := st.Facts + if f.Stripe.Orders != 941 || f.Stripe.GrossCents != 8108384 || f.Stripe.Payouts != 12 { + t.Errorf("stripe facts: %+v", f.Stripe) + } + if f.Xero.Invoices != 6 || f.Xero.OutstandingCents != 1615000 || f.Xero.OverdueCents != 540000 { + t.Errorf("xero facts: %+v", f.Xero) + } + if f.Plaid.AccountID == "" { + t.Errorf("plaid facts: %+v", f.Plaid) + } +} + +func TestApplyAcceptFundAndSettle(t *testing.T) { + baseURL(t) + resetWorld(t) + + var a lender.Application + if code := call(t, "POST", "/api/applications", &a); code != http.StatusCreated || a.Status != lender.Applied { + t.Fatalf("apply: %d %+v", code, a) + } + + for _, step := range []struct { + path string + want lender.Status + }{ + {"connect", lender.Connected}, + {"review", lender.Reviewed}, + {"offer", lender.Offered}, + {"accept", lender.Accepted}, + {"fund", lender.Funded}, + {"settle", lender.Settled}, + } { + if code := call(t, "POST", "/api/applications/"+a.ID+"/"+step.path, &a); code != http.StatusOK { + t.Fatalf("%s: status %d", step.path, code) + } + if a.Status != step.want { + t.Fatalf("after %s the application is %s, want %s", step.path, a.Status, step.want) + } + } + + if len(a.Connections) != 3 { + t.Errorf("want a connection per vendor, got %+v", a.Connections) + } + if a.Offer == nil || !a.Offer.Approved || a.Offer.AdvanceCents <= 0 { + t.Fatalf("want an approved offer, got %+v", a.Offer) + } + if a.Offer.TotalRepayCents != a.Offer.AdvanceCents+a.Offer.FeeCents { + t.Errorf("repay %d is not advance %d plus fee %d", a.Offer.TotalRepayCents, a.Offer.AdvanceCents, a.Offer.FeeCents) + } + + // The money moved through Stripe, and the same amount is booked in Xero. + fu := a.Funding + if fu == nil || fu.TransferID == "" || fu.PayoutID == "" || fu.AmountCents != a.Offer.AdvanceCents { + t.Fatalf("funding: %+v", fu) + } + se := a.Settlement + if se == nil || se.AmountCents != a.Offer.AdvanceCents || se.Reference != a.ID || se.BankTransactionID == "" { + t.Fatalf("settlement: %+v", se) + } + + // What the server keeps is what the last step returned. + var got lender.Application + if code := call(t, "GET", "/api/applications/"+a.ID, &got); code != http.StatusOK || got.Status != lender.Settled { + t.Fatalf("get: %d %+v", code, got) + } +} + +func TestStepsOutOfOrderAreRefused(t *testing.T) { + baseURL(t) + resetWorld(t) + + var a lender.Application + call(t, "POST", "/api/applications", &a) + if code := call(t, "POST", "/api/applications/"+a.ID+"/fund", nil); code != http.StatusConflict { + t.Errorf("fund before accept: status %d, want 409", code) + } + if code := call(t, "GET", "/api/applications/app-9999", nil); code != http.StatusNotFound { + t.Errorf("unknown application: status %d, want 404", code) + } +} + +func TestEveryVendorCallIsRecordedAsAnEmulatorAnswer(t *testing.T) { + baseURL(t) + resetWorld(t) + + var a lender.Application + call(t, "POST", "/api/applications", &a) + call(t, "POST", "/api/applications/"+a.ID+"/connect", &a) + call(t, "POST", "/api/applications/"+a.ID+"/review", &a) + + var ev struct { + Events []struct { + Vendor string `json:"vendor"` + Origin string `json:"origin"` + Status int `json:"status"` + Error string `json:"error"` + } `json:"events"` + } + if code := call(t, "GET", "/api/events", &ev); code != http.StatusOK { + t.Fatalf("events: %d", code) + } + seen := map[string]bool{} + for _, e := range ev.Events { + seen[e.Vendor] = true + if e.Origin != "emulator" { + t.Errorf("%s call has origin %q, want emulator", e.Vendor, e.Origin) + } + if e.Error != "" || e.Status >= 500 { + t.Errorf("%s call failed: %d %s", e.Vendor, e.Status, e.Error) + } + } + for _, v := range []string{"stripe", "plaid", "xero"} { + if !seen[v] { + t.Errorf("no recorded call to %s", v) + } + } +} + +// Last, because it leaves the world as it found it: a reset undoes a run. +func TestResetUndoesARun(t *testing.T) { + baseURL(t) + before := resetWorld(t) + + var a lender.Application + call(t, "POST", "/api/applications", &a) + call(t, "POST", "/api/applications/"+a.ID+"/connect", &a) + + after := resetWorld(t) + if after.Stripe.Orders != before.Stripe.Orders || after.Stripe.GrossCents != before.Stripe.GrossCents { + t.Errorf("the world changed across a reset: %+v then %+v", before.Stripe, after.Stripe) + } + var st status + call(t, "GET", "/api/status", &st) + if !st.Ready { + t.Errorf("not ready after a reset: %+v", st) + } +} From 8b5335f85e1e13e5a46d6a7d0a4e29377eaf71d0 Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Mon, 9 Nov 2026 23:00:36 +0100 Subject: [PATCH 4/9] ci: use only LocalStack-hosted app emulators in the integration job Drops the WonderTwin fallback and the custom port. The job now fails at the ready check if LocalStack does not host an app emulator. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 43 +++++++--------------------------------- 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3413aa3..6009b76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -149,8 +149,6 @@ jobs: permissions: id-token: write contents: read - env: - KIT_ADDR: 127.0.0.1:18081 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -203,52 +201,25 @@ jobs: - name: Verify LocalStack is healthy run: curl -sf localhost:4566/_localstack/health - # The demo calls Stripe, Plaid and Xero. Any LocalStack does not host yet - # is built from WonderTwin and run beside it, the same as the usage guide - # says to do locally. An app emulator installs on its first call, so the - # health check below can take a while. - - name: Run the app emulators LocalStack does not host - run: | - set -euo pipefail - missing="" - for app in plaid xero; do - code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 120 \ - -H "Host: $app.localhost.localstack.cloud:4566" http://127.0.0.1:4566/admin/health || true)" - echo "$app on LocalStack: HTTP $code" - [ "$code" = 200 ] || missing="$missing $app" - done - [ -n "$missing" ] || exit 0 - git clone --depth 1 https://github.com/WonderTwin-AI/wondertwin "$RUNNER_TEMP/wondertwin" - port=5101 - for app in $missing; do - (cd "$RUNNER_TEMP/wondertwin" && go build -o "$RUNNER_TEMP/twin-$app" "./twin-$app/cmd/twin-$app") - "$RUNNER_TEMP/twin-$app" --port=$port > "$RUNNER_TEMP/twin-$app.log" 2>&1 & - echo "$(echo "$app" | tr a-z A-Z)_EMULATOR_URL=http://127.0.0.1:$port" >> "$GITHUB_ENV" - port=$((port + 1)) - done - - name: Build the demo - run: | - set -euo pipefail - go build -o "$RUNNER_TEMP/kit" ./demos/acme-commerce-lenders/cmd/kit + run: go build -o "$RUNNER_TEMP/kit" ./demos/acme-commerce-lenders/cmd/kit + # Stripe, Plaid and Xero are all hosted by LocalStack. An app emulator + # installs on its first call, and this is that call. - name: Check the app emulators are ready run: | "$RUNNER_TEMP/kit" ready --state "$RUNNER_TEMP/state.json" - # ready may have to wait out a first-call install, so give it every chance - # before the flow starts. - - name: Run the demo and its integration tests + - name: Run the integration tests against the demo run: | - set -euo pipefail - "$RUNNER_TEMP/kit" serve --addr "$KIT_ADDR" --state "$RUNNER_TEMP/state.json" > "$RUNNER_TEMP/kit.log" 2>&1 & + "$RUNNER_TEMP/kit" serve --state "$RUNNER_TEMP/state.json" > "$RUNNER_TEMP/kit.log" 2>&1 & trap 'kill $!' EXIT for _ in $(seq 1 40); do - curl -sf "http://$KIT_ADDR/api/status" >/dev/null && break + curl -sf http://127.0.0.1:8080/api/status >/dev/null && break sleep 0.5 done cd demos/acme-commerce-lenders - KIT_URL="http://$KIT_ADDR" go test -tags integration -count=1 -v ./integration + KIT_URL=http://127.0.0.1:8080 go test -tags integration -count=1 -v ./integration - name: Show the demo log and LocalStack logs if: always() From a15ff6ec1945fdda8197344f43d4f795ddbcc559 Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Mon, 9 Nov 2026 23:01:17 +0100 Subject: [PATCH 5/9] ci: run the demo inside the integration test The test starts the demo in-process, so the job no longer builds the kit, runs it in the background or polls for it. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 36 +++------- .../integration/integration_test.go | 69 +++++++++++++------ 2 files changed, 56 insertions(+), 49 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6009b76..9a743ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,34 +198,16 @@ jobs: mkdir -p "$RUNNER_TEMP/lstk-stage" && exec > >(tee -a "$RUNNER_TEMP/lstk-stage/start.log") 2>&1 lstk project start "localstack-app-emulators" - - name: Verify LocalStack is healthy - run: curl -sf localhost:4566/_localstack/health - - - name: Build the demo - run: go build -o "$RUNNER_TEMP/kit" ./demos/acme-commerce-lenders/cmd/kit - - # Stripe, Plaid and Xero are all hosted by LocalStack. An app emulator - # installs on its first call, and this is that call. - - name: Check the app emulators are ready - run: | - "$RUNNER_TEMP/kit" ready --state "$RUNNER_TEMP/state.json" - - - name: Run the integration tests against the demo - run: | - "$RUNNER_TEMP/kit" serve --state "$RUNNER_TEMP/state.json" > "$RUNNER_TEMP/kit.log" 2>&1 & - trap 'kill $!' EXIT - for _ in $(seq 1 40); do - curl -sf http://127.0.0.1:8080/api/status >/dev/null && break - sleep 0.5 - done - cd demos/acme-commerce-lenders - KIT_URL=http://127.0.0.1:8080 go test -tags integration -count=1 -v ./integration - - - name: Show the demo log and LocalStack logs + # The test starts the demo itself. Stripe, Plaid and Xero are hosted by + # LocalStack, and each installs on its first call, which is the test's + # first reset. + - name: Run the integration tests + working-directory: demos/acme-commerce-lenders + run: go test -tags integration -count=1 -v ./integration + + - name: Show the LocalStack logs if: always() - run: | - cat "$RUNNER_TEMP/kit.log" || true - docker logs --tail 200 localstack-aws || true + run: docker logs --tail 200 localstack-aws || true - name: Generate a LocalStack diagnostic report if: failure() diff --git a/demos/acme-commerce-lenders/integration/integration_test.go b/demos/acme-commerce-lenders/integration/integration_test.go index 926c8b2..01a9397 100644 --- a/demos/acme-commerce-lenders/integration/integration_test.go +++ b/demos/acme-commerce-lenders/integration/integration_test.go @@ -1,48 +1,78 @@ //go:build integration -// Package integration drives a running demo end to end over HTTP, against real -// app emulators (hosted by LocalStack, or run by you). It is what CI runs after -// starting LocalStack. +// Package integration runs the demo against LocalStack and drives it over HTTP. +// It starts the demo in the test process, so it needs only LocalStack running +// with the Stripe, Plaid and Xero app emulators: // -// Run it with: -// -// make kit APP=acme-commerce-lenders ARGS=serve & # with LocalStack up -// KIT_URL=http://127.0.0.1:8080 go test -tags integration ./integration +// lstk +// go test -tags integration ./integration // // The tests share one seeded world, so they run in order and the last one -// resets it. Without KIT_URL they skip. +// resets it. package integration import ( "bytes" + "context" "encoding/json" + "fmt" "io" + "net" "net/http" "os" - "strings" + "path/filepath" "testing" "time" + "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/internal/cli" "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/lender" "github.com/localstack-samples/localstack-app-emulators/demos/acme-commerce-lenders/world" ) -var client = &http.Client{Timeout: 3 * time.Minute} +var ( + client = &http.Client{Timeout: 3 * time.Minute} + base string +) -func baseURL(t *testing.T) string { - t.Helper() - u := os.Getenv("KIT_URL") - if u == "" { - t.Skip("KIT_URL is not set; start the demo and set it to run the integration tests") +func TestMain(m *testing.M) { os.Exit(run(m)) } + +func run(m *testing.M) int { + dir, err := os.MkdirTemp("", "demo-integration") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + defer os.RemoveAll(dir) + + // A free port, so the tests never collide with a demo you have running. + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + fmt.Fprintln(os.Stderr, err) + return 1 + } + addr := l.Addr().String() + l.Close() + base = "http://" + addr + + go cli.Run(context.Background(), []string{"serve", "--addr", addr, "--state", filepath.Join(dir, "state.json")}, os.Getenv, os.Stderr, os.Stderr) + for i := 0; ; i++ { + if resp, err := client.Get(base + "/api/status"); err == nil { + resp.Body.Close() + break + } else if i == 50 { + fmt.Fprintln(os.Stderr, "the demo did not start:", err) + return 1 + } + time.Sleep(100 * time.Millisecond) } - return strings.TrimRight(u, "/") + return m.Run() } // call sends a request to the demo and decodes a JSON response into out, which // may be nil. It returns the HTTP status. func call(t *testing.T, method, path string, out any) int { t.Helper() - req, err := http.NewRequest(method, baseURL(t)+path, bytes.NewReader(nil)) + req, err := http.NewRequest(method, base+path, bytes.NewReader(nil)) if err != nil { t.Fatal(err) } @@ -81,7 +111,6 @@ func resetWorld(t *testing.T) world.Facts { } func TestSeededWorldIsReadyAndMatchesTheDocs(t *testing.T) { - baseURL(t) resetWorld(t) var st status @@ -109,7 +138,6 @@ func TestSeededWorldIsReadyAndMatchesTheDocs(t *testing.T) { } func TestApplyAcceptFundAndSettle(t *testing.T) { - baseURL(t) resetWorld(t) var a lender.Application @@ -164,7 +192,6 @@ func TestApplyAcceptFundAndSettle(t *testing.T) { } func TestStepsOutOfOrderAreRefused(t *testing.T) { - baseURL(t) resetWorld(t) var a lender.Application @@ -178,7 +205,6 @@ func TestStepsOutOfOrderAreRefused(t *testing.T) { } func TestEveryVendorCallIsRecordedAsAnEmulatorAnswer(t *testing.T) { - baseURL(t) resetWorld(t) var a lender.Application @@ -216,7 +242,6 @@ func TestEveryVendorCallIsRecordedAsAnEmulatorAnswer(t *testing.T) { // Last, because it leaves the world as it found it: a reset undoes a run. func TestResetUndoesARun(t *testing.T) { - baseURL(t) before := resetWorld(t) var a lender.Application From 6d5fcf191f45d911d151e129f9b85d6ec921ede3 Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Mon, 9 Nov 2026 23:06:58 +0100 Subject: [PATCH 6/9] ci: show why the integration reset fails and find the LocalStack logs Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 4 +++- .../integration/integration_test.go | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a743ba..1cd4bc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,7 +207,9 @@ jobs: - name: Show the LocalStack logs if: always() - run: docker logs --tail 200 localstack-aws || true + run: | + docker ps -a + for c in $(docker ps -aq); do docker logs --tail 200 "$c" 2>&1 || true; done - name: Generate a LocalStack diagnostic report if: failure() diff --git a/demos/acme-commerce-lenders/integration/integration_test.go b/demos/acme-commerce-lenders/integration/integration_test.go index 01a9397..5352a33 100644 --- a/demos/acme-commerce-lenders/integration/integration_test.go +++ b/demos/acme-commerce-lenders/integration/integration_test.go @@ -103,11 +103,14 @@ type status struct { // resetWorld puts every app emulator back at the seeded start. func resetWorld(t *testing.T) world.Facts { t.Helper() - var f world.Facts + var f struct { + world.Facts + Error string `json:"error"` + } if code := call(t, "POST", "/api/reset", &f); code != http.StatusOK { - t.Fatalf("reset: status %d", code) + t.Fatalf("reset: status %d: %s", code, f.Error) } - return f + return f.Facts } func TestSeededWorldIsReadyAndMatchesTheDocs(t *testing.T) { From ab1277f4f757c16cbdafbcc2ae7d6ca2cbf8c470 Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Tue, 10 Nov 2026 00:05:07 +0100 Subject: [PATCH 7/9] ci: probe the app emulators in the integration job Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cd4bc0..ff98957 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -198,6 +198,16 @@ jobs: mkdir -p "$RUNNER_TEMP/lstk-stage" && exec > >(tee -a "$RUNNER_TEMP/lstk-stage/start.log") 2>&1 lstk project start "localstack-app-emulators" + # Shows what the sandbox serves, so a "not-included" failure below says + # whether the app emulators are missing or the Host header never arrives. + - name: Probe the app emulators + run: | + curl -s localhost:4566/_localstack/info || true; echo + for app in stripe plaid xero; do + echo "$app via Host header:" + curl -s -w ' HTTP %{http_code}\n' -H "Host: $app.localhost.localstack.cloud:4566" localhost:4566/admin/health || true + done + # The test starts the demo itself. Stripe, Plaid and Xero are hosted by # LocalStack, and each installs on its first call, which is the test's # first reset. From 0f5dcf091369c89181d165fd4cb26e339ea6a1e7 Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Tue, 10 Nov 2026 01:56:08 +0100 Subject: [PATCH 8/9] ci: run the app emulators LocalStack does not host Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff98957..415a824 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,9 +208,31 @@ jobs: curl -s -w ' HTTP %{http_code}\n' -H "Host: $app.localhost.localstack.cloud:4566" localhost:4566/admin/health || true done - # The test starts the demo itself. Stripe, Plaid and Xero are hosted by - # LocalStack, and each installs on its first call, which is the test's - # first reset. + # Any app emulator this LocalStack release does not host is built from + # WonderTwin and run beside it, and the demo is pointed at it through + # _EMULATOR_URL. Plaid and Xero are in no LocalStack release yet. + - name: Run the app emulators LocalStack does not host + run: | + set -euo pipefail + missing="" + for app in stripe plaid xero; do + code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 120 \ + -H "Host: $app.localhost.localstack.cloud:4566" http://127.0.0.1:4566/admin/health || true)" + echo "$app on LocalStack: HTTP $code" + [ "$code" = 200 ] || missing="$missing $app" + done + [ -n "$missing" ] || exit 0 + git clone --depth 1 https://github.com/WonderTwin-AI/wondertwin "$RUNNER_TEMP/wondertwin" + port=5101 + for app in $missing; do + (cd "$RUNNER_TEMP/wondertwin" && go build -o "$RUNNER_TEMP/twin-$app" "./twin-$app/cmd/twin-$app") + "$RUNNER_TEMP/twin-$app" --port=$port > "$RUNNER_TEMP/twin-$app.log" 2>&1 & + echo "$(echo "$app" | tr a-z A-Z)_EMULATOR_URL=http://127.0.0.1:$port" >> "$GITHUB_ENV" + port=$((port + 1)) + done + + # The test starts the demo itself, and it uses LocalStack for every app + # emulator the step above did not replace. - name: Run the integration tests working-directory: demos/acme-commerce-lenders run: go test -tags integration -count=1 -v ./integration From cabf9c41366d392b14fd5da819fb596bac2f5d21 Mon Sep 17 00:00:00 2001 From: Waldemar Hummer Date: Tue, 10 Nov 2026 23:01:01 +0100 Subject: [PATCH 9/9] ci: address review feedback on the integration job - Pin WonderTwin to a commit instead of cloning main, so upstream changes cannot turn this repo's CI red. - Start the twins without LOCALSTACK_AUTH_TOKEN. - Require INTEGRATION_ALLOW_RESET=1 to run the integration tests, since they reset every app emulator they talk to. - Note in the job comment that all three emulators come from WonderTwin today. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 17 +++++++++++++++-- .../integration/integration_test.go | 10 +++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 415a824..3ecfda4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,6 +142,10 @@ jobs: # localstack-project-id: proj_5f62a1e8 (https://v2.app.localstack.cloud/projects/proj_5f62a1e8) # Runtime config (emulator image, env vars, extensions) is fetched by # `lstk project start` at run time, so change it on the platform, not here. + # + # Today LocalStack answers 404 for stripe, plaid and xero, so all three app + # emulators come from the WonderTwin builds below and LocalStack is started + # but not exercised. This is not LocalStack-hosted coverage yet. integration: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest @@ -149,6 +153,10 @@ jobs: permissions: id-token: write contents: read + env: + # The WonderTwin commit the twins are built from. The test asserts exact + # numbers, so bump this deliberately, in the PR that changes them. + WONDERTWIN_REF: 8f1b30fcaf8d9f7c97278eff1b29ff476e8eaa26 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -222,11 +230,14 @@ jobs: [ "$code" = 200 ] || missing="$missing $app" done [ -n "$missing" ] || exit 0 - git clone --depth 1 https://github.com/WonderTwin-AI/wondertwin "$RUNNER_TEMP/wondertwin" + git init -q "$RUNNER_TEMP/wondertwin" + git -C "$RUNNER_TEMP/wondertwin" fetch -q --depth 1 https://github.com/WonderTwin-AI/wondertwin "$WONDERTWIN_REF" + git -C "$RUNNER_TEMP/wondertwin" checkout -q FETCH_HEAD port=5101 for app in $missing; do (cd "$RUNNER_TEMP/wondertwin" && go build -o "$RUNNER_TEMP/twin-$app" "./twin-$app/cmd/twin-$app") - "$RUNNER_TEMP/twin-$app" --port=$port > "$RUNNER_TEMP/twin-$app.log" 2>&1 & + # The twins never need the LocalStack token, so they don't get it. + env -u LOCALSTACK_AUTH_TOKEN "$RUNNER_TEMP/twin-$app" --port=$port > "$RUNNER_TEMP/twin-$app.log" 2>&1 & echo "$(echo "$app" | tr a-z A-Z)_EMULATOR_URL=http://127.0.0.1:$port" >> "$GITHUB_ENV" port=$((port + 1)) done @@ -235,6 +246,8 @@ jobs: # emulator the step above did not replace. - name: Run the integration tests working-directory: demos/acme-commerce-lenders + env: + INTEGRATION_ALLOW_RESET: "1" run: go test -tags integration -count=1 -v ./integration - name: Show the LocalStack logs diff --git a/demos/acme-commerce-lenders/integration/integration_test.go b/demos/acme-commerce-lenders/integration/integration_test.go index 5352a33..e843f3b 100644 --- a/demos/acme-commerce-lenders/integration/integration_test.go +++ b/demos/acme-commerce-lenders/integration/integration_test.go @@ -5,7 +5,11 @@ // with the Stripe, Plaid and Xero app emulators: // // lstk -// go test -tags integration ./integration +// INTEGRATION_ALLOW_RESET=1 go test -tags integration ./integration +// +// The tests reset every app emulator they talk to: LocalStack's shared Stripe +// emulator and any emulator at _EMULATOR_URL. They refuse to run +// unless INTEGRATION_ALLOW_RESET=1 is set. // // The tests share one seeded world, so they run in order and the last one // resets it. @@ -37,6 +41,10 @@ var ( func TestMain(m *testing.M) { os.Exit(run(m)) } func run(m *testing.M) int { + if os.Getenv("INTEGRATION_ALLOW_RESET") != "1" { + fmt.Fprintln(os.Stderr, "this resets every app emulator it talks to; set INTEGRATION_ALLOW_RESET=1 to run") + return 1 + } dir, err := os.MkdirTemp("", "demo-integration") if err != nil { fmt.Fprintln(os.Stderr, err)