Skip to content

Commit ecfaf35

Browse files
committed
test: run the JS SDK examples on the local runner
Add .github/scripts/js_examples/run.py. It runs the JS SDK's example tests against the testing package's local runner, and exits non-zero on any failure. It uses a JS SDK checkout from --js-dir or JS_SDK_DIR, or clones JS main. Run it with hatch run dev-testing:js-examples. run.py serves WebRunner in a child process, so each server session starts with no runner state. A small proxy on a thread turns the tests' Lambda Invoke into POST /start-durable-execution. The Lambda shim runs each function on reusable worker threads. When an invocation passes the function's Timeout, the shim terminates the worker, as Lambda does. step/interrupted-no-retry depends on this. The js-examples workflow runs the harness on changes to the testing package. All 121 test files with cloud tests pass in about 2.5 minutes on 4 CPUs.
1 parent 631b78d commit ecfaf35

10 files changed

Lines changed: 2477 additions & 1 deletion

File tree

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
# JS examples against the local runner
2+
3+
The JS SDK repository ([aws/aws-durable-execution-sdk-js]) has about 125
4+
example tests. Each test runs a durable function and checks its result and
5+
history. The JS repository runs them against real Lambda. This directory runs
6+
the same tests against the Python local runner in
7+
`packages/aws-durable-execution-sdk-python-testing`. Everything runs on your
8+
machine. No AWS account or credentials are needed.
9+
10+
Run it after you change the testing package. The same script runs in CI
11+
(`.github/workflows/js-examples.yml`) on pull requests that touch the testing
12+
package.
13+
14+
[aws/aws-durable-execution-sdk-js]: https://github.com/aws/aws-durable-execution-sdk-js
15+
16+
## Quick start
17+
18+
You need `git`, [hatch](https://hatch.pypa.io/), and Node.js 22 or newer with
19+
`npm`. Run from the repository root:
20+
21+
```sh
22+
hatch run dev-testing:js-examples # every example, about 2.5 minutes
23+
hatch run dev-testing:js-examples invoke # only tests whose path matches "invoke"
24+
```
25+
26+
`dev-testing` is the hatch environment for the testing package. It has the SDK
27+
and the testing package from your checkout, installed in editable mode, so a
28+
run always tests your current source.
29+
30+
To use a JS SDK checkout you already have, pass it with `--js-dir`, or set
31+
`JS_SDK_DIR` once in your shell:
32+
33+
```sh
34+
export JS_SDK_DIR=~/github/aws-durable-execution-sdk-js
35+
hatch run dev-testing:js-examples invoke
36+
```
37+
38+
The harness uses that checkout as it is. It does not fetch, switch branches or
39+
clone. It runs `npm ci` and builds the example bundles when the checkout's
40+
source differs from the last build: a new commit, or an uncommitted change
41+
to a tracked or untracked file.
42+
43+
Without `--js-dir`, the harness clones the JS SDK's `main` branch into
44+
`~/.cache/dex-js-examples/js-sdk` on the first run, and builds it. Each later
45+
run fetches `main` again, and rebuilds only when `main` has moved.
46+
47+
The last lines of the output give the result:
48+
49+
```text
50+
[js-examples] selected 125 test files: 121 passed (0 on retry), 0 failed, 4 have no cloud tests, 0 not run
51+
```
52+
53+
The script exits 0 when every selected test passes and 1 otherwise. It exits
54+
2 when setup fails, for example when the interpreter running it does not have
55+
the testing package from this checkout.
56+
57+
## Common tasks
58+
59+
Each command below follows `hatch run dev-testing:js-examples`.
60+
61+
| Task | Options |
62+
| --- | --- |
63+
| Run tests whose path matches a regex | `step/ 'wait-for-callback/.*heartbeat'` |
64+
| Test against another JS SDK branch, tag or commit | `--js-ref v1.2.0` |
65+
| Use your own JS SDK checkout | `--js-dir ~/github/aws-durable-execution-sdk-js` |
66+
| Skip the JS build for that checkout | `--js-dir ~/github/aws-durable-execution-sdk-js --no-build` |
67+
| Force a JS rebuild | `--rebuild` |
68+
| See runner debug logs | `--log-level DEBUG hello-world` |
69+
| Pass options to jest | `invoke --jest-args='--verbose'` |
70+
| All options | `--help` |
71+
72+
`--js-ref` works only with the cloned JS SDK, so it cannot be combined with
73+
`--js-dir` or `JS_SDK_DIR`. With `--js-dir`, the harness writes only build
74+
output into your checkout. Its lock and build stamp live in
75+
`~/.cache/dex-js-examples`.
76+
77+
## When a test fails
78+
79+
Each run writes its files to `~/.cache/dex-js-examples/out/`, or to the
80+
directory given with `--out`. The run empties that directory first. So it
81+
refuses a directory that has files in it and was not created by an earlier
82+
run:
83+
84+
| File | Contents |
85+
| --- | --- |
86+
| `report.json` | Passed, failed, flaky and skipped test files |
87+
| `runner.log` | The Python local runner, which is the code under test |
88+
| `shim.log` | One line per handler invocation: request ID, function, duration, outcome |
89+
| `jest.json`, `retry1.json` | Raw jest results, for the first run and the retry |
90+
| `maps/` | The function maps generated from the examples' `template.yml` |
91+
92+
To investigate one example, run it alone with debug logs:
93+
94+
```sh
95+
hatch run dev-testing:js-examples --log-level DEBUG --retries 0 --jest-args='--verbose' step/interrupted-no-retry
96+
```
97+
98+
Add `--dump-dir /tmp/dump` to also save every history, state and checkpoint
99+
response the tests read.
100+
101+
A test file that fails is run once more on fresh servers. If it then passes,
102+
the run succeeds and the file is reported as `FLAKY`. A few examples race real
103+
timers, for example parallel branches that must checkpoint within
104+
milliseconds of each other, and they can fail on a loaded machine. Use
105+
`--retries 0` to see every failure.
106+
107+
## How it works
108+
109+
The Python local runner is the backend, in place of the Lambda service. Three
110+
helpers run around it, on the loopback, on free ports chosen for each run:
111+
112+
```text
113+
jest ----> invoke_proxy.py ----> local runner ----> lambda-shim.cjs
114+
(test (starts executions) (the code under (runs the JS handlers,
115+
driver) test) like the Lambda runtime)
116+
```
117+
118+
1. **jest** runs the JS example tests unchanged, in the mode the JS repository
119+
uses against real Lambda (`NODE_ENV=integration`). Each test is a client:
120+
it starts an execution, then reads history and state, and some tests send
121+
callbacks. `LAMBDA_ENDPOINT` points the tests' Lambda client at the proxy.
122+
2. **invoke_proxy.py** exists because the tests start an execution with Lambda
123+
`Invoke`, and the local runner has no `Invoke` route. The proxy turns that
124+
call into the runner's `POST /start-durable-execution`, and returns the
125+
execution ARN in the header the JS SDK reads. It forwards every other
126+
request to the runner unchanged.
127+
3. **The local runner** runs the executions. To run a handler, it calls
128+
Lambda `Invoke` on its Lambda endpoint, which is the shim. It resolves
129+
chained-invoke targets from the function configs `run.py` gives it.
130+
4. **lambda-shim.cjs** hosts the built example bundles, as the Lambda runtime
131+
does in AWS. It runs each function on worker threads. A worker handles one
132+
invocation at a time and is reused afterwards, like a warm Lambda sandbox.
133+
When an invocation runs past the function's `Timeout`, the shim returns
134+
Lambda's `Sandbox.Timedout` error and terminates the worker.
135+
`step/interrupted-no-retry` depends on this. Inside the handler, the durable
136+
SDK checkpoints straight to the runner, because the shim sets
137+
`AWS_ENDPOINT_URL_LAMBDA` to the runner's address.
138+
139+
`run.py` starts the runner with `WebRunner` from the testing package, in a
140+
child process, so each server session gets a runner with no state left from
141+
an earlier one. A run has one session, plus one per retry. The proxy runs on a
142+
thread in `run.py`, and the shim and jest run as separate Node processes.
143+
Before the run, `run.py` reads the examples' `template.yml` and writes three maps: test file to
144+
function name (for jest), function name to bundle, timeout and environment
145+
(for the shim), and function name to `DurableConfig` (for the runner).
146+
147+
The runner emulates region `us-west-2` and account `123456789012`. The proxy
148+
and the shim use the same values. If they differ, the runner rejects chained
149+
invokes whose target ARN names another region or account.
150+
151+
## Which tests run
152+
153+
Every `*.test.ts` under the examples' `src/examples` is selected, except for
154+
three groups:
155+
156+
- `otel/` examples. They export spans to an OpenTelemetry collector, which the
157+
harness does not run.
158+
- Examples marked `localOnly` in the JS catalog. `template.yml` omits them,
159+
and the JS repository's integration run skips them too. The output reports
160+
how many were skipped.
161+
- Tests without "cloud" in their name. The integration jest config runs only
162+
those. A file with no such test is reported as "no cloud tests".
163+
164+
## Known failures
165+
166+
`known-failures.txt` lists test files that are expected to fail, one per line,
167+
with the reason after `#`. It is empty today. A listed file that fails does
168+
not fail the run. A listed file that passes does fail the run, so that the
169+
list stays accurate.
170+
171+
## A failure caused by a JS change
172+
173+
Every run tests against the JS SDK's `main` branch. So a run can fail because
174+
of a new JS commit, with no Python change. To check, run the failing test
175+
against an older JS commit:
176+
177+
```sh
178+
hatch run dev-testing:js-examples --js-ref <older JS commit> <failing test>
179+
```
180+
181+
If it passes there, the new JS commit exposed something the runner does not
182+
handle yet.
183+
184+
## Harness tests
185+
186+
`.github/scripts/tests/test_js_examples.py` tests the template parsing, test
187+
selection and result handling in `run.py`. It needs no JS SDK:
188+
189+
```sh
190+
hatch run dev-testing:python -m pytest .github/scripts/tests/test_js_examples.py
191+
```
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"""Accept the Lambda Invoke call the JS tests use to start an execution.
2+
3+
The JS test runner (CloudDurableTestRunner) starts an execution by calling
4+
Lambda Invoke on the function. It reads the execution ARN from the
5+
X-Amz-Durable-Execution-Arn response header. The local runner has no Invoke
6+
route. It starts executions with POST /start-durable-execution. So this
7+
proxy sits between jest and the runner:
8+
9+
* POST /2015-03-31/functions/{name}/invocations: the proxy calls
10+
POST /start-durable-execution on the runner, then answers with the new
11+
execution ARN in the X-Amz-Durable-Execution-Arn header.
12+
* Every other request, for example history, state and callbacks: the proxy
13+
forwards it to the runner unchanged and relays the response.
14+
15+
The proxy listens on the loopback only and does not check request
16+
signatures. Its account ID must match the one lambda-shim.cjs reports.
17+
Otherwise the runner rejects chained invokes whose target ARN names another
18+
account.
19+
20+
run.py serves a ProxyServer on a thread for each server session.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
import re
27+
import uuid
28+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
29+
from pathlib import Path
30+
from urllib.error import HTTPError, URLError
31+
from urllib.request import Request, urlopen
32+
33+
ACCOUNT_ID = "123456789012"
34+
EXECUTION_TIMEOUT_SECONDS = 300
35+
RETENTION_DAYS = 7
36+
37+
INVOKE_PATH = re.compile(r"^/2015-03-31/functions/(?P<name>[^/]+)/invocations/?$")
38+
# Hop-by-hop headers, and headers the proxy recomputes. They are not
39+
# forwarded.
40+
SKIPPED_HEADERS = {
41+
"connection",
42+
"keep-alive",
43+
"proxy-authenticate",
44+
"proxy-authorization",
45+
"te",
46+
"trailers",
47+
"transfer-encoding",
48+
"upgrade",
49+
"host",
50+
"content-length",
51+
}
52+
53+
54+
class ProxyServer(ThreadingHTTPServer):
55+
"""The HTTP server. Handlers read their settings from it as self.server."""
56+
57+
daemon_threads = True
58+
59+
def __init__(self, port: int, upstream: str, dump_dir: Path | None) -> None:
60+
super().__init__(("127.0.0.1", port), ProxyHandler)
61+
self.upstream = upstream.rstrip("/")
62+
self.dump_dir = dump_dir
63+
if dump_dir is not None:
64+
dump_dir.mkdir(parents=True, exist_ok=True)
65+
66+
67+
class ProxyHandler(BaseHTTPRequestHandler):
68+
# HTTP/1.0 closes the connection after each response, so each request
69+
# thread ends promptly. With HTTP/1.1 keep-alive, every idle client
70+
# connection would hold a thread and a file descriptor for the whole run.
71+
protocol_version = "HTTP/1.0"
72+
server: ProxyServer
73+
74+
def log_message(self, format: str, *args: object) -> None: # noqa: A002
75+
# The default logs every request to stderr. The run is too noisy for
76+
# that to help.
77+
return
78+
79+
def do_GET(self) -> None: # noqa: N802
80+
self.dispatch()
81+
82+
def do_POST(self) -> None: # noqa: N802
83+
self.dispatch()
84+
85+
def do_PUT(self) -> None: # noqa: N802
86+
self.dispatch()
87+
88+
def do_DELETE(self) -> None: # noqa: N802
89+
self.dispatch()
90+
91+
def dispatch(self) -> None:
92+
length = int(self.headers.get("Content-Length") or 0)
93+
body = self.rfile.read(length) if length else b""
94+
match = INVOKE_PATH.match(self.path.split("?", 1)[0])
95+
if self.command == "POST" and match:
96+
self.start_execution(match["name"], body)
97+
else:
98+
self.forward(body)
99+
100+
def start_execution(self, function_name: str, payload: bytes) -> None:
101+
start_input = {
102+
"AccountId": ACCOUNT_ID,
103+
"FunctionName": function_name,
104+
"FunctionQualifier": "$LATEST",
105+
"ExecutionName": f"{function_name}-{uuid.uuid4().hex}",
106+
"ExecutionTimeoutSeconds": EXECUTION_TIMEOUT_SECONDS,
107+
"ExecutionRetentionPeriodDays": RETENTION_DAYS,
108+
"InvocationId": str(uuid.uuid4()),
109+
# Lambda delivers an empty object to a handler invoked with no
110+
# payload. The runner would read an empty input as JSON null.
111+
"Input": payload.decode("utf-8") or "{}",
112+
}
113+
request = Request(
114+
f"{self.server.upstream}/start-durable-execution",
115+
data=json.dumps(start_input).encode("utf-8"),
116+
headers={"Content-Type": "application/json"},
117+
method="POST",
118+
)
119+
status, headers, data = self.call(request, timeout=30)
120+
# The runner answers a started execution with 201 Created.
121+
if not 200 <= status < 300:
122+
self.respond(status, headers, data)
123+
return
124+
arn = json.loads(data)["ExecutionArn"]
125+
self.respond(200, [("X-Amz-Durable-Execution-Arn", arn)], b"")
126+
127+
def forward(self, body: bytes) -> None:
128+
headers = {
129+
k: v for k, v in self.headers.items() if k.lower() not in SKIPPED_HEADERS
130+
}
131+
request = Request(
132+
f"{self.server.upstream}{self.path}",
133+
data=body or None,
134+
headers=headers,
135+
method=self.command,
136+
)
137+
status, response_headers, data = self.call(request, timeout=60)
138+
self.respond(status, response_headers, data)
139+
self.dump(data)
140+
141+
def call(
142+
self, request: Request, timeout: float
143+
) -> tuple[int, list[tuple[str, str]], bytes]:
144+
"""Send a request to the runner. Return status, headers and body.
145+
146+
An HTTP error status is returned like any other response. A runner
147+
that cannot be reached becomes a 502.
148+
"""
149+
try:
150+
with urlopen(request, timeout=timeout) as response: # noqa: S310
151+
return response.status, list(response.headers.items()), response.read()
152+
except HTTPError as exc:
153+
headers = list(exc.headers.items()) if exc.headers else []
154+
return exc.code, headers, exc.read()
155+
except URLError as exc:
156+
data = json.dumps({"errorMessage": str(exc)}).encode("utf-8")
157+
return 502, [("Content-Type", "application/json")], data
158+
159+
def respond(self, status: int, headers: list[tuple[str, str]], data: bytes) -> None:
160+
self.send_response(status)
161+
for key, value in headers:
162+
if key.lower() not in SKIPPED_HEADERS:
163+
self.send_header(key, value)
164+
self.send_header("Content-Length", str(len(data)))
165+
self.end_headers()
166+
self.wfile.write(data)
167+
168+
def dump(self, data: bytes) -> None:
169+
"""Append history, state and checkpoint bodies to --dump-dir, if set."""
170+
if self.server.dump_dir is None:
171+
return
172+
kind = self.path.split("?", 1)[0].rsplit("/", 1)[-1]
173+
if kind not in ("history", "state", "checkpoint"):
174+
return
175+
with (self.server.dump_dir / f"{kind}.ndjson").open("ab") as fh:
176+
fh.write(data.rstrip(b"\n") + b"\n")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Example test files that are expected to fail against the local runner.
2+
# One path per line, relative to the JS examples package, then "#" and the reason.
3+
# A listed file that passes also fails the run, so this list stays accurate.

0 commit comments

Comments
 (0)