Clean up testing machinery (types, helper functions)
Marco Ricci

Marco Ricci commited on 2024-09-02 01:23:14
Zeige 6 geänderte Dateien mit 445 Einfügungen und 431 Löschungen.


- Submit all input to, and read all output from, `click`-based
  command-line interfaces via the textual streams.  This includes the
  `.stdout`/`.output` and `.stderr` properties of a test runner result,
  and the input to the command-line call.

- Write a new `typing.NamedTuple`-based interface for collecting the
  test runner result in a readable fashion, because the
  `click.testing.Result` `repr` output is really unhelpful.  This also
  allows to define a higher-level API to check whether a `click`
  application ran successfully, with certain expected output, or whether
  it ran unsuccessfully, with certain expected error output.

- Annotate all pytest `monkeypatch` fixtures as `pytest.MonkeyPatch`.
... ...
@@ -15,7 +15,7 @@ import zipfile
15 15
 from typing import TYPE_CHECKING
16 16
 
17 17
 import pytest
18
-from typing_extensions import assert_never
18
+from typing_extensions import NamedTuple, Self, assert_never
19 19
 
20 20
 from derivepassphrase import _types, cli
21 21
 
... ...
@@ -323,7 +323,7 @@ JAu0J3Q+cypZuKQVAAAAMQD5sTy8p+B1cn/DhOmXquui1BcxvASqzzevkBlbQoBa73y04B
323 323
 }
324 324
 
325 325
 DUMMY_SERVICE = 'service1'
326
-DUMMY_PASSPHRASE = b'my secret passphrase\n'
326
+DUMMY_PASSPHRASE = 'my secret passphrase'
327 327
 DUMMY_KEY1 = SUPPORTED_KEYS['ed25519']['public_key_data']
328 328
 DUMMY_KEY1_B64 = base64.standard_b64encode(DUMMY_KEY1).decode('ASCII')
329 329
 DUMMY_KEY2 = SUPPORTED_KEYS['rsa']['public_key_data']
... ...
@@ -355,7 +355,7 @@ VAULT_MASTER_KEY = 'vault key'
355 355
 VAULT_V02_CONFIG = 'P7xeh5y4jmjpJ2pFq4KUcTVoaE9ZOEkwWmpVTURSSWQxbGt6emN4aFE4eFM3anVPbDRNTGpOLzY3eDF5aE1YTm5LNWh5Q1BwWTMwM3M5S083MWRWRFlmOXNqSFJNcStGMWFOS3c2emhiOUNNenZYTmNNMnZxaUErdlRoOGF2ZHdGT1ZLNTNLOVJQcU9jWmJrR3g5N09VcVBRZ0ZnSFNUQy9HdFVWWnFteVhRVkY3MHNBdnF2ZWFEbFBseWRGelE1c3BFTnVUckRQdWJSL29wNjFxd2Y2ZVpob3VyVzRod3FKTElTenJ1WTZacTJFOFBtK3BnVzh0QWVxcWtyWFdXOXYyenNQeFNZbWt1MDU2Vm1kVGtISWIxWTBpcWRFbyswUVJudVVhZkVlNVpGWDA4WUQ2Q2JTWW81SnlhQ2Zxa3cxNmZoQjJES0Uyd29rNXpSck5iWVBrVmEwOXFya1NpMi9saU5LL3F0M3N3MjZKekNCem9ER2svWkZ0SUJLdmlHRno0VlQzQ3pqZTBWcTM3YmRiNmJjTkhqUHZoQ0NxMW1ldW1XOFVVK3pQMEtUMkRMVGNvNHFlOG40ck5KcGhsYXg1b1VzZ1NYU1B2T3RXdEkwYzg4NWE3YWUzOWI1MDI0MThhMWZjODQ3MDA2OTJmNDQ0MDkxNGFiNmRlMGQ2YjZiNjI5NGMwN2IwMmI4MGZi'  # noqa: E501
356 356
 VAULT_V02_CONFIG_DATA = {
357 357
     'global': {
358
-        'phrase': DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
358
+        'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
359 359
     },
360 360
     'services': {
361 361
         '(meta)': {
... ...
@@ -367,7 +367,7 @@ VAULT_V02_CONFIG_DATA = {
367 367
 VAULT_V03_CONFIG = 'sBPBrr8BFHPxSJkV/A53zk9zwDQHFxLe6UIusCVvzFQre103pcj5xxmE11lMTA0U2QTYjkhRXKkH5WegSmYpAnzReuRsYZlWWp6N4kkubf+twZ9C3EeggPm7as2Af4TICHVbX4uXpIHeQJf9y1OtqrO+SRBrgPBzgItoxsIxebxVKgyvh1CZQOSkn7BIzt9xKhDng3ubS4hQ91fB0QCumlldTbUl8tj4Xs5JbvsSlUMxRlVzZ0OgAOrSsoWELXmsp6zXFa9K6wIuZa4wQuMLQFHiA64JO1CR3I+rviWCeMlbTOuJNx6vMB5zotKJqA2hIUpN467TQ9vI4g/QTo40m5LT2EQKbIdTvBQAzcV4lOcpr5Lqt4LHED5mKvm/4YfpuuT3I3XCdWfdG5SB7ciiB4Go+xQdddy3zZMiwm1fEwIB8XjFf2cxoJdccLQ2yxf+9diedBP04EsMHrvxKDhQ7/vHl7xF2MMFTDKl3WFd23vvcjpR1JgNAKYprG/e1p/7'  # noqa: E501
368 368
 VAULT_V03_CONFIG_DATA = {
369 369
     'global': {
370
-        'phrase': DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
370
+        'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
371 371
     },
372 372
     'services': {
373 373
         '(meta)': {
... ...
@@ -430,7 +430,7 @@ AAABAAAApIHsBwAAMWFQSwUGAAAAAAUABQDzAAAABAoAAAAA
430 430
 """
431 431
 VAULT_STOREROOM_CONFIG_DATA = {
432 432
     'global': {
433
-        'phrase': DUMMY_PASSPHRASE.decode('utf-8').rstrip('\n'),
433
+        'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
434 434
     },
435 435
     'services': {
436 436
         '(meta)': {
... ...
@@ -486,7 +486,7 @@ AAAApIHIBAAAMWVQSwUGAAAAAAQABADDAAAAoQYAAAAA
486 486
 """
487 487
 
488 488
 CANNOT_LOAD_CRYPTOGRAPHY = (
489
-    b'Cannot load the required Python module "cryptography".'
489
+    'Cannot load the required Python module "cryptography".'
490 490
 )
491 491
 
492 492
 skip_if_no_agent = pytest.mark.skipif(
... ...
@@ -543,7 +543,7 @@ def phrase_from_key(key: bytes) -> bytes:
543 543
 
544 544
 @contextlib.contextmanager
545 545
 def isolated_config(
546
-    monkeypatch: Any,
546
+    monkeypatch: pytest.MonkeyPatch,
547 547
     runner: click.testing.CliRunner,
548 548
     config: Any,
549 549
 ) -> Iterator[None]:
... ...
@@ -615,7 +615,7 @@ def isolated_vault_exporter_config(
615 615
 
616 616
 def auto_prompt(*args: Any, **kwargs: Any) -> str:
617 617
     del args, kwargs  # Unused.
618
-    return DUMMY_PASSPHRASE.decode('UTF-8')
618
+    return DUMMY_PASSPHRASE
619 619
 
620 620
 
621 621
 def make_file_readonly(
... ...
@@ -668,3 +668,63 @@ def make_file_readonly(
668 668
     finally:
669 669
         if isinstance(fname, int):
670 670
             os.close(fname)
671
+
672
+
673
+class ReadableResult(NamedTuple):
674
+    """Helper class for formatting and testing click.testing.Result objects."""
675
+
676
+    exception: BaseException | None
677
+    exit_code: int
678
+    output: str
679
+    stderr: str
680
+
681
+    @classmethod
682
+    def parse(cls, r: click.testing.Result, /) -> Self:
683
+        try:
684
+            stderr = r.stderr
685
+        except ValueError:
686
+            stderr = r.output
687
+        return cls(r.exception, r.exit_code, r.output or '', stderr or '')
688
+
689
+    def clean_exit(
690
+        self, *, output: str = '', empty_stderr: bool = False
691
+    ) -> bool:
692
+        """Return whether the invocation exited cleanly.
693
+
694
+        Args:
695
+            output:
696
+                An expected output string.
697
+
698
+        """
699
+        return (
700
+            (
701
+                not self.exception
702
+                or (
703
+                    isinstance(self.exception, SystemExit)
704
+                    and self.exit_code == 0
705
+                )
706
+            )
707
+            and (not output or output in self.output)
708
+            and (not empty_stderr or not self.stderr)
709
+        )
710
+
711
+    def error_exit(
712
+        self, *, error: str | type[BaseException] = BaseException
713
+    ) -> bool:
714
+        """Return whether the invocation exited uncleanly.
715
+
716
+        Args:
717
+            error:
718
+                An expected error message, or an expected numeric error
719
+                code, or an expected exception type.
720
+
721
+        """
722
+        match error:
723
+            case str():
724
+                return (
725
+                    isinstance(self.exception, SystemExit)
726
+                    and self.exit_code > 0
727
+                    and (not error or error in self.stderr)
728
+                )
729
+            case _:
730
+                return isinstance(self.exception, error)
... ...
@@ -44,12 +44,12 @@ DUMMY_KEY3_B64 = tests.DUMMY_KEY3_B64
44 44
 class IncompatibleConfiguration(NamedTuple):
45 45
     other_options: list[tuple[str, ...]]
46 46
     needs_service: bool | None
47
-    input: bytes | None
47
+    input: str | None
48 48
 
49 49
 
50 50
 class SingleConfiguration(NamedTuple):
51 51
     needs_service: bool | None
52
-    input: bytes | None
52
+    input: str | None
53 53
     check_success: bool
54 54
 
55 55
 
... ...
@@ -57,7 +57,7 @@ class OptionCombination(NamedTuple):
57 57
     options: list[str]
58 58
     incompatible: bool
59 59
     needs_service: bool | None
60
-    input: bytes | None
60
+    input: str | None
61 61
     check_success: bool
62 62
 
63 63
 
... ...
@@ -165,7 +165,7 @@ SINGLES: dict[tuple[str, ...], SingleConfiguration] = {
165 165
     ('--delete-globals',): SingleConfiguration(False, None, True),
166 166
     ('--clear',): SingleConfiguration(False, None, True),
167 167
     ('--export', '-'): SingleConfiguration(False, None, True),
168
-    ('--import', '-'): SingleConfiguration(False, b'{"services": {}}', True),
168
+    ('--import', '-'): SingleConfiguration(False, '{"services": {}}', True),
169 169
 }
170 170
 INTERESTING_OPTION_COMBINATIONS: list[OptionCombination] = []
171 171
 config: IncompatibleConfiguration | SingleConfiguration
... ...
@@ -200,29 +200,29 @@ for opt, config in SINGLES.items():
200 200
 
201 201
 
202 202
 class TestCLI:
203
-    def test_200_help_output(self, monkeypatch: Any) -> None:
203
+    def test_200_help_output(self, monkeypatch: pytest.MonkeyPatch) -> None:
204 204
         runner = click.testing.CliRunner(mix_stderr=False)
205 205
         with tests.isolated_config(
206 206
             monkeypatch=monkeypatch,
207 207
             runner=runner,
208 208
             config={'services': {}},
209 209
         ):
210
-            result = runner.invoke(
210
+            _result = runner.invoke(
211 211
                 cli.derivepassphrase, ['--help'], catch_exceptions=False
212 212
             )
213
-        assert result.exit_code == 0
214
-        assert (
215
-            'Password generation:\n' in result.output
216
-        ), 'Option groups not respected in help text.'
217
-        assert (
218
-            'Use NUMBER=0, e.g. "--symbol 0"' in result.output
219
-        ), 'Option group epilog not printed.'
213
+            result = tests.ReadableResult.parse(_result)
214
+        assert result.clean_exit(
215
+            empty_stderr=True, output='Password generation:\n'
216
+        ), 'expected clean exit, and option groups in help text'
217
+        assert result.clean_exit(
218
+            empty_stderr=True, output='Use NUMBER=0, e.g. "--symbol 0"'
219
+        ), 'expected clean exit, and option group epilog in help text'
220 220
 
221 221
     @pytest.mark.parametrize(
222 222
         'charset_name', ['lower', 'upper', 'number', 'space', 'dash', 'symbol']
223 223
     )
224 224
     def test_201_disable_character_set(
225
-        self, monkeypatch: Any, charset_name: str
225
+        self, monkeypatch: pytest.MonkeyPatch, charset_name: str
226 226
     ) -> None:
227 227
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
228 228
         option = f'--{charset_name}'
... ...
@@ -233,25 +233,22 @@ class TestCLI:
233 233
             runner=runner,
234 234
             config={'services': {}},
235 235
         ):
236
-            result = runner.invoke(
236
+            _result = runner.invoke(
237 237
                 cli.derivepassphrase,
238 238
                 [option, '0', '-p', DUMMY_SERVICE],
239 239
                 input=DUMMY_PASSPHRASE,
240 240
                 catch_exceptions=False,
241 241
             )
242
-        assert (
243
-            result.exit_code == 0
244
-        ), f'program died unexpectedly with exit code {result.exit_code}'
245
-        assert (
246
-            not result.stderr_bytes
247
-        ), f'program barfed on stderr: {result.stderr_bytes!r}'
242
+            result = tests.ReadableResult.parse(_result)
243
+        assert result.clean_exit(empty_stderr=True), 'expected clean exit:'
248 244
         for c in charset:
249
-            assert c not in result.stdout, (
250
-                f'derived password contains forbidden character {c!r}: '
251
-                f'{result.stdout!r}'
252
-            )
245
+            assert (
246
+                c not in result.output
247
+            ), f'derived password contains forbidden character {c!r}'
253 248
 
254
-    def test_202_disable_repetition(self, monkeypatch: Any) -> None:
249
+    def test_202_disable_repetition(
250
+        self, monkeypatch: pytest.MonkeyPatch
251
+    ) -> None:
255 252
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
256 253
         runner = click.testing.CliRunner(mix_stderr=False)
257 254
         with tests.isolated_config(
... ...
@@ -259,23 +256,21 @@ class TestCLI:
259 256
             runner=runner,
260 257
             config={'services': {}},
261 258
         ):
262
-            result = runner.invoke(
259
+            _result = runner.invoke(
263 260
                 cli.derivepassphrase,
264 261
                 ['--repeat', '0', '-p', DUMMY_SERVICE],
265 262
                 input=DUMMY_PASSPHRASE,
266 263
                 catch_exceptions=False,
267 264
             )
268
-        assert (
269
-            result.exit_code == 0
270
-        ), f'program died unexpectedly with exit code {result.exit_code}'
271
-        assert (
272
-            not result.stderr_bytes
273
-        ), f'program barfed on stderr: {result.stderr_bytes!r}'
274
-        passphrase = result.stdout.rstrip('\r\n')
265
+            result = tests.ReadableResult.parse(_result)
266
+        assert result.clean_exit(
267
+            empty_stderr=True
268
+        ), 'expected clean exit and empty stderr'
269
+        passphrase = result.output.rstrip('\r\n')
275 270
         for i in range(len(passphrase) - 1):
276 271
             assert passphrase[i : i + 1] != passphrase[i + 1 : i + 2], (
277 272
                 f'derived password contains repeated character '
278
-                f'at position {i}: {result.stdout!r}'
273
+                f'at position {i}: {result.output!r}'
279 274
             )
280 275
 
281 276
     @pytest.mark.parametrize(
... ...
@@ -290,11 +285,7 @@ class TestCLI:
290 285
             ),
291 286
             pytest.param(
292 287
                 {
293
-                    'global': {
294
-                        'phrase': DUMMY_PASSPHRASE.rstrip(b'\n').decode(
295
-                            'ASCII'
296
-                        )
297
-                    },
288
+                    'global': {'phrase': DUMMY_PASSPHRASE.rstrip('\n')},
298 289
                     'services': {
299 290
                         DUMMY_SERVICE: {
300 291
                             'key': DUMMY_KEY1_B64,
... ...
@@ -308,7 +299,7 @@ class TestCLI:
308 299
     )
309 300
     def test_204a_key_from_config(
310 301
         self,
311
-        monkeypatch: Any,
302
+        monkeypatch: pytest.MonkeyPatch,
312 303
         config: _types.VaultConfig,
313 304
     ) -> None:
314 305
         runner = click.testing.CliRunner(mix_stderr=False)
... ...
@@ -318,21 +309,24 @@ class TestCLI:
318 309
             monkeypatch.setattr(
319 310
                 dpp.vault.Vault, 'phrase_from_key', tests.phrase_from_key
320 311
             )
321
-            result = runner.invoke(
312
+            _result = runner.invoke(
322 313
                 cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False
323 314
             )
324
-            assert (result.exit_code, result.stderr_bytes) == (
325
-                0,
326
-                b'',
327
-            ), 'program exited with failure'
315
+        result = tests.ReadableResult.parse(_result)
316
+        assert result.clean_exit(
317
+            empty_stderr=True
318
+        ), 'expected clean exit and empty stderr'
319
+        assert _result.stdout_bytes
328 320
         assert (
329
-                result.stdout_bytes.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE
330
-            ), 'program generated unexpected result (phrase instead of key)'
321
+            _result.stdout_bytes.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE
322
+        ), 'known false output: phrase-based instead of key-based'
331 323
         assert (
332
-                result.stdout_bytes.rstrip(b'\n') == DUMMY_RESULT_KEY1
333
-            ), 'program generated unexpected result (wrong settings?)'
324
+            _result.stdout_bytes.rstrip(b'\n') == DUMMY_RESULT_KEY1
325
+        ), 'expected known output'
334 326
 
335
-    def test_204b_key_from_command_line(self, monkeypatch: Any) -> None:
327
+    def test_204b_key_from_command_line(
328
+        self, monkeypatch: pytest.MonkeyPatch
329
+    ) -> None:
336 330
         runner = click.testing.CliRunner(mix_stderr=False)
337 331
         with tests.isolated_config(
338 332
             monkeypatch=monkeypatch,
... ...
@@ -345,21 +339,22 @@ class TestCLI:
345 339
             monkeypatch.setattr(
346 340
                 dpp.vault.Vault, 'phrase_from_key', tests.phrase_from_key
347 341
             )
348
-            result = runner.invoke(
342
+            _result = runner.invoke(
349 343
                 cli.derivepassphrase,
350 344
                 ['-k', DUMMY_SERVICE],
351
-                input=b'1\n',
345
+                input='1\n',
352 346
                 catch_exceptions=False,
353 347
             )
354
-            assert result.exit_code == 0, 'program exited with failure'
355
-            assert result.stdout_bytes, 'program output expected'
356
-            last_line = result.stdout_bytes.splitlines(True)[-1]
348
+        result = tests.ReadableResult.parse(_result)
349
+        assert result.clean_exit(), 'expected clean exit'
350
+        assert _result.stdout_bytes, 'expected program output'
351
+        last_line = _result.stdout_bytes.splitlines(True)[-1]
357 352
         assert (
358 353
             last_line.rstrip(b'\n') != DUMMY_RESULT_PASSPHRASE
359
-            ), 'program generated unexpected result (phrase instead of key)'
354
+        ), 'known false output: phrase-based instead of key-based'
360 355
         assert (
361 356
             last_line.rstrip(b'\n') == DUMMY_RESULT_KEY1
362
-            ), 'program generated unexpected result (wrong settings?)'
357
+        ), 'expected known output'
363 358
 
364 359
     @tests.skip_if_no_agent
365 360
     @pytest.mark.parametrize(
... ...
@@ -388,7 +383,7 @@ class TestCLI:
388 383
     @pytest.mark.parametrize('key_index', [1, 2, 3], ids=lambda i: f'index{i}')
389 384
     def test_204c_key_override_on_command_line(
390 385
         self,
391
-        monkeypatch: Any,
386
+        monkeypatch: pytest.MonkeyPatch,
392 387
         config: dict[str, Any],
393 388
         key_index: int,
394 389
     ) -> None:
... ...
@@ -410,21 +405,22 @@ class TestCLI:
410 405
         with tests.isolated_config(
411 406
             monkeypatch=monkeypatch, runner=runner, config=config
412 407
         ):
413
-            result = runner.invoke(
408
+            _result = runner.invoke(
414 409
                 cli.derivepassphrase,
415 410
                 ['-k', DUMMY_SERVICE],
416
-                input=f'{key_index}\n'.encode('ASCII'),
411
+                input=f'{key_index}\n',
417 412
             )
418
-            assert result.stderr_bytes is not None
413
+        result = tests.ReadableResult.parse(_result)
414
+        assert result.clean_exit(), 'expected clean exit'
415
+        assert result.output, 'expected program output'
416
+        assert result.stderr, 'expected stderr'
419 417
         assert (
420
-                b'Error:' not in result.stderr_bytes
421
-            ), 'program exited with failure'
422
-            assert result.stdout_bytes, 'program output expected'
423
-            assert result.exit_code == 0, 'program exited with failure'
418
+            'Error:' not in result.stderr
419
+        ), 'expected no error messages on stderr'
424 420
 
425 421
     def test_205_service_phrase_if_key_in_global_config(
426 422
         self,
427
-        monkeypatch: Any,
423
+        monkeypatch: pytest.MonkeyPatch,
428 424
     ) -> None:
429 425
         runner = click.testing.CliRunner(mix_stderr=False)
430 426
         with tests.isolated_config(
... ...
@@ -434,26 +430,25 @@ class TestCLI:
434 430
                 'global': {'key': DUMMY_KEY1_B64},
435 431
                 'services': {
436 432
                     DUMMY_SERVICE: {
437
-                        'phrase': DUMMY_PASSPHRASE.rstrip(b'\n').decode(
438
-                            'ASCII'
439
-                        ),
433
+                        'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
440 434
                         **DUMMY_CONFIG_SETTINGS,
441 435
                     }
442 436
                 },
443 437
             },
444 438
         ):
445
-            result = runner.invoke(
439
+            _result = runner.invoke(
446 440
                 cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False
447 441
             )
448
-            assert result.exit_code == 0, 'program exited with failure'
449
-            assert result.stdout_bytes, 'program output expected'
450
-            last_line = result.stdout_bytes.splitlines(True)[-1]
442
+        result = tests.ReadableResult.parse(_result)
443
+        assert result.clean_exit(), 'expected clean exit'
444
+        assert _result.stdout_bytes, 'expected program output'
445
+        last_line = _result.stdout_bytes.splitlines(True)[-1]
451 446
         assert (
452 447
             last_line.rstrip(b'\n') != DUMMY_RESULT_KEY1
453
-            ), 'program generated unexpected result (key instead of phrase)'
448
+        ), 'known false output: key-based instead of phrase-based'
454 449
         assert (
455 450
             last_line.rstrip(b'\n') == DUMMY_RESULT_PASSPHRASE
456
-            ), 'program generated unexpected result (wrong settings?)'
451
+        ), 'expected known output'
457 452
 
458 453
     @pytest.mark.parametrize(
459 454
         'option',
... ...
@@ -469,7 +464,7 @@ class TestCLI:
469 464
         ],
470 465
     )
471 466
     def test_210_invalid_argument_range(
472
-        self, monkeypatch: Any, option: str
467
+        self, monkeypatch: pytest.MonkeyPatch, option: str
473 468
     ) -> None:
474 469
         runner = click.testing.CliRunner(mix_stderr=False)
475 470
         with tests.isolated_config(
... ...
@@ -478,19 +473,16 @@ class TestCLI:
478 473
             config={'services': {}},
479 474
         ):
480 475
             for value in '-42', 'invalid':
481
-                result = runner.invoke(
476
+                _result = runner.invoke(
482 477
                     cli.derivepassphrase,
483 478
                     [option, value, '-p', DUMMY_SERVICE],
484 479
                     input=DUMMY_PASSPHRASE,
485 480
                     catch_exceptions=False,
486 481
                 )
487
-                assert result.exit_code > 0, 'program unexpectedly succeeded'
488
-                assert (
489
-                    result.stderr_bytes
490
-                ), 'program did not print any error message'
491
-                assert (
492
-                    b'Error: Invalid value' in result.stderr_bytes
493
-                ), 'program did not print the expected error message'
482
+                result = tests.ReadableResult.parse(_result)
483
+                assert result.error_exit(
484
+                    error='Error: Invalid value'
485
+                ), 'expected error exit and known error message'
494 486
 
495 487
     @pytest.mark.parametrize(
496 488
         ['options', 'service', 'input', 'check_success'],
... ...
@@ -502,10 +494,10 @@ class TestCLI:
502 494
     )
503 495
     def test_211_service_needed(
504 496
         self,
505
-        monkeypatch: Any,
497
+        monkeypatch: pytest.MonkeyPatch,
506 498
         options: list[str],
507 499
         service: bool | None,
508
-        input: bytes | None,
500
+        input: str | None,
509 501
         check_success: bool,
510 502
     ) -> None:
511 503
         monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt)
... ...
@@ -515,30 +507,26 @@ class TestCLI:
515 507
             runner=runner,
516 508
             config={'global': {'phrase': 'abc'}, 'services': {}},
517 509
         ):
518
-            result = runner.invoke(
510
+            _result = runner.invoke(
519 511
                 cli.derivepassphrase,
520 512
                 options if service else [*options, DUMMY_SERVICE],
521 513
                 input=input,
522 514
                 catch_exceptions=False,
523 515
             )
516
+            result = tests.ReadableResult.parse(_result)
524 517
             if service is not None:
525
-                assert result.exit_code > 0, 'program unexpectedly succeeded'
526
-                assert (
527
-                    result.stderr_bytes
528
-                ), 'program did not print any error message'
529 518
                 err_msg = (
530
-                    b' requires a SERVICE'
519
+                    ' requires a SERVICE'
531 520
                     if service
532
-                    else b' does not take a SERVICE argument'
521
+                    else ' does not take a SERVICE argument'
533 522
                 )
534
-                assert (
535
-                    err_msg in result.stderr_bytes
536
-                ), 'program did not print the expected error message'
523
+                assert result.error_exit(
524
+                    error=err_msg
525
+                ), 'expected error exit and known error message'
537 526
             else:
538
-                assert (result.exit_code, result.stderr_bytes) == (
539
-                    0,
540
-                    b'',
541
-                ), 'program unexpectedly failed'
527
+                assert result.clean_exit(
528
+                    empty_stderr=True
529
+                ), 'expected clean exit'
542 530
         if check_success:
543 531
             with tests.isolated_config(
544 532
                 monkeypatch=monkeypatch,
... ...
@@ -548,16 +536,14 @@ class TestCLI:
548 536
                 monkeypatch.setattr(
549 537
                     cli, '_prompt_for_passphrase', tests.auto_prompt
550 538
                 )
551
-                result = runner.invoke(
539
+                _result = runner.invoke(
552 540
                     cli.derivepassphrase,
553 541
                     [*options, DUMMY_SERVICE] if service else options,
554 542
                     input=input,
555 543
                     catch_exceptions=False,
556 544
                 )
557
-                assert (result.exit_code, result.stderr_bytes) == (
558
-                    0,
559
-                    b'',
560
-                ), 'program unexpectedly failed'
545
+                result = tests.ReadableResult.parse(_result)
546
+            assert result.clean_exit(empty_stderr=True), 'expected clean exit'
561 547
 
562 548
     @pytest.mark.parametrize(
563 549
         ['options', 'service'],
... ...
@@ -569,7 +555,7 @@ class TestCLI:
569 555
     )
570 556
     def test_212_incompatible_options(
571 557
         self,
572
-        monkeypatch: Any,
558
+        monkeypatch: pytest.MonkeyPatch,
573 559
         options: list[str],
574 560
         service: bool | None,
575 561
     ) -> None:
... ...
@@ -579,65 +565,58 @@ class TestCLI:
579 565
             runner=runner,
580 566
             config={'services': {}},
581 567
         ):
582
-            result = runner.invoke(
568
+            _result = runner.invoke(
583 569
                 cli.derivepassphrase,
584 570
                 [*options, DUMMY_SERVICE] if service else options,
585 571
                 input=DUMMY_PASSPHRASE,
586 572
                 catch_exceptions=False,
587 573
             )
588
-        assert result.exit_code > 0, 'program unexpectedly succeeded'
589
-        assert result.stderr_bytes, 'program did not print any error message'
590
-        assert (
591
-            b'mutually exclusive with ' in result.stderr_bytes
592
-        ), 'program did not print the expected error message'
574
+        result = tests.ReadableResult.parse(_result)
575
+        assert result.error_exit(
576
+            error='mutually exclusive with '
577
+        ), 'expected error exit and known error message'
593 578
 
594 579
     def test_213_import_bad_config_not_vault_config(
595 580
         self,
596
-        monkeypatch: Any,
581
+        monkeypatch: pytest.MonkeyPatch,
597 582
     ) -> None:
598 583
         runner = click.testing.CliRunner(mix_stderr=False)
599 584
         with tests.isolated_config(
600 585
             monkeypatch=monkeypatch, runner=runner, config={'services': {}}
601 586
         ):
602
-            result = runner.invoke(
587
+            _result = runner.invoke(
603 588
                 cli.derivepassphrase,
604 589
                 ['--import', '-'],
605
-                input=b'null',
590
+                input='null',
606 591
                 catch_exceptions=False,
607 592
             )
608
-            assert result.exit_code > 0, 'program unexpectedly succeeded'
609
-            assert (
610
-                result.stderr_bytes
611
-            ), 'program did not print any error message'
612
-            assert (
613
-                b'Invalid vault config' in result.stderr_bytes
614
-            ), 'program did not print the expected error message'
593
+        result = tests.ReadableResult.parse(_result)
594
+        assert result.error_exit(
595
+            error='Invalid vault config'
596
+        ), 'expected error exit and known error message'
615 597
 
616 598
     def test_213a_import_bad_config_not_json_data(
617 599
         self,
618
-        monkeypatch: Any,
600
+        monkeypatch: pytest.MonkeyPatch,
619 601
     ) -> None:
620 602
         runner = click.testing.CliRunner(mix_stderr=False)
621 603
         with tests.isolated_config(
622 604
             monkeypatch=monkeypatch, runner=runner, config={'services': {}}
623 605
         ):
624
-            result = runner.invoke(
606
+            _result = runner.invoke(
625 607
                 cli.derivepassphrase,
626 608
                 ['--import', '-'],
627
-                input=b'This string is not valid JSON.',
609
+                input='This string is not valid JSON.',
628 610
                 catch_exceptions=False,
629 611
             )
630
-            assert result.exit_code > 0, 'program unexpectedly succeeded'
631
-            assert (
632
-                result.stderr_bytes
633
-            ), 'program did not print any error message'
634
-            assert (
635
-                b'cannot decode JSON' in result.stderr_bytes
636
-            ), 'program did not print the expected error message'
612
+        result = tests.ReadableResult.parse(_result)
613
+        assert result.error_exit(
614
+            error='cannot decode JSON'
615
+        ), 'expected error exit and known error message'
637 616
 
638 617
     def test_213b_import_bad_config_not_a_file(
639 618
         self,
640
-        monkeypatch: Any,
619
+        monkeypatch: pytest.MonkeyPatch,
641 620
     ) -> None:
642 621
         runner = click.testing.CliRunner(mix_stderr=False)
643 622
         # `isolated_config` validates the configuration.  So, to pass an
... ...
@@ -651,23 +630,19 @@ class TestCLI:
651 630
             ) as outfile:
652 631
                 print('This string is not valid JSON.', file=outfile)
653 632
             dname = os.path.dirname(cli._config_filename())
654
-            result = runner.invoke(
633
+            _result = runner.invoke(
655 634
                 cli.derivepassphrase,
656 635
                 ['--import', os.fsdecode(dname)],
657 636
                 catch_exceptions=False,
658 637
             )
659
-            assert result.exit_code > 0, 'program unexpectedly succeeded'
660
-            assert (
661
-                result.stderr_bytes
662
-            ), 'program did not print any error message'
663
-            assert (
664
-                os.strerror(errno.EISDIR).encode('utf-8')
665
-                in result.stderr_bytes
666
-            ), 'program did not print the expected error message'
638
+        result = tests.ReadableResult.parse(_result)
639
+        assert result.error_exit(
640
+            error=os.strerror(errno.EISDIR)
641
+        ), 'expected error exit and known error message'
667 642
 
668 643
     def test_214_export_settings_no_stored_settings(
669 644
         self,
670
-        monkeypatch: Any,
645
+        monkeypatch: pytest.MonkeyPatch,
671 646
     ) -> None:
672 647
         runner = click.testing.CliRunner(mix_stderr=False)
673 648
         with tests.isolated_config(
... ...
@@ -675,39 +650,34 @@ class TestCLI:
675 650
         ):
676 651
             with contextlib.suppress(FileNotFoundError):
677 652
                 os.remove(cli._config_filename())
678
-            result = runner.invoke(
653
+            _result = runner.invoke(
679 654
                 cli.derivepassphrase, ['--export', '-'], catch_exceptions=False
680 655
             )
681
-            assert (result.exit_code, result.stderr_bytes) == (
682
-                0,
683
-                b'',
684
-            ), 'program exited with failure'
656
+        result = tests.ReadableResult.parse(_result)
657
+        assert result.clean_exit(empty_stderr=True), 'expected clean exit'
685 658
 
686 659
     def test_214a_export_settings_bad_stored_config(
687 660
         self,
688
-        monkeypatch: Any,
661
+        monkeypatch: pytest.MonkeyPatch,
689 662
     ) -> None:
690 663
         runner = click.testing.CliRunner(mix_stderr=False)
691 664
         with tests.isolated_config(
692 665
             monkeypatch=monkeypatch, runner=runner, config={}
693 666
         ):
694
-            result = runner.invoke(
667
+            _result = runner.invoke(
695 668
                 cli.derivepassphrase,
696 669
                 ['--export', '-'],
697
-                input=b'null',
670
+                input='null',
698 671
                 catch_exceptions=False,
699 672
             )
700
-            assert result.exit_code > 0, 'program unexpectedly succeeded'
701
-            assert (
702
-                result.stderr_bytes
703
-            ), 'program did not print any error message'
704
-            assert (
705
-                b'Cannot load config' in result.stderr_bytes
706
-            ), 'program did not print the expected error message'
673
+        result = tests.ReadableResult.parse(_result)
674
+        assert result.error_exit(
675
+            error='Cannot load config'
676
+        ), 'expected error exit and known error message'
707 677
 
708 678
     def test_214b_export_settings_not_a_file(
709 679
         self,
710
-        monkeypatch: Any,
680
+        monkeypatch: pytest.MonkeyPatch,
711 681
     ) -> None:
712 682
         runner = click.testing.CliRunner(mix_stderr=False)
713 683
         with tests.isolated_config(
... ...
@@ -716,46 +686,40 @@ class TestCLI:
716 686
             with contextlib.suppress(FileNotFoundError):
717 687
                 os.remove(cli._config_filename())
718 688
             os.makedirs(cli._config_filename())
719
-            result = runner.invoke(
689
+            _result = runner.invoke(
720 690
                 cli.derivepassphrase,
721 691
                 ['--export', '-'],
722
-                input=b'null',
692
+                input='null',
723 693
                 catch_exceptions=False,
724 694
             )
725
-            assert result.exit_code > 0, 'program unexpectedly succeeded'
726
-            assert (
727
-                result.stderr_bytes
728
-            ), 'program did not print any error message'
729
-            assert (
730
-                b'Cannot load config' in result.stderr_bytes
731
-            ), 'program did not print the expected error message'
695
+        result = tests.ReadableResult.parse(_result)
696
+        assert result.error_exit(
697
+            error='Cannot load config'
698
+        ), 'expected error exit and known error message'
732 699
 
733 700
     def test_214c_export_settings_target_not_a_file(
734 701
         self,
735
-        monkeypatch: Any,
702
+        monkeypatch: pytest.MonkeyPatch,
736 703
     ) -> None:
737 704
         runner = click.testing.CliRunner(mix_stderr=False)
738 705
         with tests.isolated_config(
739 706
             monkeypatch=monkeypatch, runner=runner, config={'services': {}}
740 707
         ):
741 708
             dname = os.path.dirname(cli._config_filename())
742
-            result = runner.invoke(
709
+            _result = runner.invoke(
743 710
                 cli.derivepassphrase,
744 711
                 ['--export', os.fsdecode(dname)],
745
-                input=b'null',
712
+                input='null',
746 713
                 catch_exceptions=False,
747 714
             )
748
-            assert result.exit_code > 0, 'program unexpectedly succeeded'
749
-            assert (
750
-                result.stderr_bytes
751
-            ), 'program did not print any error message'
752
-            assert (
753
-                b'Cannot store config' in result.stderr_bytes
754
-            ), 'program did not print the expected error message'
715
+        result = tests.ReadableResult.parse(_result)
716
+        assert result.error_exit(
717
+            error='Cannot store config'
718
+        ), 'expected error exit and known error message'
755 719
 
756 720
     def test_214d_export_settings_settings_directory_not_a_directory(
757 721
         self,
758
-        monkeypatch: Any,
722
+        monkeypatch: pytest.MonkeyPatch,
759 723
     ) -> None:
760 724
         runner = click.testing.CliRunner(mix_stderr=False)
761 725
         with tests.isolated_config(
... ...
@@ -765,21 +729,20 @@ class TestCLI:
765 729
                 shutil.rmtree('.derivepassphrase')
766 730
             with open('.derivepassphrase', 'w', encoding='UTF-8') as outfile:
767 731
                 print('Obstruction!!', file=outfile)
768
-            result = runner.invoke(
732
+            _result = runner.invoke(
769 733
                 cli.derivepassphrase,
770 734
                 ['--export', '-'],
771
-                input=b'null',
735
+                input='null',
772 736
                 catch_exceptions=False,
773 737
             )
774
-            assert result.exit_code > 0, 'program unexpectedly succeeded'
775
-            assert (
776
-                result.stderr_bytes
777
-            ), 'program did not print any error message'
778
-            assert (
779
-                b'Cannot load config' in result.stderr_bytes
780
-            ), 'program did not print the expected error message'
738
+        result = tests.ReadableResult.parse(_result)
739
+        assert result.error_exit(
740
+            error='Cannot load config'
741
+        ), 'expected error exit and known error message'
781 742
 
782
-    def test_220_edit_notes_successfully(self, monkeypatch: Any) -> None:
743
+    def test_220_edit_notes_successfully(
744
+        self, monkeypatch: pytest.MonkeyPatch
745
+    ) -> None:
783 746
         edit_result = """
784 747
 
785 748
 # - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - -
... ...
@@ -792,13 +755,11 @@ contents go here
792 755
             config={'global': {'phrase': 'abc'}, 'services': {}},
793 756
         ):
794 757
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: edit_result)  # noqa: ARG005
795
-            result = runner.invoke(
758
+            _result = runner.invoke(
796 759
                 cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False
797 760
             )
798
-            assert (result.exit_code, result.stderr_bytes) == (
799
-                0,
800
-                b'',
801
-            ), 'program exited with failure'
761
+            result = tests.ReadableResult.parse(_result)
762
+            assert result.clean_exit(empty_stderr=True), 'expected clean exit'
802 763
             with open(cli._config_filename(), encoding='UTF-8') as infile:
803 764
                 config = json.load(infile)
804 765
             assert config == {
... ...
@@ -806,7 +767,9 @@ contents go here
806 767
                 'services': {'sv': {'notes': 'contents go here'}},
807 768
             }
808 769
 
809
-    def test_221_edit_notes_noop(self, monkeypatch: Any) -> None:
770
+    def test_221_edit_notes_noop(
771
+        self, monkeypatch: pytest.MonkeyPatch
772
+    ) -> None:
810 773
         runner = click.testing.CliRunner(mix_stderr=False)
811 774
         with tests.isolated_config(
812 775
             monkeypatch=monkeypatch,
... ...
@@ -814,18 +777,18 @@ contents go here
814 777
             config={'global': {'phrase': 'abc'}, 'services': {}},
815 778
         ):
816 779
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: None)  # noqa: ARG005
817
-            result = runner.invoke(
780
+            _result = runner.invoke(
818 781
                 cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False
819 782
             )
820
-            assert (result.exit_code, result.stderr_bytes) == (
821
-                0,
822
-                b'',
823
-            ), 'program exited with failure'
783
+            result = tests.ReadableResult.parse(_result)
784
+            assert result.clean_exit(empty_stderr=True), 'expected clean exit'
824 785
             with open(cli._config_filename(), encoding='UTF-8') as infile:
825 786
                 config = json.load(infile)
826 787
             assert config == {'global': {'phrase': 'abc'}, 'services': {}}
827 788
 
828
-    def test_222_edit_notes_marker_removed(self, monkeypatch: Any) -> None:
789
+    def test_222_edit_notes_marker_removed(
790
+        self, monkeypatch: pytest.MonkeyPatch
791
+    ) -> None:
829 792
         runner = click.testing.CliRunner(mix_stderr=False)
830 793
         with tests.isolated_config(
831 794
             monkeypatch=monkeypatch,
... ...
@@ -833,13 +796,11 @@ contents go here
833 796
             config={'global': {'phrase': 'abc'}, 'services': {}},
834 797
         ):
835 798
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: 'long\ntext')  # noqa: ARG005
836
-            result = runner.invoke(
799
+            _result = runner.invoke(
837 800
                 cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False
838 801
             )
839
-            assert (result.exit_code, result.stderr_bytes) == (
840
-                0,
841
-                b'',
842
-            ), 'program exited with failure'
802
+            result = tests.ReadableResult.parse(_result)
803
+            assert result.clean_exit(empty_stderr=True), 'expected clean exit'
843 804
             with open(cli._config_filename(), encoding='UTF-8') as infile:
844 805
                 config = json.load(infile)
845 806
             assert config == {
... ...
@@ -847,7 +808,9 @@ contents go here
847 808
                 'services': {'sv': {'notes': 'long\ntext'}},
848 809
             }
849 810
 
850
-    def test_223_edit_notes_abort(self, monkeypatch: Any) -> None:
811
+    def test_223_edit_notes_abort(
812
+        self, monkeypatch: pytest.MonkeyPatch
813
+    ) -> None:
851 814
         runner = click.testing.CliRunner(mix_stderr=False)
852 815
         with tests.isolated_config(
853 816
             monkeypatch=monkeypatch,
... ...
@@ -855,14 +818,13 @@ contents go here
855 818
             config={'global': {'phrase': 'abc'}, 'services': {}},
856 819
         ):
857 820
             monkeypatch.setattr(click, 'edit', lambda *a, **kw: '\n\n')  # noqa: ARG005
858
-            result = runner.invoke(
821
+            _result = runner.invoke(
859 822
                 cli.derivepassphrase, ['--notes', 'sv'], catch_exceptions=False
860 823
             )
861
-            assert result.exit_code != 0, 'program unexpectedly succeeded'
862
-            assert result.stderr_bytes is not None
863
-            assert (
864
-                b'user aborted request' in result.stderr_bytes
865
-            ), 'expected error message missing'
824
+            result = tests.ReadableResult.parse(_result)
825
+            assert result.error_exit(
826
+                error='user aborted request'
827
+            ), 'expected known error message'
866 828
             with open(cli._config_filename(), encoding='UTF-8') as infile:
867 829
                 config = json.load(infile)
868 830
             assert config == {'global': {'phrase': 'abc'}, 'services': {}}
... ...
@@ -872,17 +834,17 @@ contents go here
872 834
         [
873 835
             (
874 836
                 ['--phrase'],
875
-                b'my passphrase\n',
837
+                'my passphrase\n',
876 838
                 {'global': {'phrase': 'my passphrase'}, 'services': {}},
877 839
             ),
878 840
             (
879 841
                 ['--key'],
880
-                b'1\n',
842
+                '1\n',
881 843
                 {'global': {'key': DUMMY_KEY1_B64}, 'services': {}},
882 844
             ),
883 845
             (
884 846
                 ['--phrase', 'sv'],
885
-                b'my passphrase\n',
847
+                'my passphrase\n',
886 848
                 {
887 849
                     'global': {'phrase': 'abc'},
888 850
                     'services': {'sv': {'phrase': 'my passphrase'}},
... ...
@@ -890,7 +852,7 @@ contents go here
890 852
             ),
891 853
             (
892 854
                 ['--key', 'sv'],
893
-                b'1\n',
855
+                '1\n',
894 856
                 {
895 857
                     'global': {'phrase': 'abc'},
896 858
                     'services': {'sv': {'key': DUMMY_KEY1_B64}},
... ...
@@ -898,7 +860,7 @@ contents go here
898 860
             ),
899 861
             (
900 862
                 ['--key', '--length', '15', 'sv'],
901
-                b'1\n',
863
+                '1\n',
902 864
                 {
903 865
                     'global': {'phrase': 'abc'},
904 866
                     'services': {'sv': {'key': DUMMY_KEY1_B64, 'length': 15}},
... ...
@@ -908,9 +870,9 @@ contents go here
908 870
     )
909 871
     def test_224_store_config_good(
910 872
         self,
911
-        monkeypatch: Any,
873
+        monkeypatch: pytest.MonkeyPatch,
912 874
         command_line: list[str],
913
-        input: bytes,
875
+        input: str,
914 876
         result_config: Any,
915 877
     ) -> None:
916 878
         runner = click.testing.CliRunner(mix_stderr=False)
... ...
@@ -922,13 +884,14 @@ contents go here
922 884
             monkeypatch.setattr(
923 885
                 cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys
924 886
             )
925
-            result = runner.invoke(
887
+            _result = runner.invoke(
926 888
                 cli.derivepassphrase,
927 889
                 ['--config', *command_line],
928 890
                 catch_exceptions=False,
929 891
                 input=input,
930 892
             )
931
-            assert result.exit_code == 0, 'program exited with failure'
893
+            result = tests.ReadableResult.parse(_result)
894
+            assert result.clean_exit(), 'expected clean exit'
932 895
             with open(cli._config_filename(), encoding='UTF-8') as infile:
933 896
                 config = json.load(infile)
934 897
             assert (
... ...
@@ -938,26 +901,22 @@ contents go here
938 901
     @pytest.mark.parametrize(
939 902
         ['command_line', 'input', 'err_text'],
940 903
         [
941
-            (
942
-                [],
943
-                b'',
944
-                b'Cannot update global settings without actual settings',
945
-            ),
904
+            ([], '', 'Cannot update global settings without actual settings'),
946 905
             (
947 906
                 ['sv'],
948
-                b'',
949
-                b'Cannot update service settings without actual settings',
907
+                '',
908
+                'Cannot update service settings without actual settings',
950 909
             ),
951
-            (['--phrase', 'sv'], b'', b'No passphrase given'),
952
-            (['--key'], b'', b'No valid SSH key selected'),
910
+            (['--phrase', 'sv'], '', 'No passphrase given'),
911
+            (['--key'], '', 'No valid SSH key selected'),
953 912
         ],
954 913
     )
955 914
     def test_225_store_config_fail(
956 915
         self,
957
-        monkeypatch: Any,
916
+        monkeypatch: pytest.MonkeyPatch,
958 917
         command_line: list[str],
959
-        input: bytes,
960
-        err_text: bytes,
918
+        input: str,
919
+        err_text: str,
961 920
     ) -> None:
962 921
         runner = click.testing.CliRunner(mix_stderr=False)
963 922
         with tests.isolated_config(
... ...
@@ -968,21 +927,20 @@ contents go here
968 927
             monkeypatch.setattr(
969 928
                 cli, '_get_suitable_ssh_keys', tests.suitable_ssh_keys
970 929
             )
971
-            result = runner.invoke(
930
+            _result = runner.invoke(
972 931
                 cli.derivepassphrase,
973 932
                 ['--config', *command_line],
974 933
                 catch_exceptions=False,
975 934
                 input=input,
976 935
             )
977
-            assert result.exit_code != 0, 'program unexpectedly succeeded?!'
978
-            assert result.stderr_bytes is not None
979
-            assert (
980
-                err_text in result.stderr_bytes
981
-            ), 'expected error message missing'
936
+        result = tests.ReadableResult.parse(_result)
937
+        assert result.error_exit(
938
+            error=err_text
939
+        ), 'expected error exit and known error message'
982 940
 
983 941
     def test_225a_store_config_fail_manual_no_ssh_key_selection(
984 942
         self,
985
-        monkeypatch: Any,
943
+        monkeypatch: pytest.MonkeyPatch,
986 944
     ) -> None:
987 945
         runner = click.testing.CliRunner(mix_stderr=False)
988 946
         with tests.isolated_config(
... ...
@@ -996,20 +954,19 @@ contents go here
996 954
                 raise RuntimeError(custom_error)
997 955
 
998 956
             monkeypatch.setattr(cli, '_select_ssh_key', raiser)
999
-            result = runner.invoke(
957
+            _result = runner.invoke(
1000 958
                 cli.derivepassphrase,
1001 959
                 ['--key', '--config'],
1002 960
                 catch_exceptions=False,
1003 961
             )
1004
-            assert result.exit_code != 0, 'program unexpectedly succeeded'
1005
-            assert result.stderr_bytes is not None
1006
-            assert (
1007
-                custom_error.encode() in result.stderr_bytes
1008
-            ), 'expected error message missing'
962
+        result = tests.ReadableResult.parse(_result)
963
+        assert result.error_exit(
964
+            error=custom_error
965
+        ), 'expected error exit and known error message'
1009 966
 
1010 967
     def test_225b_store_config_fail_manual_no_ssh_agent(
1011 968
         self,
1012
-        monkeypatch: Any,
969
+        monkeypatch: pytest.MonkeyPatch,
1013 970
     ) -> None:
1014 971
         runner = click.testing.CliRunner(mix_stderr=False)
1015 972
         with tests.isolated_config(
... ...
@@ -1018,20 +975,19 @@ contents go here
1018 975
             config={'global': {'phrase': 'abc'}, 'services': {}},
1019 976
         ):
1020 977
             monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
1021
-            result = runner.invoke(
978
+            _result = runner.invoke(
1022 979
                 cli.derivepassphrase,
1023 980
                 ['--key', '--config'],
1024 981
                 catch_exceptions=False,
1025 982
             )
1026
-            assert result.exit_code != 0, 'program unexpectedly succeeded'
1027
-            assert result.stderr_bytes is not None
1028
-            assert (
1029
-                b'Cannot find running SSH agent' in result.stderr_bytes
1030
-            ), 'expected error message missing'
983
+        result = tests.ReadableResult.parse(_result)
984
+        assert result.error_exit(
985
+            error='Cannot find running SSH agent'
986
+        ), 'expected error exit and known error message'
1031 987
 
1032 988
     def test_225c_store_config_fail_manual_bad_ssh_agent_connection(
1033 989
         self,
1034
-        monkeypatch: Any,
990
+        monkeypatch: pytest.MonkeyPatch,
1035 991
     ) -> None:
1036 992
         runner = click.testing.CliRunner(mix_stderr=False)
1037 993
         with tests.isolated_config(
... ...
@@ -1040,21 +996,20 @@ contents go here
1040 996
             config={'global': {'phrase': 'abc'}, 'services': {}},
1041 997
         ):
1042 998
             monkeypatch.setenv('SSH_AUTH_SOCK', os.getcwd())
1043
-            result = runner.invoke(
999
+            _result = runner.invoke(
1044 1000
                 cli.derivepassphrase,
1045 1001
                 ['--key', '--config'],
1046 1002
                 catch_exceptions=False,
1047 1003
             )
1048
-            assert result.exit_code != 0, 'program unexpectedly succeeded'
1049
-            assert result.stderr_bytes is not None
1050
-            assert (
1051
-                b'Cannot connect to SSH agent' in result.stderr_bytes
1052
-            ), 'expected error message missing'
1004
+        result = tests.ReadableResult.parse(_result)
1005
+        assert result.error_exit(
1006
+            error='Cannot connect to SSH agent'
1007
+        ), 'expected error exit and known error message'
1053 1008
 
1054 1009
     @pytest.mark.parametrize('try_race_free_implementation', [True, False])
1055 1010
     def test_225d_store_config_fail_manual_read_only_file(
1056 1011
         self,
1057
-        monkeypatch: Any,
1012
+        monkeypatch: pytest.MonkeyPatch,
1058 1013
         try_race_free_implementation: bool,
1059 1014
     ) -> None:
1060 1015
         runner = click.testing.CliRunner(mix_stderr=False)
... ...
@@ -1067,20 +1022,19 @@ contents go here
1067 1022
                 cli._config_filename(),
1068 1023
                 try_race_free_implementation=try_race_free_implementation,
1069 1024
             )
1070
-            result = runner.invoke(
1025
+            _result = runner.invoke(
1071 1026
                 cli.derivepassphrase,
1072 1027
                 ['--config', '--length=15', DUMMY_SERVICE],
1073 1028
                 catch_exceptions=False,
1074 1029
             )
1075
-            assert result.exit_code != 0, 'program unexpectedly succeeded'
1076
-            assert result.stderr_bytes is not None
1077
-            assert (
1078
-                b'Cannot store config' in result.stderr_bytes
1079
-            ), 'expected error message missing'
1030
+        result = tests.ReadableResult.parse(_result)
1031
+        assert result.error_exit(
1032
+            error='Cannot store config'
1033
+        ), 'expected error exit and known error message'
1080 1034
 
1081 1035
     def test_225e_store_config_fail_manual_custom_error(
1082 1036
         self,
1083
-        monkeypatch: Any,
1037
+        monkeypatch: pytest.MonkeyPatch,
1084 1038
     ) -> None:
1085 1039
         runner = click.testing.CliRunner(mix_stderr=False)
1086 1040
         with tests.isolated_config(
... ...
@@ -1095,50 +1049,51 @@ contents go here
1095 1049
                 raise RuntimeError(custom_error)
1096 1050
 
1097 1051
             monkeypatch.setattr(cli, '_save_config', raiser)
1098
-            result = runner.invoke(
1052
+            _result = runner.invoke(
1099 1053
                 cli.derivepassphrase,
1100 1054
                 ['--config', '--length=15', DUMMY_SERVICE],
1101 1055
                 catch_exceptions=False,
1102 1056
             )
1103
-            assert result.exit_code != 0, 'program unexpectedly succeeded'
1104
-            assert result.stderr_bytes is not None
1105
-            assert (
1106
-                custom_error.encode() in result.stderr_bytes
1107
-            ), 'expected error message missing'
1057
+        result = tests.ReadableResult.parse(_result)
1058
+        assert result.error_exit(
1059
+            error=custom_error
1060
+        ), 'expected error exit and known error message'
1108 1061
 
1109
-    def test_226_no_arguments(self, monkeypatch: Any) -> None:
1062
+    def test_226_no_arguments(self, monkeypatch: pytest.MonkeyPatch) -> None:
1110 1063
         runner = click.testing.CliRunner(mix_stderr=False)
1111 1064
         with tests.isolated_config(
1112 1065
             monkeypatch=monkeypatch,
1113 1066
             runner=runner,
1114 1067
             config={'services': {}},
1115 1068
         ):
1116
-            result = runner.invoke(
1069
+            _result = runner.invoke(
1117 1070
                 cli.derivepassphrase, [], catch_exceptions=False
1118 1071
             )
1119
-        assert result.exit_code != 0, 'program unexpectedly succeeded'
1120
-        assert result.stderr_bytes is not None
1121
-        assert (
1122
-            b'SERVICE is required' in result.stderr_bytes
1123
-        ), 'expected error message missing'
1072
+        result = tests.ReadableResult.parse(_result)
1073
+        assert result.error_exit(
1074
+            error='SERVICE is required'
1075
+        ), 'expected error exit and known error message'
1124 1076
 
1125
-    def test_226a_no_passphrase_or_key(self, monkeypatch: Any) -> None:
1077
+    def test_226a_no_passphrase_or_key(
1078
+        self, monkeypatch: pytest.MonkeyPatch
1079
+    ) -> None:
1126 1080
         runner = click.testing.CliRunner(mix_stderr=False)
1127 1081
         with tests.isolated_config(
1128 1082
             monkeypatch=monkeypatch,
1129 1083
             runner=runner,
1130 1084
             config={'services': {}},
1131 1085
         ):
1132
-            result = runner.invoke(
1086
+            _result = runner.invoke(
1133 1087
                 cli.derivepassphrase, [DUMMY_SERVICE], catch_exceptions=False
1134 1088
             )
1135
-        assert result.exit_code != 0, 'program unexpectedly succeeded'
1136
-        assert result.stderr_bytes is not None
1137
-        assert (
1138
-            b'No passphrase or key given' in result.stderr_bytes
1139
-        ), 'expected error message missing'
1089
+        result = tests.ReadableResult.parse(_result)
1090
+        assert result.error_exit(
1091
+            error='No passphrase or key given'
1092
+        ), 'expected error exit and known error message'
1140 1093
 
1141
-    def test_230_config_directory_nonexistant(self, monkeypatch: Any) -> None:
1094
+    def test_230_config_directory_nonexistant(
1095
+        self, monkeypatch: pytest.MonkeyPatch
1096
+    ) -> None:
1142 1097
         """the-13th-letter/derivepassphrase#6"""
1143 1098
         runner = click.testing.CliRunner(mix_stderr=False)
1144 1099
         with tests.isolated_config(
... ...
@@ -1157,16 +1112,17 @@ contents go here
1157 1112
                 return real_os_makedirs(*args, **kwargs)
1158 1113
 
1159 1114
             monkeypatch.setattr(os, 'makedirs', makedirs)
1160
-            result = runner.invoke(
1115
+            _result = runner.invoke(
1161 1116
                 cli.derivepassphrase,
1162 1117
                 ['--config', '-p'],
1163 1118
                 catch_exceptions=False,
1164 1119
                 input='abc\n',
1165 1120
             )
1121
+            result = tests.ReadableResult.parse(_result)
1122
+            assert result.clean_exit(), 'expected clean exit'
1166 1123
             assert (
1167
-                result.stderr_bytes == b'Passphrase:'
1124
+                result.stderr == 'Passphrase:'
1168 1125
             ), 'program unexpectedly failed?!'
1169
-            assert result.exit_code == 0, 'program unexpectedly failed?!'
1170 1126
             assert os_makedirs_called, 'os.makedirs has not been called?!'
1171 1127
             with open(cli._config_filename(), encoding='UTF-8') as infile:
1172 1128
                 config_readback = json.load(infile)
... ...
@@ -1175,7 +1131,9 @@ contents go here
1175 1131
                 'services': {},
1176 1132
             }, 'config mismatch'
1177 1133
 
1178
-    def test_230a_config_directory_not_a_file(self, monkeypatch: Any) -> None:
1134
+    def test_230a_config_directory_not_a_file(
1135
+        self, monkeypatch: pytest.MonkeyPatch
1136
+    ) -> None:
1179 1137
         """the-13th-letter/derivepassphrase#6"""
1180 1138
         runner = click.testing.CliRunner(mix_stderr=False)
1181 1139
         with tests.isolated_config(
... ...
@@ -1196,19 +1154,20 @@ contents go here
1196 1154
                 return _save_config(*args, **kwargs)
1197 1155
 
1198 1156
             monkeypatch.setattr(cli, '_save_config', obstruct_config_saving)
1199
-            result = runner.invoke(
1157
+            _result = runner.invoke(
1200 1158
                 cli.derivepassphrase,
1201 1159
                 ['--config', '-p'],
1202 1160
                 catch_exceptions=False,
1203 1161
                 input='abc\n',
1204 1162
             )
1205
-            assert result.exit_code != 0, 'program unexpectedly succeeded?!'
1206
-            assert result.stderr_bytes is not None
1207
-            assert (
1208
-                b'Cannot store config' in result.stderr_bytes
1209
-            ), 'program unexpectedly failed?!'
1163
+            result = tests.ReadableResult.parse(_result)
1164
+            assert result.error_exit(
1165
+                error='Cannot store config'
1166
+            ), 'expected error exit and known error message'
1210 1167
 
1211
-    def test_230b_store_config_custom_error(self, monkeypatch: Any) -> None:
1168
+    def test_230b_store_config_custom_error(
1169
+        self, monkeypatch: pytest.MonkeyPatch
1170
+    ) -> None:
1212 1171
         runner = click.testing.CliRunner(mix_stderr=False)
1213 1172
         with tests.isolated_config(
1214 1173
             monkeypatch=monkeypatch,
... ...
@@ -1228,20 +1187,19 @@ contents go here
1228 1187
                 return _save_config(*args, **kwargs)
1229 1188
 
1230 1189
             monkeypatch.setattr(cli, '_save_config', obstruct_config_saving)
1231
-            result = runner.invoke(
1190
+            _result = runner.invoke(
1232 1191
                 cli.derivepassphrase,
1233 1192
                 ['--config', '-p'],
1234 1193
                 catch_exceptions=False,
1235 1194
                 input='abc\n',
1236 1195
             )
1237
-            assert result.exit_code != 0, 'program unexpectedly succeeded?!'
1238
-            assert result.stderr_bytes is not None
1239
-            assert (
1240
-                b'Cannot store config' in result.stderr_bytes
1241
-            ), 'program unexpectedly failed?!'
1196
+            result = tests.ReadableResult.parse(_result)
1197
+            assert result.error_exit(
1198
+                error='Cannot store config'
1199
+            ), 'expected error exit and known error message'
1242 1200
 
1243 1201
     @pytest.mark.parametrize(
1244
-        ['command_line', 'input', 'error_message'],
1202
+        ['command_line', 'input', 'warning_message'],
1245 1203
         [
1246 1204
             pytest.param(
1247 1205
                 ['--import', '-'],
... ...
@@ -1314,10 +1272,10 @@ contents go here
1314 1272
     )
1315 1273
     def test_300_unicode_normalization_form_warning(
1316 1274
         self,
1317
-        monkeypatch: Any,
1275
+        monkeypatch: pytest.MonkeyPatch,
1318 1276
         command_line: list[str],
1319 1277
         input: str | None,
1320
-        error_message: str,
1278
+        warning_message: str,
1321 1279
     ) -> None:
1322 1280
         runner = click.testing.CliRunner(mix_stderr=False)
1323 1281
         with tests.isolated_config(
... ...
@@ -1325,19 +1283,23 @@ contents go here
1325 1283
             runner=runner,
1326 1284
             config={'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}},
1327 1285
         ):
1328
-            result = runner.invoke(
1286
+            _result = runner.invoke(
1329 1287
                 cli.derivepassphrase,
1330 1288
                 command_line,
1331 1289
                 catch_exceptions=False,
1332 1290
                 input=input,
1333 1291
             )
1334
-            assert (result.exception, result.exit_code) == (None, 0)
1335
-            assert result.stderr_bytes is not None
1336
-            assert error_message in result.stderr
1292
+        result = tests.ReadableResult.parse(_result)
1293
+        assert result.clean_exit(), 'expected clean exit'
1294
+        assert (
1295
+            warning_message in result.stderr
1296
+        ), 'expected known warning message in stderr'
1337 1297
 
1338 1298
 
1339 1299
 class TestCLIUtils:
1340
-    def test_100_save_bad_config(self, monkeypatch: Any) -> None:
1300
+    def test_100_save_bad_config(
1301
+        self, monkeypatch: pytest.MonkeyPatch
1302
+    ) -> None:
1341 1303
         runner = click.testing.CliRunner()
1342 1304
         with (
1343 1305
             tests.isolated_config(
... ...
@@ -1378,11 +1340,10 @@ class TestCLIUtils:
1378 1340
             click.echo('(Note: Vikings strictly optional.)')
1379 1341
 
1380 1342
         runner = click.testing.CliRunner(mix_stderr=True)
1381
-        result = runner.invoke(driver, [], input='9')
1382
-        assert result.exit_code == 0, 'driver program failed'
1383
-        assert (
1384
-            result.stdout
1385
-            == """\
1343
+        _result = runner.invoke(driver, [], input='9')
1344
+        result = tests.ReadableResult.parse(_result)
1345
+        assert result.clean_exit(
1346
+            output="""\
1386 1347
 Our menu:
1387 1348
 [1] Egg and bacon
1388 1349
 [2] Egg, sausage and bacon
... ...
@@ -1398,13 +1359,16 @@ Your selection? (1-10, leave empty to abort): 9
1398 1359
 A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam and spam
1399 1360
 (Note: Vikings strictly optional.)
1400 1361
 """  # noqa: E501
1401
-        ), 'driver program produced unexpected output'
1402
-        result = runner.invoke(
1362
+        ), 'expected clean exit'
1363
+        _result = runner.invoke(
1403 1364
             driver, ['--heading='], input='', catch_exceptions=True
1404 1365
         )
1405
-        assert result.exit_code > 0, 'driver program succeeded?!'
1366
+        result = tests.ReadableResult.parse(_result)
1367
+        assert result.error_exit(
1368
+            error=IndexError
1369
+        ), 'expected error exit and known error type'
1406 1370
         assert (
1407
-            result.stdout
1371
+            result.output
1408 1372
             == """\
1409 1373
 [1] Egg and bacon
1410 1374
 [2] Egg, sausage and bacon
... ...
@@ -1418,10 +1382,7 @@ A fine choice: Spam, spam, spam, spam, spam, spam, baked beans, spam, spam, spam
1418 1382
 [10] Lobster thermidor aux crevettes with a mornay sauce garnished with truffle paté, brandy and a fried egg on top and spam
1419 1383
 Your selection? (1-10, leave empty to abort):\x20
1420 1384
 """  # noqa: E501
1421
-        ), 'driver program produced unexpected output'
1422
-        assert isinstance(
1423
-            result.exception, IndexError
1424
-        ), 'driver program did not raise IndexError?!'
1385
+        ), 'expected known output'
1425 1386
 
1426 1387
     def test_102_prompt_for_selection_single(self) -> None:
1427 1388
         @click.command()
... ...
@@ -1439,37 +1400,38 @@ Your selection? (1-10, leave empty to abort):\x20
1439 1400
                 click.echo('Great!')
1440 1401
 
1441 1402
         runner = click.testing.CliRunner(mix_stderr=True)
1442
-        result = runner.invoke(
1403
+        _result = runner.invoke(
1443 1404
             driver, ['Will replace with spam. Confirm, y/n?'], input='y'
1444 1405
         )
1445
-        assert result.exit_code == 0, 'driver program failed'
1446
-        assert (
1447
-            result.stdout
1448
-            == """\
1406
+        result = tests.ReadableResult.parse(_result)
1407
+        assert result.clean_exit(
1408
+            output="""\
1449 1409
 [1] baked beans
1450 1410
 Will replace with spam. Confirm, y/n? y
1451 1411
 Great!
1452 1412
 """
1453
-        ), 'driver program produced unexpected output'
1454
-        result = runner.invoke(
1413
+        ), 'expected clean exit'
1414
+        _result = runner.invoke(
1455 1415
             driver,
1456 1416
             ['Will replace with spam, okay? ' '(Please say "y" or "n".)'],
1457 1417
             input='',
1458 1418
         )
1459
-        assert result.exit_code > 0, 'driver program succeeded?!'
1419
+        result = tests.ReadableResult.parse(_result)
1420
+        assert result.error_exit(
1421
+            error=IndexError
1422
+        ), 'expected error exit and known error type'
1460 1423
         assert (
1461
-            result.stdout
1424
+            result.output
1462 1425
             == """\
1463 1426
 [1] baked beans
1464 1427
 Will replace with spam, okay? (Please say "y" or "n".):\x20
1465 1428
 Boo.
1466 1429
 """
1467
-        ), 'driver program produced unexpected output'
1468
-        assert isinstance(
1469
-            result.exception, IndexError
1470
-        ), 'driver program did not raise IndexError?!'
1430
+        ), 'expected known output'
1471 1431
 
1472
-    def test_103_prompt_for_passphrase(self, monkeypatch: Any) -> None:
1432
+    def test_103_prompt_for_passphrase(
1433
+        self, monkeypatch: pytest.MonkeyPatch
1434
+    ) -> None:
1473 1435
         monkeypatch.setattr(
1474 1436
             click,
1475 1437
             'prompt',
... ...
@@ -1513,7 +1475,7 @@ Boo.
1513 1475
     )
1514 1476
     def test_203_repeated_config_deletion(
1515 1477
         self,
1516
-        monkeypatch: Any,
1478
+        monkeypatch: pytest.MonkeyPatch,
1517 1479
         command_line: list[str],
1518 1480
         config: _types.VaultConfig,
1519 1481
         result_config: _types.VaultConfig,
... ...
@@ -1523,13 +1485,13 @@ Boo.
1523 1485
             with tests.isolated_config(
1524 1486
                 monkeypatch=monkeypatch, runner=runner, config=start_config
1525 1487
             ):
1526
-                result = runner.invoke(
1488
+                _result = runner.invoke(
1527 1489
                     cli.derivepassphrase, command_line, catch_exceptions=False
1528 1490
                 )
1529
-                assert (result.exit_code, result.stderr_bytes) == (
1530
-                    0,
1531
-                    b'',
1532
-                ), 'program exited with failure'
1491
+                result = tests.ReadableResult.parse(_result)
1492
+                assert result.clean_exit(
1493
+                    empty_stderr=True
1494
+                ), 'expected clean exit'
1533 1495
                 with open(cli._config_filename(), encoding='UTF-8') as infile:
1534 1496
                     config_readback = json.load(infile)
1535 1497
                 assert config_readback == result_config
... ...
@@ -1562,7 +1524,7 @@ Boo.
1562 1524
     @pytest.mark.parametrize('conn_hint', ['none', 'socket', 'client'])
1563 1525
     def test_227_get_suitable_ssh_keys(
1564 1526
         self,
1565
-        monkeypatch: Any,
1527
+        monkeypatch: pytest.MonkeyPatch,
1566 1528
         conn_hint: str,
1567 1529
     ) -> None:
1568 1530
         monkeypatch.setattr(
... ...
@@ -115,18 +115,16 @@ class Test002CLI:
115 115
             vault_config=tests.VAULT_V03_CONFIG,
116 116
             vault_key=tests.VAULT_MASTER_KEY,
117 117
         ):
118
-            result = runner.invoke(
118
+            _result = runner.invoke(
119 119
                 cli.derivepassphrase_export,
120 120
                 ['-f', 'INVALID', 'VAULT_PATH'],
121 121
                 catch_exceptions=False,
122 122
             )
123
-        assert isinstance(result.exception, SystemExit)
124
-        assert result.exit_code
125
-        assert result.stderr_bytes
126
-        assert b'Invalid value for' in result.stderr_bytes
127
-        assert b'-f' in result.stderr_bytes
128
-        assert b'--format' in result.stderr_bytes
129
-        assert b'INVALID' in result.stderr_bytes
123
+        result = tests.ReadableResult.parse(_result)
124
+        for snippet in ('Invalid value for', '-f', '--format', 'INVALID'):
125
+            assert result.error_exit(
126
+                error=snippet
127
+            ), 'expected error exit and known error message'
130 128
 
131 129
     @tests.skip_if_cryptography_support
132 130
     @pytest.mark.parametrize(
... ...
@@ -166,12 +164,12 @@ class Test002CLI:
166 164
             vault_config=config,
167 165
             vault_key=key,
168 166
         ):
169
-            result = runner.invoke(
167
+            _result = runner.invoke(
170 168
                 cli.derivepassphrase_export,
171 169
                 ['-f', format, 'VAULT_PATH'],
172 170
                 catch_exceptions=False,
173 171
             )
174
-        assert isinstance(result.exception, SystemExit)
175
-        assert result.exit_code
176
-        assert result.stderr_bytes
177
-        assert tests.CANNOT_LOAD_CRYPTOGRAPHY in result.stderr_bytes
172
+        result = tests.ReadableResult.parse(_result)
173
+        assert result.error_exit(
174
+            error=tests.CANNOT_LOAD_CRYPTOGRAPHY
175
+        ), 'expected error exit and known error message'
... ...
@@ -31,13 +31,13 @@ class TestCLI:
31 31
             vault_key=tests.VAULT_MASTER_KEY,
32 32
         ):
33 33
             monkeypatch.setenv('VAULT_KEY', tests.VAULT_MASTER_KEY)
34
-            result = runner.invoke(
34
+            _result = runner.invoke(
35 35
                 cli.derivepassphrase_export,
36 36
                 ['VAULT_PATH'],
37 37
             )
38
-        assert not result.exception
39
-        assert (result.exit_code, result.stderr_bytes) == (0, b'')
40
-        assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA
38
+        result = tests.ReadableResult.parse(_result)
39
+        assert result.clean_exit(empty_stderr=True), 'expected clean exit'
40
+        assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
41 41
 
42 42
     def test_201_key_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None:
43 43
         runner = click.testing.CliRunner(mix_stderr=False)
... ...
@@ -46,13 +46,13 @@ class TestCLI:
46 46
             runner=runner,
47 47
             vault_config=tests.VAULT_V03_CONFIG,
48 48
         ):
49
-            result = runner.invoke(
49
+            _result = runner.invoke(
50 50
                 cli.derivepassphrase_export,
51 51
                 ['-k', tests.VAULT_MASTER_KEY, '.vault'],
52 52
             )
53
-        assert not result.exception
54
-        assert (result.exit_code, result.stderr_bytes) == (0, b'')
55
-        assert json.loads(result.stdout) == tests.VAULT_V03_CONFIG_DATA
53
+        result = tests.ReadableResult.parse(_result)
54
+        assert result.clean_exit(empty_stderr=True), 'expected clean exit'
55
+        assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA
56 56
 
57 57
     @pytest.mark.parametrize(
58 58
         ['format', 'config', 'config_data'],
... ...
@@ -90,13 +90,13 @@ class TestCLI:
90 90
             runner=runner,
91 91
             vault_config=config,
92 92
         ):
93
-            result = runner.invoke(
93
+            _result = runner.invoke(
94 94
                 cli.derivepassphrase_export,
95 95
                 ['-f', format, '-k', tests.VAULT_MASTER_KEY, 'VAULT_PATH'],
96 96
             )
97
-        assert not result.exception
98
-        assert (result.exit_code, result.stderr_bytes) == (0, b'')
99
-        assert json.loads(result.stdout) == config_data
97
+        result = tests.ReadableResult.parse(_result)
98
+        assert result.clean_exit(empty_stderr=True), 'expected clean exit'
99
+        assert json.loads(result.output) == config_data
100 100
 
101 101
     # test_300_invalid_format is found in
102 102
     # tests.test_derivepassphrase_export::Test002CLI
... ...
@@ -112,18 +112,15 @@ class TestCLI:
112 112
             vault_config=tests.VAULT_V03_CONFIG,
113 113
             vault_key=tests.VAULT_MASTER_KEY,
114 114
         ):
115
-            result = runner.invoke(
115
+            _result = runner.invoke(
116 116
                 cli.derivepassphrase_export,
117 117
                 ['does-not-exist.txt'],
118 118
             )
119
-        assert isinstance(result.exception, SystemExit)
120
-        assert result.exit_code
121
-        assert result.stderr_bytes
122
-        assert (
123
-            b"Cannot parse 'does-not-exist.txt' as a valid config"
124
-            in result.stderr_bytes
125
-        )
126
-        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
119
+        result = tests.ReadableResult.parse(_result)
120
+        assert result.error_exit(
121
+            error="Cannot parse 'does-not-exist.txt' as a valid config"
122
+        ), 'expected error exit and known error message'
123
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
127 124
 
128 125
     def test_302_vault_config_invalid(
129 126
         self,
... ...
@@ -136,17 +133,15 @@ class TestCLI:
136 133
             vault_config='',
137 134
             vault_key=tests.VAULT_MASTER_KEY,
138 135
         ):
139
-            result = runner.invoke(
136
+            _result = runner.invoke(
140 137
                 cli.derivepassphrase_export,
141 138
                 ['.vault'],
142 139
             )
143
-        assert isinstance(result.exception, SystemExit)
144
-        assert result.exit_code
145
-        assert result.stderr_bytes
146
-        assert (
147
-            b"Cannot parse '.vault' as a valid config." in result.stderr_bytes
148
-        )
149
-        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
140
+        result = tests.ReadableResult.parse(_result)
141
+        assert result.error_exit(
142
+            error="Cannot parse '.vault' as a valid config"
143
+        ), 'expected error exit and known error message'
144
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
150 145
 
151 146
     def test_403_invalid_vault_config_bad_signature(
152 147
         self,
... ...
@@ -159,17 +154,15 @@ class TestCLI:
159 154
             vault_config=tests.VAULT_V02_CONFIG,
160 155
             vault_key=tests.VAULT_MASTER_KEY,
161 156
         ):
162
-            result = runner.invoke(
157
+            _result = runner.invoke(
163 158
                 cli.derivepassphrase_export,
164 159
                 ['-f', 'v0.3', '.vault'],
165 160
             )
166
-        assert isinstance(result.exception, SystemExit)
167
-        assert result.exit_code
168
-        assert result.stderr_bytes
169
-        assert (
170
-            b"Cannot parse '.vault' as a valid config." in result.stderr_bytes
171
-        )
172
-        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
161
+        result = tests.ReadableResult.parse(_result)
162
+        assert result.error_exit(
163
+            error="Cannot parse '.vault' as a valid config"
164
+        ), 'expected error exit and known error message'
165
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
173 166
 
174 167
     def test_500_vault_config_invalid_internal(
175 168
         self,
... ...
@@ -187,15 +180,15 @@ class TestCLI:
187 180
                 return None
188 181
 
189 182
             monkeypatch.setattr(cli, '_load_data', _load_data)
190
-            result = runner.invoke(
183
+            _result = runner.invoke(
191 184
                 cli.derivepassphrase_export,
192 185
                 ['.vault'],
193 186
             )
194
-        assert isinstance(result.exception, SystemExit)
195
-        assert result.exit_code
196
-        assert result.stderr_bytes
197
-        assert b'Invalid vault config: ' in result.stderr_bytes
198
-        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr_bytes
187
+        result = tests.ReadableResult.parse(_result)
188
+        assert result.error_exit(
189
+            error='Invalid vault config: '
190
+        ), 'expected error exit and known error message'
191
+        assert tests.CANNOT_LOAD_CRYPTOGRAPHY not in result.stderr
199 192
 
200 193
 
201 194
 class TestStoreroom:
... ...
@@ -41,7 +41,9 @@ class TestStaticFunctionality:
41 41
             keydata == public_key_data
42 42
         ), "recorded public key data doesn't match"
43 43
 
44
-    def test_200_constructor_no_running_agent(self, monkeypatch: Any) -> None:
44
+    def test_200_constructor_no_running_agent(
45
+        self, monkeypatch: pytest.MonkeyPatch
46
+    ) -> None:
45 47
         monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
46 48
         sock = socket.socket(family=socket.AF_UNIX)
47 49
         with pytest.raises(
... ...
@@ -268,7 +270,7 @@ class TestAgentInteraction:
268 270
 
269 271
     @pytest.mark.parametrize(['key', 'single'], list(_params()))
270 272
     def test_210_ssh_key_selector(
271
-        self, monkeypatch: Any, key: bytes, single: bool
273
+        self, monkeypatch: pytest.MonkeyPatch, key: bytes, single: bool
272 274
     ) -> None:
273 275
         def key_is_suitable(key: bytes) -> bool:
274 276
             return key in {
... ...
@@ -308,24 +310,21 @@ class TestAgentInteraction:
308 310
             click.echo(base64.standard_b64encode(key).decode('ASCII'))
309 311
 
310 312
         runner = click.testing.CliRunner(mix_stderr=True)
311
-        result = runner.invoke(
313
+        _result = runner.invoke(
312 314
             driver,
313 315
             [],
314 316
             input=('yes\n' if single else f'{index}\n'),
315 317
             catch_exceptions=True,
316 318
         )
317
-        assert result.stdout.startswith(
318
-            'Suitable SSH keys:\n'
319
-        ), 'missing expected output'
320
-        assert text in result.stdout, 'missing expected output'
321
-        assert result.stdout.endswith(
322
-            f'\n{b64_key}\n'
323
-        ), 'missing expected output'
324
-        assert result.exit_code == 0, 'driver program failed?!'
319
+        result = tests.ReadableResult.parse(_result)
320
+        for snippet in ('Suitable SSH keys:\n', text, f'\n{b64_key}\n'):
321
+            assert result.clean_exit(output=snippet), 'expected clean exit'
325 322
 
326 323
     del _params
327 324
 
328
-    def test_300_constructor_bad_running_agent(self, monkeypatch: Any) -> None:
325
+    def test_300_constructor_bad_running_agent(
326
+        self, monkeypatch: pytest.MonkeyPatch
327
+    ) -> None:
329 328
         monkeypatch.setenv('SSH_AUTH_SOCK', os.environ['SSH_AUTH_SOCK'] + '~')
330 329
         sock = socket.socket(family=socket.AF_UNIX)
331 330
         with pytest.raises(OSError):  # noqa: PT011
... ...
@@ -339,7 +338,7 @@ class TestAgentInteraction:
339 338
         ],
340 339
     )
341 340
     def test_310_truncated_server_response(
342
-        self, monkeypatch: Any, response: bytes
341
+        self, monkeypatch: pytest.MonkeyPatch, response: bytes
343 342
     ) -> None:
344 343
         client = ssh_agent.SSHAgentClient()
345 344
         response_stream = io.BytesIO(response)
... ...
@@ -382,7 +381,7 @@ class TestAgentInteraction:
382 381
     )
383 382
     def test_320_list_keys_error_responses(
384 383
         self,
385
-        monkeypatch: Any,
384
+        monkeypatch: pytest.MonkeyPatch,
386 385
         response_code: _types.SSH_AGENT,
387 386
         response: bytes | bytearray,
388 387
         exc_type: type[Exception],
... ...
@@ -419,7 +418,7 @@ class TestAgentInteraction:
419 418
     )
420 419
     def test_330_sign_error_responses(
421 420
         self,
422
-        monkeypatch: Any,
421
+        monkeypatch: pytest.MonkeyPatch,
423 422
         key: bytes | bytearray,
424 423
         check: bool,
425 424
         response: tuple[_types.SSH_AGENT, bytes | bytearray],
... ...
@@ -7,7 +7,6 @@
7 7
 from __future__ import annotations
8 8
 
9 9
 import math
10
-from typing import Any
11 10
 
12 11
 import pytest
13 12
 
... ...
@@ -193,7 +192,10 @@ class TestVault:
193 192
         ],
194 193
     )
195 194
     def test_223_hash_length_expansion(
196
-        self, monkeypatch: Any, service: str | bytes, expected: bytes
195
+        self,
196
+        monkeypatch: pytest.MonkeyPatch,
197
+        service: str | bytes,
198
+        expected: bytes,
197 199
     ) -> None:
198 200
         v = Vault(phrase=self.phrase)
199 201
         monkeypatch.setattr(
200 202