Skip to content

Commit adc70e6

Browse files
committed
Add Config.dry_run and more robust error handling in the test loop
This commit adds a `dry_run` method to the config, so that it can be called before executing tests. The dry run validates the semantic struture of the operation definitions. Aditionally this commit adds more robust error handling to the main test loop.
1 parent 8c423db commit adc70e6

3 files changed

Lines changed: 193 additions & 63 deletions

File tree

test-runner/tests/test_test_case.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,77 @@ def test_new_config_with_multiple_proposals(_mock_file: Mock) -> None:
213213
def test_new_config_should_fail_with_invalid_proposal(_mock_file: Mock) -> None:
214214
with pytest.raises(ValueError):
215215
Config.from_file("file")
216+
217+
218+
def test_dry_run_valid_config_should_not_raise() -> None:
219+
config = Config(operations=[Run(), Wait()])
220+
config.dry_run()
221+
222+
223+
def test_dry_run_run_without_wait() -> None:
224+
config = Config(operations=[Run(), Run()])
225+
with pytest.raises(ValueError, match="each Run operation must be paired with a Wait operation"):
226+
config.dry_run()
227+
228+
229+
def test_dry_run_read_before_run() -> None:
230+
config = Config(operations=[Read()])
231+
with pytest.raises(ValueError, match="Found Read operation before Run"):
232+
config.dry_run()
233+
234+
235+
def test_dry_run_wait_before_run() -> None:
236+
config = Config(operations=[Wait()])
237+
with pytest.raises(ValueError, match="Found Wait operation before Run"):
238+
config.dry_run()
239+
240+
241+
def test_dry_run_connect_before_run() -> None:
242+
config = Config(operations=[Connect()])
243+
with pytest.raises(ValueError, match="Found Connect operation before Run"):
244+
config.dry_run()
245+
246+
247+
def test_dry_run_connect_with_non_tcp_protocol() -> None:
248+
config = Config(operations=[Run(), Connect(protocol_type=ProtocolType.UDP), Wait()])
249+
with pytest.raises(ValueError, match="udp not supported"):
250+
config.dry_run()
251+
252+
253+
def test_dry_run_connect_with_duplicate_id() -> None:
254+
config = Config(operations=[
255+
Run(),
256+
Connect(id="conn1"),
257+
Connect(id="conn1"),
258+
Wait()
259+
])
260+
with pytest.raises(ValueError, match="Duplicate definition of id conn1"):
261+
config.dry_run()
262+
263+
264+
def test_dry_run_send_before_run() -> None:
265+
config = Config(operations=[Send(id="conn1", payload="test")])
266+
with pytest.raises(ValueError, match="Found Send operation before Run"):
267+
config.dry_run()
268+
269+
270+
def test_dry_run_send_with_undefined_id() -> None:
271+
config = Config(operations=[Run(), Send(id="conn1", payload="test"), Wait()])
272+
with pytest.raises(ValueError, match="No identifier defined for conn1"):
273+
config.dry_run()
274+
275+
276+
def test_dry_run_recv_before_run() -> None:
277+
config = Config(operations=[Recv(id="conn1", payload="test")])
278+
with pytest.raises(ValueError, match="Found Recv operation before Run"):
279+
config.dry_run()
280+
281+
282+
def test_dry_run_multiple_errors() -> None:
283+
config = Config(operations=[Read(), Wait(), Run(), Run()])
284+
with pytest.raises(ValueError) as exc_info:
285+
config.dry_run()
286+
error_message = str(exc_info.value)
287+
assert "Found Read operation before Run" in error_message
288+
assert "Found Wait operation before Run" in error_message
289+
assert "each Run operation must be paired with a Wait operation" in error_message

test-runner/wasi_test_runner/runtime_adapter.py

Lines changed: 80 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ def compute_argv(self, test_path: str,
109109
return argv
110110

111111
def run_test(self, config: Config, argv: List[str]) -> Result:
112-
# pylint: disable=too-many-branches
112+
config.dry_run()
113+
# pylint: disable=too-many-branches,too-many-locals
113114
result = Result(is_executed=True, failures=[])
114115

115116
proc: subprocess.Popen[Any] | None = None
@@ -123,36 +124,30 @@ def run_test(self, config: Config, argv: List[str]) -> Result:
123124
proc = subprocess.Popen(argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
124125
cleanup_dirs = dirs
125126
case Read() as read:
126-
if proc is None:
127-
result.failures.append(Failure.unexpected("Read operation called before Run"))
128-
else:
129-
# Instance asserts might seem redudant here, given the match.
130-
# Asserts merely exist to ensure that mypy can fully resolve the underlying type;
131-
# else it will report errors like:
132-
# wasi_test_runner/runtime_adapter.py:131: error: Argument 2 to "_handle_read"
133-
# has incompatible type "Read"; expected "Read" [arg-type]
134-
assert isinstance(read, Read)
135-
_handle_read(proc, read, result)
127+
assert proc is not None
128+
# Instance asserts might seem redudant here, given the match.
129+
# Asserts merely exist to ensure that mypy can fully resolve the underlying type;
130+
# else it will report errors like:
131+
# wasi_test_runner/runtime_adapter.py:131: error: Argument 2 to "_handle_read"
132+
# has incompatible type "Read"; expected "Read" [arg-type]
133+
assert isinstance(read, Read)
134+
_handle_read(proc, read, result)
136135
case Wait() as wait:
137-
if proc is None:
138-
result.failures.append(Failure.unexpected("Wait operation called before Run"))
139-
else:
140-
assert isinstance(wait, Wait)
141-
_handle_wait(proc, wait, result)
136+
assert proc is not None
137+
assert isinstance(wait, Wait)
138+
_handle_wait(proc, wait, result)
139+
# The wait handler will `kill` the process
140+
proc = None
142141
case Connect() as conn:
143-
if proc is None:
144-
result.failures.append(Failure.unexpected("Connect operation called before Run"))
145-
else:
146-
assert isinstance(conn, Connect)
147-
_handle_connect(proc, config, conn, result)
142+
assert proc is not None
143+
assert isinstance(conn, Connect)
144+
_handle_connect(proc, config, conn, result)
148145
case Send() as send:
149146
assert isinstance(send, Send)
150147
_handle_send(config, send, result)
151148
case Recv() as recv:
152149
assert isinstance(recv, Recv)
153150
_handle_recv(config, recv, result)
154-
case _:
155-
pass
156151

157152
finally:
158153
if cleanup_dirs:
@@ -164,44 +159,36 @@ def run_test(self, config: Config, argv: List[str]) -> Result:
164159
_, _ = proc.communicate(timeout=5)
165160
except subprocess.TimeoutExpired:
166161
proc.kill()
167-
result.failures.append(Failure.unexpected("Process timed out"))
162+
out, err = proc.communicate()
163+
msg = "Process timed out"
164+
msg = _append_stdout_and_stderr(msg, out, err)
165+
result.failures.append(Failure.unexpected(msg))
168166

169167
return result
170168

171169

172170
def _handle_read(proc: subprocess.Popen[Any], spec: Read, result: Result) -> None:
173-
if spec.id == "stdout":
174-
if proc.stdout is None:
175-
result.failures.append(Failure.unexpected(f"{spec.id} is not available"))
176-
return
177-
payload = proc.stdout.readline().strip()
178-
if payload != spec.payload:
179-
result.failures.append(Failure.expectation(f"{spec.id} failed: expected {spec.payload}, got {payload}"))
171+
stream = getattr(proc, spec.id, None)
172+
if stream is None:
173+
result.failures.append(Failure.unexpected(f"{spec} {spec.id} is not available"))
174+
return
180175

181-
if spec.id == "stderr":
182-
if proc.stderr is None:
183-
result.failures.append(Failure.unexpected(f"{spec.id} is not available"))
184-
return
185-
payload = proc.stderr.readline().strip()
186-
if payload != spec.payload:
187-
result.failures.append(Failure.expectation(f"{spec.id} failed: expected {spec.payload}, got {payload}"))
176+
payload = stream.readline().strip()
177+
if payload != spec.payload:
178+
result.failures.append(Failure.expectation(f"{spec} {spec.id} failed: expected {spec.payload}, got {payload}"))
188179

189180

190181
def _handle_wait(proc: subprocess.Popen[Any], spec: Wait, result: Result) -> None:
191182
try:
192183
out, err = proc.communicate(timeout=5)
193184
if spec.exit_code != proc.returncode:
194185
msg = f"{spec} failed: expected {spec.exit_code}, got {proc.returncode}"
195-
196-
if out:
197-
msg += f"\n\n==STDOUT==\n{out}"
198-
199-
if err:
200-
msg += f"\n\n==STDERR==\n{err}"
201-
186+
msg = _append_stdout_and_stderr(msg, out, err)
202187
result.failures.append(Failure.expectation(msg))
203188

204189
except subprocess.TimeoutExpired:
190+
proc.kill()
191+
out, err = proc.communicate()
205192
result.failures.append(Failure.expectation(f"{spec} failed: timeout expired"))
206193

207194

@@ -220,35 +207,55 @@ def _handle_connect(proc: subprocess.Popen[Any], config: Config, spec: Connect,
220207
result.failures.append(Failure.unexpected(f"{spec}: No connection information available"))
221208
return
222209

223-
host, port = proc.stdout.readline().strip().split(':')
210+
try:
211+
line = proc.stdout.readline().strip()
212+
parts = line.split(':')
213+
if len(parts) != 2:
214+
msg = f"{spec}: Expected address information to be available as <host>: <port>, found {line}"
215+
result.failures.append(Failure.unexpected(msg))
216+
return
217+
host, port_str = parts
218+
if not host or not port_str:
219+
msg = f"{spec}: Expected address information to be available as <host>: <port>, found {line}"
220+
result.failures.append(Failure.unexpected(msg))
221+
return
222+
port = int(port_str)
223+
except ValueError as e:
224+
result.failures.append(Failure.unexpected(f"{spec}: Failed to parse connection information: {e}"))
225+
return
226+
227+
sock = None
224228
try:
225229
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
226-
sock.connect((host, int(port)))
230+
sock.connect((host, port))
227231
config.connections[spec.id] = sock
228-
except socket.timeout:
229-
host_port = host + ":" + port
230-
result.failures.append(Failure.unexpected(f"{spec}: Could not connect to {host_port}"))
232+
except (socket.timeout, ConnectionRefusedError, OSError) as e:
233+
if sock is not None:
234+
sock.close()
235+
result.failures.append(Failure.unexpected(f"{spec}: Could not connect to {host}: {port} - {e}"))
231236

232237

233238
def _handle_send(config: Config, spec: Send, result: Result) -> None:
234-
if config.connections[spec.id] is None:
235-
result.failures.append(Failure.unexpected(f"{spec}: No connection declared for id {spec.id}"))
236-
return
237-
238239
sock = config.connections[spec.id]
239-
sock.sendall(spec.payload.encode('utf-8'))
240+
assert sock is not None
241+
try:
242+
sock.sendall(spec.payload.encode('utf-8'))
243+
except (OSError, socket.error) as e:
244+
result.failures.append(Failure.unexpected(f"{spec}: Failed to send data: {e}"))
240245

241246

242247
def _handle_recv(config: Config, spec: Recv, result: Result) -> None:
243-
if config.connections[spec.id] is None:
244-
result.failures.append(Failure.unexpected(f"{spec}: No connection declared for id {spec.id}"))
245-
return
246-
247248
sock = config.connections[spec.id]
248-
response_bytes = sock.recv(len(spec.payload))
249-
response = response_bytes.decode('utf-8')
250-
if response != spec.payload:
251-
result.failures.append(Failure.unexpected(f"{spec}: Expected {spec.payload}, got {response}"))
249+
assert sock is not None
250+
try:
251+
response_bytes = sock.recv(len(spec.payload))
252+
response = response_bytes.decode('utf-8')
253+
if response != spec.payload:
254+
result.failures.append(Failure.unexpected(f"{spec}: Expected {spec.payload}, got {response}"))
255+
except (OSError, socket.error) as e:
256+
result.failures.append(Failure.unexpected(f"{spec}: Failed to receive data: {e}"))
257+
except UnicodeDecodeError as e:
258+
result.failures.append(Failure.unexpected(f"{spec}: Failed to decode response: {e}"))
252259

253260

254261
def _cleanup_test_output(dirs: List[Tuple[Path, str]]) -> None:
@@ -258,3 +265,13 @@ def _cleanup_test_output(dirs: List[Tuple[Path, str]]) -> None:
258265
f.unlink()
259266
elif f.is_dir():
260267
shutil.rmtree(f)
268+
269+
270+
def _append_stdout_and_stderr(msg: str, out: str | None, err: str | None) -> str:
271+
if out:
272+
msg += f"\n\n==STDOUT==\n{out}"
273+
274+
if err:
275+
msg += f"\n\n==STDERR==\n{err}"
276+
277+
return msg

test-runner/wasi_test_runner/test_case.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,45 @@ def args_env_dirs(self) -> Tuple[List[str], Dict[str, str], List[Tuple[Path, str
239239
def proposals_as_str(self) -> List[str]:
240240
return [p.value for p in self.proposals]
241241

242+
# Performs a dry run of the configuration validating its structure.
243+
def dry_run(self) -> None:
244+
run_found = False
245+
procs: List[str] = []
246+
errors: List[str] = []
247+
248+
for op in self.operations:
249+
match op:
250+
case Run() as run:
251+
if run_found:
252+
errors.append(f"{run}: each Run operation must be paired with a Wait operation")
253+
run_found = True
254+
case Read() as read:
255+
if not run_found:
256+
errors.append(f"{read}: Found Read operation before Run")
257+
case Wait() as wait:
258+
if not run_found:
259+
errors.append(f"{wait}: Found Wait operation before Run")
260+
run_found = False
261+
case Connect(conn_id, protocol_type) as conn:
262+
if not run_found:
263+
errors.append(f"{conn}: Found Connect operation before Run")
264+
if protocol_type != ProtocolType.TCP:
265+
errors.append(f"{conn}: {protocol_type} not supported")
266+
if conn_id in procs:
267+
errors.append(f"{conn}: Duplicate definition of id {conn_id}")
268+
procs.append(conn_id)
269+
case Send(send_id) as send:
270+
if not run_found:
271+
errors.append(f"{send}: Found Send operation before Run")
272+
if send_id not in procs:
273+
errors.append(f"{send}: No identifier defined for {send_id}")
274+
case Recv() as recv:
275+
if not run_found:
276+
errors.append(f"{recv}: Found Recv operation before Run")
277+
278+
if errors:
279+
raise ValueError("\n".join(errors))
280+
242281
@classmethod
243282
def _validate_config(cls: Type[T], dict_config: Dict[str, Any], expected_keys: Set[str]) -> None:
244283
# Check that the test configuration is unique, either v0 or v1

0 commit comments

Comments
 (0)