Skip to content

Commit 83a190d

Browse files
authored
Merge pull request #220 from WebAssembly/http-end-to-end
Add new HTTP end-to-end test
2 parents 43f4e5c + 860e48c commit 83a190d

11 files changed

Lines changed: 323 additions & 37 deletions

File tree

adapters/wasmtime.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ def _add_wasi_version_options(argv: List[str], wasi_version: str, proposals: Lis
6363
flags_from_proposals += ",http"
6464
if "sockets" in proposals:
6565
flags_from_proposals += ",inherit-network"
66+
if "http/service" in proposals:
67+
flags_from_proposals += ",cli"
68+
argv[splice_pos:splice_pos] = ["serve", "--addr=127.0.0.1:0"]
69+
splice_pos += 1
6670

6771
argv[splice_pos:splice_pos] = ["-Wcomponent-model-async",
6872
f"-Sp3{flags_from_proposals}"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
colorama>=0.4.3
2+
requests

test-runner/requirements/dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ mypy==0.991
44
pylint==3.3.8
55
pytest==6.2.5
66
coverage==6.3.3
7+
types-requests

test-runner/tests/test_test_case.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import signal
2+
13
from json import JSONDecodeError
24
from pathlib import Path
35
from unittest.mock import Mock, patch, mock_open
46

57
import pytest
68

79
from wasi_test_runner.test_case import (
8-
Config, Failure, Result, Run, Wait, Read, Write, Connect, Send, Recv,
10+
Config, Failure, Result,
11+
Run, Wait, Read, Write, Connect, Send, Recv, Request, Response, Kill,
912
ProtocolType, WasiProposal, TestCaseValidator
1013
)
1114

@@ -196,6 +199,23 @@ def test_recv_from_config_with_default_payload() -> None:
196199
assert recv.payload == ""
197200

198201

202+
def test_request_from_config() -> None:
203+
req = Request.from_config({"method": "POST", "response": {"body": "hey"}})
204+
assert req.method == "POST"
205+
assert req.path == "/"
206+
assert req.response == Response(status=200, headers={}, body="hey")
207+
208+
209+
def test_kill_from_config_with_signal() -> None:
210+
kill = Kill.from_config({"signal": "SIGABRT"})
211+
assert kill.signal == signal.SIGABRT
212+
213+
214+
def test_kill_from_config_with_defaults() -> None:
215+
kill = Kill.from_config({})
216+
assert kill.signal == signal.SIGTERM
217+
218+
199219
@patch(
200220
"builtins.open",
201221
new_callable=mock_open,
@@ -304,6 +324,12 @@ def test_dry_run_recv_before_run() -> None:
304324
validate_config(config)
305325

306326

327+
def test_dry_run_request_before_run() -> None:
328+
config = Config(operations=[Request.from_config({})])
329+
with pytest.raises(AssertionError, match="no process running"):
330+
validate_config(config)
331+
332+
307333
def test_dry_run_multiple_errors() -> None:
308334
config = Config(operations=[Read(), Wait(), Run(), Run()])
309335
with pytest.raises(AssertionError) as exc_info:

test-runner/wasi_test_runner/test_case.py

Lines changed: 140 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import json
3+
import signal
34
from pathlib import Path
45
from enum import Enum, StrEnum, auto
56
from typing import List, NamedTuple, TypeVar, Type, Dict, Any, Set, Tuple
@@ -10,7 +11,8 @@
1011

1112

1213
# Supported operations
13-
SUPPORTED_OPERATIONS = {"run", "wait", "read", "write", "connect", "send", "recv"}
14+
SUPPORTED_OPERATIONS = {"run", "wait", "read", "write", "connect",
15+
"send", "recv", "request", "kill"}
1416

1517

1618
class WasiVersion(StrEnum):
@@ -166,14 +168,104 @@ def from_config(cls: Type[C], config: Dict[str, Any]) -> C:
166168
)
167169

168170

169-
Operation = Run | Wait | Read | Write | Connect | Send | Recv
171+
Resp = TypeVar("Resp", bound="Response")
172+
173+
174+
class Response(NamedTuple):
175+
status: int
176+
headers: Dict[str, str]
177+
body: str
178+
179+
@classmethod
180+
def from_config(cls: Type[Resp], config: Dict[str, Any]) -> Resp:
181+
status = config.get("status", 200)
182+
headers = config.get("headers", {})
183+
body = config.get("body", "")
184+
185+
if not isinstance(status, int):
186+
raise ValueError("Response status code should be an int")
187+
if not isinstance(headers, dict):
188+
raise ValueError("Response expected headers should be a dict")
189+
for k, v in headers.items():
190+
if not isinstance(k, str):
191+
raise ValueError("Response expected header name should be a str")
192+
if not isinstance(v, str):
193+
raise ValueError("Response expected header value should be a str")
194+
if not isinstance(body, str):
195+
raise ValueError("Response expected body should be a str")
196+
197+
return cls(status, headers, body)
198+
199+
200+
Req = TypeVar("Req", bound="Request")
201+
202+
203+
class Request(NamedTuple):
204+
method: str
205+
path: str
206+
response: Response
207+
208+
@classmethod
209+
def from_config(cls: Type[Req], config: Dict[str, Any]) -> Req:
210+
method = config.get("method", "GET")
211+
path = config.get("path", "/")
212+
response = config.get("response", {})
213+
214+
if not isinstance(method, str):
215+
raise ValueError("Request method should be a str")
216+
if not isinstance(path, str):
217+
raise ValueError("Request path should be a str")
218+
response = Response.from_config(response)
219+
220+
return cls(method, path, response)
221+
222+
223+
K = TypeVar("K", bound="Kill")
224+
225+
226+
class Kill(NamedTuple):
227+
signal: signal.Signals
228+
229+
@classmethod
230+
def from_config(cls: Type[K], config: Dict[str, Any]) -> K:
231+
signame = config.get("signal", "SIGTERM")
232+
233+
if not isinstance(signame, str):
234+
raise ValueError(f"Signal name should be a str: {signame}")
235+
if signame not in signal.Signals.__members__:
236+
raise ValueError(f"Unknown signal name: {signame}")
237+
238+
return cls(signal.Signals[signame])
239+
240+
241+
Operation = Run | Wait | Read | Write | Connect | Send | Recv | Request | Kill
170242

171243

172244
class WasiProposal(StrEnum):
173245
HTTP = 'http'
246+
HTTP_SERVICE = 'http/service'
174247
SOCKETS = 'sockets'
175248

176249

250+
def _infer_proposals_from_operations(ops: List[Operation]) -> List[WasiProposal]:
251+
sockets = False
252+
http_service = False
253+
for op in ops:
254+
match op:
255+
case Recv() | Send() | Connect():
256+
sockets = True
257+
case Request():
258+
http_service = True
259+
case _:
260+
pass
261+
ret = []
262+
if sockets:
263+
ret.append(WasiProposal.SOCKETS)
264+
if http_service:
265+
ret.append(WasiProposal.HTTP_SERVICE)
266+
return ret
267+
268+
177269
T = TypeVar("T", bound="Config")
178270

179271

@@ -196,8 +288,9 @@ def from_file(cls: Type[T], config_file: str) -> T:
196288
if dict_config.get("operations") is not None:
197289
operations = cls._operations_from_config(test_config_path, dict_config.get("operations"))
198290

199-
proposals = []
200-
if dict_config.get("proposals") is not None:
291+
if dict_config.get("proposals") is None:
292+
proposals = _infer_proposals_from_operations(operations)
293+
else:
201294
proposals = cls._proposals_from_config(dict_config.get("proposals"))
202295

203296
return cls(operations=operations, proposals=proposals)
@@ -276,6 +369,10 @@ def _operations_from_config(cls: Type[T], test_config_path: Path, ops: List[Any]
276369
operations.append(Send.from_config(op))
277370
case "recv":
278371
operations.append(Recv.from_config(op))
372+
case "request":
373+
operations.append(Request.from_config(op))
374+
case "kill":
375+
operations.append(Kill.from_config(op))
279376

280377
return operations
281378

@@ -321,6 +418,12 @@ def do_send(self, send: Send) -> None:
321418
def do_recv(self, recv: Recv) -> None:
322419
raise NotImplementedError()
323420

421+
def do_request(self, req: Request) -> None:
422+
raise NotImplementedError()
423+
424+
def do_kill(self, kill: Kill) -> None:
425+
raise NotImplementedError()
426+
324427
def do_cleanup(self, successful: bool) -> None:
325428
raise NotImplementedError()
326429

@@ -345,27 +448,33 @@ def run(self) -> Result:
345448
# wasi_test_runner/runtime_adapter.py:131: error: Argument 2 to "_handle_read"
346449
# has incompatible type "Read"; expected "Read" [arg-type]
347450
match op:
348-
case Run() as run:
349-
assert isinstance(run, Run)
350-
self.do_run(run)
351-
case Write() as write:
352-
assert isinstance(write, Write)
353-
self.do_write(write)
354-
case Read() as read:
355-
assert isinstance(read, Read)
356-
self.do_read(read)
357-
case Wait() as wait:
358-
assert isinstance(wait, Wait)
359-
self.do_wait(wait)
360-
case Connect() as conn:
361-
assert isinstance(conn, Connect)
362-
self.do_connect(conn)
363-
case Send() as send:
364-
assert isinstance(send, Send)
365-
self.do_send(send)
366-
case Recv() as recv:
367-
assert isinstance(recv, Recv)
368-
self.do_recv(recv)
451+
case Run():
452+
assert isinstance(op, Run)
453+
self.do_run(op)
454+
case Write():
455+
assert isinstance(op, Write)
456+
self.do_write(op)
457+
case Read():
458+
assert isinstance(op, Read)
459+
self.do_read(op)
460+
case Wait():
461+
assert isinstance(op, Wait)
462+
self.do_wait(op)
463+
case Connect():
464+
assert isinstance(op, Connect)
465+
self.do_connect(op)
466+
case Send():
467+
assert isinstance(op, Send)
468+
self.do_send(op)
469+
case Recv():
470+
assert isinstance(op, Recv)
471+
self.do_recv(op)
472+
case Request():
473+
assert isinstance(op, Request)
474+
self.do_request(op)
475+
case Kill():
476+
assert isinstance(op, Kill)
477+
self.do_kill(op)
369478

370479
successful = not self.has_failure()
371480
finally:
@@ -445,6 +554,12 @@ def do_recv(self, recv: Recv) -> None:
445554
self.assert_proc(recv)
446555
self.assert_stream(recv, recv.id, StreamType.SOCKET)
447556

557+
def do_request(self, req: Request) -> None:
558+
self.assert_proc(req)
559+
560+
def do_kill(self, kill: Kill) -> None:
561+
self.assert_proc(kill)
562+
448563
def do_cleanup(self, successful: bool) -> None:
449564
if successful:
450565
self.assert_no_proc(self._config_path)

test-runner/wasi_test_runner/test_suite_runner.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111
from pathlib import Path
1212
from typing import List, NamedTuple, Tuple, Dict, Any, IO
1313

14+
import requests
15+
1416
from .filters import TestFilter
1517
from .runtime_adapter import RuntimeAdapter
1618
from .test_case import (
1719
Result, Failure, WasiVersion, Config,
1820
TestCase, TestCaseRunnerBase, TestCaseValidator,
1921
# Operation types
20-
Run, Read, Write, Wait, Send, Recv, Connect
22+
Run, Read, Write, Wait, Send, Recv, Connect, Request, Kill
2123
)
2224
from .reporters import TestReporter
2325
from .test_suite import TestSuite, TestSuiteMeta
@@ -38,6 +40,7 @@ class TestCaseRunner(TestCaseRunnerBase):
3840
_pipes: Dict[str, IO[str]]
3941
_sockets: Dict[str, socket.socket]
4042
_last_argv: List[str]
43+
_http_server: str | None
4144

4245
def __init__(self, config: Config, test_path: str, wasi_version: WasiVersion,
4346
runtime: RuntimeAdapter) -> None:
@@ -50,6 +53,7 @@ def __init__(self, config: Config, test_path: str, wasi_version: WasiVersion,
5053
self._pipes = {}
5154
self._sockets = {}
5255
self._last_argv = []
56+
self._http_server = None
5357

5458
def _add_cleanup_dir(self, d: Path) -> None:
5559
_cleanup_test_output(d)
@@ -88,6 +92,18 @@ def get_pipe(self, name: str) -> IO[str]:
8892
def last_argv(self) -> List[str]:
8993
return self._last_argv
9094

95+
def get_http_server(self) -> str | None:
96+
if self._http_server:
97+
return self._http_server
98+
line = self.get_pipe('stderr').readline().strip()
99+
start = line.find('http://')
100+
if start < 0:
101+
self.fail_unexpected(f"Expected 'http://' in first line, got {line}") # noqa: E231
102+
return None
103+
# The server URL starts with http:// and ends at EOL or whitespace.
104+
self._http_server = line[start:].split()[0]
105+
return self._http_server
106+
91107
def do_run(self, run: Run) -> None:
92108
for (host, _guest) in run.dirs:
93109
self._add_cleanup_dir(host)
@@ -178,6 +194,46 @@ def do_recv(self, recv: Recv) -> None:
178194
except UnicodeDecodeError as e:
179195
self.fail_unexpected(f"{recv}: Failed to decode response: {e}")
180196

197+
def do_request(self, req: Request) -> None:
198+
# pylint: disable-msg=too-many-return-statements
199+
http_server = self.get_http_server()
200+
if http_server is None:
201+
return
202+
url = http_server + req.path
203+
try:
204+
response = requests.request(req.method, url, timeout=5)
205+
except requests.exceptions.Timeout:
206+
self.fail_unexpected(f"{req}: Timeout waiting for response")
207+
return
208+
except requests.exceptions.RequestException as e:
209+
self.fail_unexpected(f"{req}: Failed to make request: {e}")
210+
return
211+
if response.status_code != req.response.status:
212+
self.fail_unexpected(
213+
f"{req}: Expected status {req.response.status}, got {response.status_code}")
214+
return
215+
for h, expected in req.response.headers.items():
216+
if h not in response.headers:
217+
self.fail_unexpected(f"{req}: Response missing header {h}")
218+
return
219+
actual = response.headers[h]
220+
if actual != expected:
221+
self.fail_unexpected(
222+
f"{req}: Expected response header {h}={expected}, got {actual}")
223+
return
224+
if response.text != req.response.body:
225+
self.fail_unexpected(
226+
f"{req}: Expected response body '{req.response.body}', got '{response.text}'")
227+
return
228+
229+
def do_kill(self, kill: Kill) -> None:
230+
try:
231+
proc = self._proc
232+
assert proc is not None
233+
proc.send_signal(kill.signal)
234+
except OSError as e:
235+
self.fail_unexpected(f"{kill}: Failed to send {kill.signal}: {e}")
236+
181237
def do_cleanup(self, successful: bool) -> None:
182238
if self._proc:
183239
self._proc.kill()

0 commit comments

Comments
 (0)