diff --git a/CHANGELOG.md b/CHANGELOG.md index ea9268b6..46fe6ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.3.3 + +### Fixed + +- Publish a stream's completion or truncation verdict before closing its reader + channel, so a reader that has drained the payload observes the correct end. + ## 0.3.2 ### Added diff --git a/Cargo.lock b/Cargo.lock index 349094a1..286c4a04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -853,7 +853,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.3.2" +version = "0.3.3" dependencies = [ "async-trait", "clap", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.3.2" +version = "0.3.3" dependencies = [ "proc-macro2", "quote", @@ -888,7 +888,7 @@ dependencies = [ [[package]] name = "tinybus-module" -version = "0.3.2" +version = "0.3.3" dependencies = [ "async-trait", "serde", diff --git a/Cargo.toml b/Cargo.toml index 48ad5efe..948b9da3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,16 +11,16 @@ exclude = ["examples"] resolver = "3" [workspace.package] -version = "0.3.2" +version = "0.3.3" edition = "2024" rust-version = "1.85" license = "GPL-3.0-only" repository = "https://github.com/tinyhumansai/tinybus" [workspace.dependencies] -tinybus = { path = "crates/tinybus", version = "=0.3.2", default-features = false } -tinybus-macros = { path = "crates/tinybus-macros", version = "=0.3.2" } -tinybus-module = { path = "crates/tinybus-module", version = "=0.3.2" } +tinybus = { path = "crates/tinybus", version = "=0.3.3", default-features = false } +tinybus-macros = { path = "crates/tinybus-macros", version = "=0.3.3" } +tinybus-module = { path = "crates/tinybus-module", version = "=0.3.3" } tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } async-trait = "0.1" diff --git a/crates/tinybus/src/module/client_pinned_tests.rs b/crates/tinybus/src/module/client_pinned_tests.rs index b015b7db..4f43c94d 100644 --- a/crates/tinybus/src/module/client_pinned_tests.rs +++ b/crates/tinybus/src/module/client_pinned_tests.rs @@ -13,7 +13,7 @@ static RECORD: ReleaseRecord = ReleaseRecord { id: "ordinary-pinned-client-test", bus_name: "ai.tinyhumans.test.OrdinaryPinnedClient", object_path: "/ai/tinyhumans/test/OrdinaryPinnedClient", - version: "0.3.2", + version: "0.3.3", release_url: "https://example.invalid/tinybus", // Ordinary fixtures and linked host factories intentionally have no // release archive/library pair to attest. @@ -284,7 +284,7 @@ fn linked_ordinary_factory() -> crate::Result { schema: MANIFEST_SCHEMA, module: ModuleIdentity { name: RECORD.id.to_owned(), - version: Version::new(0, 3, 2), + version: Version::new(0, 3, 3), description: String::new(), homepage: None, license: String::new(), @@ -330,7 +330,7 @@ static RESOLVER_LINKED_RECORD: ReleaseRecord = ReleaseRecord { id: "ordinary-binding-linked-readiness", bus_name: "ai.tinyhumans.test.OrdinaryBindingLinkedReadiness", object_path: "/ai/tinyhumans/test/OrdinaryBindingLinkedReadiness", - version: "0.3.2", + version: "0.3.3", release_url: "https://example.invalid/tinybus", assets: &[], }; @@ -411,7 +411,7 @@ fn resolver_linked_factory() -> crate::Result { schema: MANIFEST_SCHEMA, module: ModuleIdentity { name: record.id.to_owned(), - version: Version::new(0, 3, 2), + version: Version::new(0, 3, 3), description: String::new(), homepage: None, license: String::new(), @@ -1038,7 +1038,7 @@ async fn ordinary_binding_refuses_an_unsupported_resolver_without_proxy_or_busin id: "ordinary-binding-unsupported", bus_name: "ai.tinyhumans.test.OrdinaryBindingUnsupported", object_path: "/ai/tinyhumans/test/OrdinaryBindingUnsupported", - version: "0.3.2", + version: "0.3.3", release_url: "https://example.invalid/tinybus", assets: &[], }; diff --git a/crates/tinybus/src/stream/mod.rs b/crates/tinybus/src/stream/mod.rs index 93687180..9feb3be1 100644 --- a/crates/tinybus/src/stream/mod.rs +++ b/crates/tinybus/src/stream/mod.rs @@ -252,6 +252,8 @@ pub(crate) struct StreamRegistry { limits: std::sync::RwLock, inbound: std::sync::Mutex>>, next_id: AtomicU64, + #[cfg(test)] + after_close_seal: std::sync::Mutex>>, } impl StreamRegistry { @@ -260,6 +262,8 @@ impl StreamRegistry { limits: std::sync::RwLock::new(StreamLimits::default()), inbound: std::sync::Mutex::new(HashMap::new()), next_id: AtomicU64::new(1), + #[cfg(test)] + after_close_seal: std::sync::Mutex::new(None), } } @@ -462,15 +466,29 @@ impl StreamRegistry { // the *fast* case — into "no such stream". let received = stream.received.load(Ordering::Relaxed); stream.touch(); + let truncated = received != total_len; + if truncated { + stream.finish(Outcome::Aborted("the sender closed a truncated stream")); + } else { + stream.finish(Outcome::Complete); + } stream.seal(); - if received != total_len { - stream.finish(Outcome::Aborted("the sender closed a truncated stream")); + #[cfg(test)] + if let Some(hook) = self + .after_close_seal + .lock() + .expect("stream close test hook lock") + .take() + { + hook(); + } + + if truncated { return Err(Error::protocol(format!( "stream closed after {received} bytes, {total_len} declared" ))); } - stream.finish(Outcome::Complete); Ok(Value::Null) } diff --git a/crates/tinybus/src/stream/stream_tests.rs b/crates/tinybus/src/stream/stream_tests.rs index 4617bf02..2a9da0d3 100644 --- a/crates/tinybus/src/stream/stream_tests.rs +++ b/crates/tinybus/src/stream/stream_tests.rs @@ -18,7 +18,8 @@ use crate::message::Message; use crate::name::{BusName, InterfaceName, MemberName, ObjectPath}; use crate::ports::Transport; use crate::stream::{ - MAX_CHUNK_LEN, STREAM_INTERFACE, STREAM_PATH, StreamDescriptor, StreamLimits, StreamRef, + MAX_CHUNK_LEN, STREAM_INTERFACE, STREAM_PATH, StreamDescriptor, StreamLimits, StreamReader, + StreamRef, StreamRegistry, }; use crate::transport::memory::{MemoryBus, MemoryTransport}; @@ -96,6 +97,92 @@ fn payload(len: usize) -> Vec { (0..len).map(|i| (i % 251) as u8).collect() } +fn stream_header(member: &str, sender: &BusName) -> crate::message::Header { + let mut header = Message::method_call( + BusName::new("ai.tinyhumans.StreamReceiver").unwrap(), + ObjectPath::new(STREAM_PATH).unwrap(), + InterfaceName::new(STREAM_INTERFACE).unwrap(), + MemberName::new(member).unwrap(), + Value::Null, + ) + .header; + header.sender = Some(sender.clone()); + header +} + +async fn close_verdict_is_published_before_reader_observes_eof( + declared_len: u64, + close_len: u64, + expected_terminal: &str, + expected_close_error: Option<&str>, +) { + let registry = Arc::new(StreamRegistry::new()); + let owner = BusName::new("ai.tinyhumans.StreamSender").unwrap(); + let open = registry + .dispatch( + &stream_header("Open", &owner), + serde_json::json!([StreamDescriptor::with_len(declared_len)]), + ) + .await + .unwrap(); + let id = open.as_str().unwrap().to_string(); + let mut reader: StreamReader = registry.take_reader(&id).unwrap(); + registry + .dispatch( + &stream_header("Write", &owner), + serde_json::json!([id.clone(), 0, super::base64::encode(b"abc")]), + ) + .await + .unwrap(); + assert_eq!( + reader.next_chunk().await.unwrap().as_deref(), + Some(b"abc".as_slice()) + ); + + let (close_sealed_tx, close_sealed_rx) = tokio::sync::oneshot::channel(); + let release_close = Arc::new(std::sync::Barrier::new(2)); + let close_release = Arc::clone(&release_close); + *registry + .after_close_seal + .lock() + .expect("stream close test hook lock") = Some(Box::new(move || { + let _ = close_sealed_tx.send(()); + close_release.wait(); + })); + + let close_registry = Arc::clone(®istry); + let close_member = MemberName::new("Close").unwrap(); + let close_header = stream_header("Close", &owner); + let close = tokio::task::spawn_blocking(move || { + close_registry.close( + &close_header, + &close_member, + serde_json::json!([id, close_len]), + ) + }); + + close_sealed_rx + .await + .expect("Close should reach the per-registry scheduling hook"); + let terminal = reader.next_chunk().await; + // Always release and join the paused synchronous Close before asserting, so + // a deliberately failing RED run cannot strand a blocking task. + release_close.wait(); + let close_result = close.await.expect("Close task should complete"); + + let terminal = match terminal { + Ok(None) => "clean EOF".to_string(), + Ok(Some(_)) => "unexpected extra chunk".to_string(), + Err(Error::StreamAborted { reason }) => format!("aborted: {reason}"), + Err(error) => format!("error: {}", error.wire_name()), + }; + assert_eq!(terminal, expected_terminal); + match expected_close_error { + Some(wire_name) => assert_eq!(close_result.unwrap_err().wire_name(), wire_name), + None => assert!(close_result.is_ok()), + } +} + /// A broker, a sink service that owns [`SINK`], and a client. async fn bus() -> (Connection, Connection) { let bus = MemoryBus::new(); @@ -370,6 +457,22 @@ async fn closing_short_of_the_declared_length_is_an_error_not_a_short_read() { assert_eq!(error.wire_name(), "ai.tinyhumans.tinybus.Error.Protocol"); } +#[tokio::test] +async fn a_complete_close_publishes_its_verdict_before_reader_eof() { + close_verdict_is_published_before_reader_observes_eof(3, 3, "clean EOF", None).await; +} + +#[tokio::test] +async fn a_truncated_close_publishes_its_abort_before_reader_eof() { + close_verdict_is_published_before_reader_observes_eof( + 4, + 4, + "aborted: the sender closed a truncated stream", + Some("ai.tinyhumans.tinybus.Error.Protocol"), + ) + .await; +} + #[tokio::test] async fn a_reader_sees_an_error_rather_than_an_eof_when_the_sender_aborts() { let (client, service) = bus().await; diff --git a/examples/Cargo.lock b/examples/Cargo.lock index 4ac848ba..7b8285eb 100644 --- a/examples/Cargo.lock +++ b/examples/Cargo.lock @@ -260,7 +260,7 @@ dependencies = [ [[package]] name = "tinybus" -version = "0.3.2" +version = "0.3.3" dependencies = [ "async-trait", "serde", @@ -284,7 +284,7 @@ dependencies = [ [[package]] name = "tinybus-macros" -version = "0.3.2" +version = "0.3.3" dependencies = [ "proc-macro2", "quote", diff --git a/scripts/check-coverage.sh b/scripts/check-coverage.sh index 9f1cd67a..78795dca 100755 --- a/scripts/check-coverage.sh +++ b/scripts/check-coverage.sh @@ -4,11 +4,11 @@ cd "$(dirname "${BASH_SOURCE[0]}")/.." cargo build --locked --example module_clock --example module_clock_two --example module_wrong_target --example module_context --example module_context_eager --example module_host_callback --example module_controlled_init --example module_terminal --no-default-features --features modules,macros,__native-fixture-support set -euo pipefail target_dir=$(cargo metadata --format-version 1 --no-deps | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])') -module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_clock.so' -print -quit) -module_two=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_clock_two.so' -print -quit) -wrong_target=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_wrong_target.so' -print -quit) -context_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_context.so' -print -quit) -context_eager_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f -name 'libmodule_context_eager.so' -print -quit) +module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_clock.so' -o -name 'libmodule_clock.dylib' \) -print -quit) +module_two=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_clock_two.so' -o -name 'libmodule_clock_two.dylib' \) -print -quit) +wrong_target=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_wrong_target.so' -o -name 'libmodule_wrong_target.dylib' \) -print -quit) +context_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context.so' -o -name 'libmodule_context.dylib' \) -print -quit) +context_eager_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_context_eager.so' -o -name 'libmodule_context_eager.dylib' \) -print -quit) host_callback_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_host_callback.so' -o -name 'libmodule_host_callback.dylib' \) -print -quit) controlled_init_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_controlled_init.so' -o -name 'libmodule_controlled_init.dylib' \) -print -quit) terminal_module=$(find "$target_dir/debug/examples" -maxdepth 1 -type f \( -name 'libmodule_terminal.so' -o -name 'libmodule_terminal.dylib' \) -print -quit) diff --git a/scripts/test-features.sh b/scripts/test-features.sh index eee04749..66d801bb 100755 --- a/scripts/test-features.sh +++ b/scripts/test-features.sh @@ -1,6 +1,12 @@ #!/usr/bin/env bash set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." +case "${RUNNER_OS:-$(uname -s)}" in + Linux|Darwin|macOS) + python3 scripts/test_check_coverage.py + ;; + Windows|MINGW*|MSYS*|CYGWIN*) ;; +esac cargo check --locked --workspace --no-default-features cargo check --locked --package tinybus --no-default-features --features modules cargo metadata --locked --format-version 1 --manifest-path examples/Cargo.toml >/dev/null diff --git a/scripts/test_check_coverage.py b/scripts/test_check_coverage.py new file mode 100644 index 00000000..78fbd501 --- /dev/null +++ b/scripts/test_check_coverage.py @@ -0,0 +1,335 @@ +#!/usr/bin/env python3 +"""Exercise coverage-script fixture discovery without running Cargo.""" + +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parent.parent +COVERAGE_SCRIPT = ROOT / "scripts" / "check-coverage.sh" +LIBRARIES = ( + "module_clock", + "module_clock_two", + "module_wrong_target", + "module_context", + "module_context_eager", + "module_host_callback", + "module_controlled_init", + "module_terminal", +) +TERMINAL_TESTS = ( + "terminal_runtime_tests::compiled_native_fixture_first", + "terminal_runtime_tests::compiled_native_fixture_second", +) +IGNORE_REGEX = ( + r"crates/tinybus/src/ports/(listener|transport)\.rs$" + r"|crates/tinybus/src/module/github\.rs$" + r"|crates/tinybus/src/module/(host|mod)\.rs$" + r"|crates/tinybus/src/bin/(tinybus\.rs|tinybus/unix\.rs)$" + r"|crates/tinybus/src/connection\.rs$" + r"|crates/tinybus/src/test_support\.rs$" +) + + +FAKE_CARGO = r"""#!/usr/bin/env python3 +import hashlib +import json +import os +from pathlib import Path +import sys + +args = sys.argv[1:] +target = Path(os.environ["FAKE_TARGET_DIR"]) +suffix = os.environ["FAKE_FIXTURE_SUFFIX"] +log = Path(os.environ["FAKE_CARGO_LOG"]) +entry = {"args": args} + +if args and args[0] == "build": + examples = target / "debug" / "examples" + examples.mkdir(parents=True, exist_ok=True) + for name in ( + "module_clock", + "module_clock_two", + "module_wrong_target", + "module_context", + "module_context_eager", + "module_host_callback", + "module_controlled_init", + "module_terminal", + ): + (examples / f"lib{name}.{suffix}").write_bytes(name.encode()) +elif args[:2] == ["metadata", "--format-version"]: + print(json.dumps({"target_directory": str(target)})) +elif args[:2] == ["llvm-cov", "--workspace"]: + fixture_variables = ( + "TINYBUS_TEST_MODULE", + "TINYBUS_TEST_MODULE_TWO", + "TINYBUS_TEST_WRONG_TARGET", + "TINYBUS_TEST_CONTEXT_MODULE", + "TINYBUS_TEST_CONTEXT_EAGER_MODULE", + "TINYBUS_TEST_HOST_CALLBACK_MODULE", + "TINYBUS_TEST_CONTROLLED_INIT_MODULE", + "TINYBUS_TEST_TERMINAL_MODULE", + ) + fixtures = {name: os.environ.get(name, "") for name in fixture_variables} + if any(not Path(path).is_file() for path in fixtures.values()): + print("coverage did not receive every compiled fixture", file=sys.stderr) + sys.exit(3) + for variable in ("TINYBUS_TEST_MODULE_TWO", "TINYBUS_TEST_TERMINAL_MODULE"): + path = Path(fixtures[variable]) + manifest = path.parent / "modules.toml" + expected = f'"{path.name}" = "{hashlib.sha256(path.read_bytes()).hexdigest()}"' + if not manifest.is_file() or expected not in manifest.read_text(): + print(f"invalid private fixture manifest for {variable}", file=sys.stderr) + sys.exit(4) + entry["fixture_paths"] = fixtures +elif args[:2] == ["test", "--locked"] and "--list" in args: + for name in ( + "terminal_runtime_tests::compiled_native_fixture_first", + "terminal_runtime_tests::compiled_native_fixture_second", + ): + print(f"{name}: test") +elif args[:2] == ["llvm-cov", "test"]: + if "--ignored" not in args or "--exact" not in args: + print("terminal scenario lost isolated ignored/exact execution", file=sys.stderr) + sys.exit(5) +elif args[:2] == ["llvm-cov", "report"]: + if args[-2:] != ["--fail-under-file-lines", "90"]: + print("coverage report threshold changed", file=sys.stderr) + sys.exit(6) +else: + print(f"unexpected fake Cargo invocation: {args!r}", file=sys.stderr) + sys.exit(7) + +with log.open("a", encoding="utf-8") as output: + output.write(json.dumps(entry) + "\n") +""" + + +class CoverageScriptPlatformTests(unittest.TestCase): + def run_coverage_script(self, suffix: str) -> tuple[subprocess.CompletedProcess[str], list[dict]]: + with tempfile.TemporaryDirectory(prefix="tinybus-coverage-script-") as directory: + temporary = Path(directory) + checkout = temporary / "checkout" + scripts = checkout / "scripts" + scripts.mkdir(parents=True) + shutil.copyfile(COVERAGE_SCRIPT, scripts / COVERAGE_SCRIPT.name) + + fake_bin = temporary / "bin" + fake_bin.mkdir() + cargo = fake_bin / "cargo" + cargo.write_text(textwrap.dedent(FAKE_CARGO), encoding="utf-8") + cargo.chmod(0o755) + + target = checkout / "target" + call_log = temporary / "cargo-calls.jsonl" + environment = os.environ.copy() + environment.update( + { + "FAKE_CARGO_LOG": str(call_log), + "FAKE_FIXTURE_SUFFIX": suffix, + "FAKE_TARGET_DIR": str(target), + "PATH": os.pathsep.join((str(fake_bin), os.environ["PATH"])), + } + ) + result = subprocess.run( + ["bash", str(scripts / COVERAGE_SCRIPT.name)], + cwd=checkout, + env=environment, + text=True, + capture_output=True, + check=False, + ) + calls = [ + json.loads(line) + for line in call_log.read_text(encoding="utf-8").splitlines() + ] if call_log.exists() else [] + return result, calls + + def assert_coverage_contract( + self, + result: subprocess.CompletedProcess[str], + calls: list[dict], + suffix: str, + ) -> None: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + args = [call["args"] for call in calls] + + self.assertEqual( + args[0], + [ + "build", + "--locked", + "--example", + "module_clock", + "--example", + "module_clock_two", + "--example", + "module_wrong_target", + "--example", + "module_context", + "--example", + "module_context_eager", + "--example", + "module_host_callback", + "--example", + "module_controlled_init", + "--example", + "module_terminal", + "--no-default-features", + "--features", + "modules,macros,__native-fixture-support", + ], + ) + self.assertEqual( + args[1], + ["metadata", "--format-version", "1", "--no-deps"], + ) + + coverage_call = next(call for call in calls if call["args"][:2] == ["llvm-cov", "--workspace"]) + self.assertEqual( + coverage_call["args"], + [ + "llvm-cov", + "--workspace", + "--all-features", + "--ignore-filename-regex", + IGNORE_REGEX, + "--no-report", + "--", + "--include-ignored", + "--skip", + "terminal_runtime_tests::compiled_native_", + ], + ) + fixture_paths = coverage_call["fixture_paths"] + self.assertEqual(len(fixture_paths), len(LIBRARIES)) + for variable, library in zip(fixture_paths, LIBRARIES, strict=True): + self.assertTrue(fixture_paths[variable].endswith(f"lib{library}.{suffix}")) + + list_call = next(call for call in calls if call["args"][:2] == ["test", "--locked"]) + self.assertEqual( + list_call["args"], + [ + "test", + "--locked", + "-p", + "tinybus-module", + "--all-features", + "--features", + "tinybus/macros", + "--", + "--list", + "--ignored", + ], + ) + isolated_calls = [call["args"] for call in calls if call["args"][:2] == ["llvm-cov", "test"]] + self.assertEqual( + isolated_calls, + [ + [ + "llvm-cov", + "test", + "--no-report", + "-p", + "tinybus-module", + "--all-features", + "--features", + "tinybus/macros", + test, + "--", + "--ignored", + "--exact", + ] + for test in TERMINAL_TESTS + ], + ) + report_call = next(call for call in calls if call["args"][:2] == ["llvm-cov", "report"]) + self.assertEqual( + report_call["args"], + [ + "llvm-cov", + "report", + "--ignore-filename-regex", + IGNORE_REGEX, + "--fail-under-file-lines", + "90", + ], + ) + + def test_linux_so_fixtures_reach_the_unchanged_coverage_gate(self) -> None: + result, calls = self.run_coverage_script("so") + self.assert_coverage_contract(result, calls, "so") + + def test_macos_dylib_fixtures_reach_the_unchanged_coverage_gate(self) -> None: + result, calls = self.run_coverage_script("dylib") + self.assert_coverage_contract(result, calls, "dylib") + + def test_fake_cargo_harness_is_limited_to_the_posix_feature_lane(self) -> None: + lines = (ROOT / "scripts" / "test-features.sh").read_text( + encoding="utf-8" + ).splitlines() + case_line = 'case "${RUNNER_OS:-$(uname -s)}" in' + self.assertIn( + case_line, + lines, + "the fake-Cargo coverage harness must be scoped to the POSIX runner branch", + ) + case_start = lines.index(case_line) + windows_branch = next( + index + for index, line in enumerate(lines) + if line.startswith(" Windows|") + ) + posix_branch = next( + index + for index, line in enumerate(lines) + if line.strip() == "Linux|Darwin|macOS)" + ) + posix_end = next( + index + for index, line in enumerate(lines[posix_branch + 1 :], posix_branch + 1) + if line.strip() == ";;" + ) + case_end = next( + index + for index, line in enumerate(lines[windows_branch + 1 :], windows_branch + 1) + if line.strip() == "esac" + ) + invocations = [ + index + for index, line in enumerate(lines) + if line.strip() == "python3 scripts/test_check_coverage.py" + ] + + self.assertEqual(len(invocations), 1) + self.assertGreater(invocations[0], posix_branch) + self.assertLess(invocations[0], posix_end) + self.assertNotIn( + "python3 scripts/test_check_coverage.py", + [line.strip() for line in lines[windows_branch + 1 : case_end]], + ) + self.assertGreater(windows_branch, case_start) + self.assertEqual( + [line.strip() for line in lines if line.strip().startswith("cargo ")], + [ + "cargo check --locked --workspace --no-default-features", + "cargo check --locked --package tinybus --no-default-features --features modules", + "cargo metadata --locked --format-version 1 --manifest-path examples/Cargo.toml >/dev/null", + "cargo test --locked --workspace --no-default-features --features macros", + "cargo test --locked --workspace --all-features", + ], + ) + + +if __name__ == "__main__": + unittest.main()