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
4 changes: 4 additions & 0 deletions adapters/wasmtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ def _add_wasi_version_options(argv: List[str], wasi_version: str, proposals: Lis
flags_from_proposals += ",http"
if "sockets" in proposals:
flags_from_proposals += ",inherit-network"
if "http/service" in proposals:
flags_from_proposals += ",cli"
argv[splice_pos:splice_pos] = ["serve", "--addr=127.0.0.1:0"]
splice_pos += 1

argv[splice_pos:splice_pos] = ["-Wcomponent-model-async",
f"-Sp3{flags_from_proposals}"]
Expand Down
1 change: 1 addition & 0 deletions test-runner/requirements/common.txt
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
colorama>=0.4.3
requests
1 change: 1 addition & 0 deletions test-runner/requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ mypy==0.991
pylint==3.3.8
pytest==6.2.5
coverage==6.3.3
types-requests
28 changes: 27 additions & 1 deletion test-runner/tests/test_test_case.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import signal

from json import JSONDecodeError
from pathlib import Path
from unittest.mock import Mock, patch, mock_open

import pytest

from wasi_test_runner.test_case import (
Config, Failure, Result, Run, Wait, Read, Write, Connect, Send, Recv,
Config, Failure, Result,
Run, Wait, Read, Write, Connect, Send, Recv, Request, Response, Kill,
ProtocolType, WasiProposal, TestCaseValidator
)

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


def test_request_from_config() -> None:
req = Request.from_config({"method": "POST", "response": {"body": "hey"}})
assert req.method == "POST"
assert req.path == "/"
assert req.response == Response(status=200, headers={}, body="hey")


def test_kill_from_config_with_signal() -> None:
kill = Kill.from_config({"signal": "SIGABRT"})
assert kill.signal == signal.SIGABRT


def test_kill_from_config_with_defaults() -> None:
kill = Kill.from_config({})
assert kill.signal == signal.SIGTERM


@patch(
"builtins.open",
new_callable=mock_open,
Expand Down Expand Up @@ -304,6 +324,12 @@ def test_dry_run_recv_before_run() -> None:
validate_config(config)


def test_dry_run_request_before_run() -> None:
config = Config(operations=[Request.from_config({})])
with pytest.raises(AssertionError, match="no process running"):
validate_config(config)


def test_dry_run_multiple_errors() -> None:
config = Config(operations=[Read(), Wait(), Run(), Run()])
with pytest.raises(AssertionError) as exc_info:
Expand Down
165 changes: 140 additions & 25 deletions test-runner/wasi_test_runner/test_case.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import logging
import json
import signal
from pathlib import Path
from enum import Enum, StrEnum, auto
from typing import List, NamedTuple, TypeVar, Type, Dict, Any, Set, Tuple
Expand All @@ -10,7 +11,8 @@


# Supported operations
SUPPORTED_OPERATIONS = {"run", "wait", "read", "write", "connect", "send", "recv"}
SUPPORTED_OPERATIONS = {"run", "wait", "read", "write", "connect",
"send", "recv", "request", "kill"}


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


Operation = Run | Wait | Read | Write | Connect | Send | Recv
Resp = TypeVar("Resp", bound="Response")


class Response(NamedTuple):
status: int
headers: Dict[str, str]
body: str

@classmethod
def from_config(cls: Type[Resp], config: Dict[str, Any]) -> Resp:
Comment thread
saulecabrera marked this conversation as resolved.
status = config.get("status", 200)
headers = config.get("headers", {})
body = config.get("body", "")

if not isinstance(status, int):
raise ValueError("Response status code should be an int")
if not isinstance(headers, dict):
raise ValueError("Response expected headers should be a dict")
for k, v in headers.items():
if not isinstance(k, str):
raise ValueError("Response expected header name should be a str")
if not isinstance(v, str):
raise ValueError("Response expected header value should be a str")
if not isinstance(body, str):
raise ValueError("Response expected body should be a str")

return cls(status, headers, body)


Req = TypeVar("Req", bound="Request")


class Request(NamedTuple):
method: str
path: str
response: Response

@classmethod
def from_config(cls: Type[Req], config: Dict[str, Any]) -> Req:
method = config.get("method", "GET")
path = config.get("path", "/")
response = config.get("response", {})

if not isinstance(method, str):
raise ValueError("Request method should be a str")
if not isinstance(path, str):
raise ValueError("Request path should be a str")
response = Response.from_config(response)

return cls(method, path, response)


K = TypeVar("K", bound="Kill")


class Kill(NamedTuple):
signal: signal.Signals

@classmethod
def from_config(cls: Type[K], config: Dict[str, Any]) -> K:
signame = config.get("signal", "SIGTERM")

if not isinstance(signame, str):
raise ValueError(f"Signal name should be a str: {signame}")
if signame not in signal.Signals.__members__:
raise ValueError(f"Unknown signal name: {signame}")

return cls(signal.Signals[signame])


Operation = Run | Wait | Read | Write | Connect | Send | Recv | Request | Kill


class WasiProposal(StrEnum):
HTTP = 'http'
HTTP_SERVICE = 'http/service'
SOCKETS = 'sockets'


def _infer_proposals_from_operations(ops: List[Operation]) -> List[WasiProposal]:
sockets = False
http_service = False
for op in ops:
match op:
case Recv() | Send() | Connect():
sockets = True
case Request():
http_service = True
case _:
pass
ret = []
if sockets:
ret.append(WasiProposal.SOCKETS)
if http_service:
ret.append(WasiProposal.HTTP_SERVICE)
return ret


T = TypeVar("T", bound="Config")


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

proposals = []
if dict_config.get("proposals") is not None:
if dict_config.get("proposals") is None:
proposals = _infer_proposals_from_operations(operations)
else:
Comment on lines +291 to +293
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I understand the rationale for doing this, however, is there a risk of introducing a mismatch of specified proposals vs non-specified vs derived? FWIW, I remember that in the original design I was heavily inclined to explicitly stating the target proposals. Could we perhaps validate that if proposals are specified, match the derived ones?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. One comment first, I think really we have "features" rather than "proposals": aspects of the WASI runtime that need to be present for a test to work. Sometimes this maps to proposals. In this case the thing that I wrote as http/service isn't a proposal really, rather a mode of operation (hence the corresponding wasmtime adapter changes to make it run wasmtime serve and enable cli, which is otherwise enabled by default. I can change the name to "features" in a followup if you like.

But, I hesitated about making the http/service feature name visible in the JSON, because it seems like a detail. Then I realized I could avoid having to say anything if I just automatically inferred what features are needed.

I don't think we can assert however that inferred proposals match explicitly specified ones. The need for http or sockets can't be inferred from the set of ops, unlike http/service.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we can assert however that inferred proposals match explicitly specified ones. The need for http or sockets can't be inferred from the set of ops, unlike http/service.

The http/service detail is indeed a good point and I agree, it's a detail and we can infer it, especially if we don't want to expose it as a configuration concept, however I see in the current test configuration:

"proposals": ["http", "http/service"],

Should we perhaps remove it then?

Particularly for inference: in _infer_proposals_from_operations we are also inferring sockets, which is what I'm not sure a path that we should follow. Maybe we could consider removing the inference of sockets, and focus solely on inferring http/service?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed this offline, we'll tackle it in a follow-up.

proposals = cls._proposals_from_config(dict_config.get("proposals"))

return cls(operations=operations, proposals=proposals)
Expand Down Expand Up @@ -276,6 +369,10 @@ def _operations_from_config(cls: Type[T], test_config_path: Path, ops: List[Any]
operations.append(Send.from_config(op))
case "recv":
operations.append(Recv.from_config(op))
case "request":
operations.append(Request.from_config(op))
case "kill":
operations.append(Kill.from_config(op))

return operations

Expand Down Expand Up @@ -321,6 +418,12 @@ def do_send(self, send: Send) -> None:
def do_recv(self, recv: Recv) -> None:
raise NotImplementedError()

def do_request(self, req: Request) -> None:
raise NotImplementedError()

def do_kill(self, kill: Kill) -> None:
raise NotImplementedError()

def do_cleanup(self, successful: bool) -> None:
raise NotImplementedError()

Expand All @@ -345,27 +448,33 @@ def run(self) -> Result:
# wasi_test_runner/runtime_adapter.py:131: error: Argument 2 to "_handle_read"
# has incompatible type "Read"; expected "Read" [arg-type]
match op:
case Run() as run:
assert isinstance(run, Run)
self.do_run(run)
case Write() as write:
assert isinstance(write, Write)
self.do_write(write)
case Read() as read:
assert isinstance(read, Read)
self.do_read(read)
case Wait() as wait:
assert isinstance(wait, Wait)
self.do_wait(wait)
case Connect() as conn:
assert isinstance(conn, Connect)
self.do_connect(conn)
case Send() as send:
assert isinstance(send, Send)
self.do_send(send)
case Recv() as recv:
assert isinstance(recv, Recv)
self.do_recv(recv)
case Run():
assert isinstance(op, Run)
self.do_run(op)
case Write():
assert isinstance(op, Write)
self.do_write(op)
case Read():
assert isinstance(op, Read)
self.do_read(op)
case Wait():
assert isinstance(op, Wait)
self.do_wait(op)
case Connect():
assert isinstance(op, Connect)
self.do_connect(op)
case Send():
assert isinstance(op, Send)
self.do_send(op)
case Recv():
assert isinstance(op, Recv)
self.do_recv(op)
case Request():
assert isinstance(op, Request)
self.do_request(op)
case Kill():
assert isinstance(op, Kill)
self.do_kill(op)

successful = not self.has_failure()
finally:
Expand Down Expand Up @@ -445,6 +554,12 @@ def do_recv(self, recv: Recv) -> None:
self.assert_proc(recv)
self.assert_stream(recv, recv.id, StreamType.SOCKET)

def do_request(self, req: Request) -> None:
self.assert_proc(req)

def do_kill(self, kill: Kill) -> None:
self.assert_proc(kill)

def do_cleanup(self, successful: bool) -> None:
if successful:
self.assert_no_proc(self._config_path)
Expand Down
58 changes: 57 additions & 1 deletion test-runner/wasi_test_runner/test_suite_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
from pathlib import Path
from typing import List, NamedTuple, Tuple, Dict, Any, IO

import requests

from .filters import TestFilter
from .runtime_adapter import RuntimeAdapter
from .test_case import (
Result, Failure, WasiVersion, Config,
TestCase, TestCaseRunnerBase, TestCaseValidator,
# Operation types
Run, Read, Write, Wait, Send, Recv, Connect
Run, Read, Write, Wait, Send, Recv, Connect, Request, Kill
)
from .reporters import TestReporter
from .test_suite import TestSuite, TestSuiteMeta
Expand All @@ -38,6 +40,7 @@ class TestCaseRunner(TestCaseRunnerBase):
_pipes: Dict[str, IO[str]]
_sockets: Dict[str, socket.socket]
_last_argv: List[str]
_http_server: str | None

def __init__(self, config: Config, test_path: str, wasi_version: WasiVersion,
runtime: RuntimeAdapter) -> None:
Expand All @@ -50,6 +53,7 @@ def __init__(self, config: Config, test_path: str, wasi_version: WasiVersion,
self._pipes = {}
self._sockets = {}
self._last_argv = []
self._http_server = None

def _add_cleanup_dir(self, d: Path) -> None:
_cleanup_test_output(d)
Expand Down Expand Up @@ -88,6 +92,18 @@ def get_pipe(self, name: str) -> IO[str]:
def last_argv(self) -> List[str]:
return self._last_argv

def get_http_server(self) -> str | None:
if self._http_server:
return self._http_server
line = self.get_pipe('stderr').readline().strip()
start = line.find('http://')
if start < 0:
self.fail_unexpected(f"Expected 'http://' in first line, got {line}") # noqa: E231
return None
# The server URL starts with http:// and ends at EOL or whitespace.
self._http_server = line[start:].split()[0]
return self._http_server

def do_run(self, run: Run) -> None:
for (host, _guest) in run.dirs:
self._add_cleanup_dir(host)
Expand Down Expand Up @@ -178,6 +194,46 @@ def do_recv(self, recv: Recv) -> None:
except UnicodeDecodeError as e:
self.fail_unexpected(f"{recv}: Failed to decode response: {e}")

def do_request(self, req: Request) -> None:
# pylint: disable-msg=too-many-return-statements
http_server = self.get_http_server()
if http_server is None:
return
url = http_server + req.path
try:
response = requests.request(req.method, url, timeout=5)
except requests.exceptions.Timeout:
self.fail_unexpected(f"{req}: Timeout waiting for response")
return
except requests.exceptions.RequestException as e:
self.fail_unexpected(f"{req}: Failed to make request: {e}")
return
if response.status_code != req.response.status:
self.fail_unexpected(
f"{req}: Expected status {req.response.status}, got {response.status_code}")
return
for h, expected in req.response.headers.items():
if h not in response.headers:
self.fail_unexpected(f"{req}: Response missing header {h}")
return
actual = response.headers[h]
if actual != expected:
self.fail_unexpected(
f"{req}: Expected response header {h}={expected}, got {actual}")
return
if response.text != req.response.body:
self.fail_unexpected(
f"{req}: Expected response body '{req.response.body}', got '{response.text}'")
return

def do_kill(self, kill: Kill) -> None:
try:
proc = self._proc
assert proc is not None
proc.send_signal(kill.signal)
except OSError as e:
self.fail_unexpected(f"{kill}: Failed to send {kill.signal}: {e}")

def do_cleanup(self, successful: bool) -> None:
if self._proc:
self._proc.kill()
Expand Down
Loading
Loading