From 575fb2fa86d611d1147e40248311bf71042b2f64 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Tue, 1 Sep 2026 04:08:13 +0200 Subject: [PATCH 1/3] fix(cli): apply the documented 600s read timeout, and report a timeout as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #42. AdtSessionOptions defaulted to 120s while AppConfig::timeout_seconds documented 600, and since both CLI entry points assign read_timeout only when --timeout is passed, 120s was what every call actually got — the MCP server included. Anything slower failed at 121s while the ABAP ran on server-side and completed, so the caller saw a failure for work that had in fact succeeded and had to go query the result to find out. There is now one constant, kDefaultReadTimeoutSeconds, behind both. The failure said "HTTP request failed: Failed to read connection", which named neither the timeout nor the flag that raises it, and the CLI then invited a bug report for a limit the caller had simply hit. The five sites that shaped a transport failure by hand now share MakeTransportError, which for the timeout family says how long it waited, hints at --timeout, and warns that the server-side work may still complete. PrintError drops the issue-tracker line for that category — nothing unexpected happened. Verified against a4h with the class from the issue: `object run ZCL_ERPL_WAIT` (WAIT UP TO 170 SECONDS) now prints WAITED 170 and exits 0 after 170s, where it used to fail at 121s. With --timeout 5 it exits 10 with "request timed out after 5s" and the hint. --- docs/cli-usage.md | 2 +- docs/spec2.md | 4 +- include/erpl_adt/adt/adt_session.hpp | 11 ++++- include/erpl_adt/config/app_config.hpp | 3 +- src/adt/adt_session.cpp | 64 ++++++++++++++----------- src/cli/command_executor.cpp | 2 +- src/cli/output_formatter.cpp | 15 +++++- src/config/config_loader.cpp | 4 +- src/main.cpp | 2 +- test/adt/test_adt_session.cpp | 65 ++++++++++++++++++++++++++ test/cli/test_output_formatter.cpp | 50 ++++++++++++++++++++ 11 files changed, 185 insertions(+), 37 deletions(-) diff --git a/docs/cli-usage.md b/docs/cli-usage.md index db585ec..a515f1b 100644 --- a/docs/cli-usage.md +++ b/docs/cli-usage.md @@ -15,7 +15,7 @@ | `--https` | Use HTTPS | | `--insecure` | Skip TLS certificate verification | | `--json` | Output in machine-readable JSON | -| `--timeout ` | Request timeout in seconds | +| `--timeout ` | Per-request read timeout in seconds (default: 600). Raise it for long classruns and ATC runs. | | `--session-file ` | Persist session for lock/write/unlock workflows | | `--color` / `--no-color` | Force or disable ANSI color output | | `-v` / `-vv` | INFO / DEBUG logging to stderr | diff --git a/docs/spec2.md b/docs/spec2.md index ca6db5c..9bfc600 100644 --- a/docs/spec2.md +++ b/docs/spec2.md @@ -297,7 +297,7 @@ erpl-adt [global-flags] [flags] [args] | `-v, --verbose` | Verbose output | | `-q, --quiet` | Suppress non-essential output | | `--version` | Print version | -| `--timeout ` | Request timeout (default: 120) | +| `--timeout ` | Per-request read timeout (default: 600) | | `--insecure` | Skip TLS certificate verification | ### 5.2 Command Groups and Actions @@ -482,7 +482,7 @@ connection: defaults: package: ZTEST transport_prefix: "AI:" - timeout: 120 + timeout: 600 activate_after_write: true # For erpl-adt compatibility: multi-repo deployment diff --git a/include/erpl_adt/adt/adt_session.hpp b/include/erpl_adt/adt/adt_session.hpp index 1332190..007222a 100644 --- a/include/erpl_adt/adt/adt_session.hpp +++ b/include/erpl_adt/adt/adt_session.hpp @@ -10,12 +10,21 @@ namespace erpl_adt { +// The per-request read timeout, in seconds, when --timeout says nothing. +// One constant, because two of them drifted: the session defaulted to 120s +// while AppConfig::timeout_seconds documented 600, and since both CLI entry +// points assign read_timeout only when --timeout is passed, 120s was what +// every call actually got. ABAP that runs longer keeps running server-side +// and completes, so giving up early reports a failure for work that +// succeeded (issue #42). +inline constexpr int kDefaultReadTimeoutSeconds = 600; + // --------------------------------------------------------------------------- // AdtSessionOptions — configuration for the ADT HTTP session. // --------------------------------------------------------------------------- struct AdtSessionOptions { std::chrono::seconds connect_timeout{30}; - std::chrono::seconds read_timeout{120}; + std::chrono::seconds read_timeout{kDefaultReadTimeoutSeconds}; bool disable_tls_verify = false; std::chrono::seconds poll_interval{2}; // SAP logon language for the connection. Sent as the Accept-Language diff --git a/include/erpl_adt/config/app_config.hpp b/include/erpl_adt/config/app_config.hpp index 7307dfa..9c771d2 100644 --- a/include/erpl_adt/config/app_config.hpp +++ b/include/erpl_adt/config/app_config.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -36,7 +37,7 @@ struct AppConfig { bool json_output = false; bool verbose = false; bool quiet = false; - int timeout_seconds = 600; + int timeout_seconds = kDefaultReadTimeoutSeconds; }; } // namespace erpl_adt diff --git a/src/adt/adt_session.cpp b/src/adt/adt_session.cpp index d534b4f..acef9bd 100644 --- a/src/adt/adt_session.cpp +++ b/src/adt/adt_session.cpp @@ -203,6 +203,39 @@ struct AdtSession::Impl { } } + // Shape a transport failure (no HTTP response at all) into an Error. + // + // A read timeout used to arrive as "HTTP request failed: Failed to read + // connection", which named neither the timeout nor the flag that raises + // it — and the CLI then invited the user to file a bug for a limit they + // had simply hit (issue #42). Worse, the ABAP behind a timed-out + // classrun keeps running and usually completes, so the failure is about + // our patience, not their work. + Error MakeTransportError(const std::string& operation, + const std::string& path, + httplib::Error http_error) const { + const auto category = CategoryFromHttpTransportError(http_error); + if (category != ErrorCategory::Timeout) { + return MakeSessionError(operation, path, std::nullopt, + "HTTP request failed: " + + httplib::to_string(http_error) + + " (connecting to " + base_url_ + ")", + category); + } + const auto seconds = std::to_string(options.read_timeout.count()); + auto error = MakeSessionError( + operation, path, std::nullopt, + "request timed out after " + seconds + "s (connecting to " + + base_url_ + ")", + category); + if (!error.hint.has_value()) { + error.hint = "Raise it with --timeout . Work already " + "started on the server may still be running and may " + "complete — check before repeating it."; + } + return error; + } + // Check if a request path targets the BW Modeling API. static bool IsBwPath(std::string_view path) { // "/sap/bw/modeling/" = 17 chars @@ -318,11 +351,7 @@ struct AdtSession::Impl { if (!res) { const auto http_error = res.error(); return Result::Err( - MakeSessionError("Get", std::string(path), std::nullopt, - "HTTP request failed: " + - httplib::to_string(http_error) + - " (connecting to " + base_url_ + ")", - CategoryFromHttpTransportError(http_error))); + MakeTransportError("Get", std::string(path), http_error)); } LogResponse(res->status, res->headers, res->body); CaptureContextId(res->headers); @@ -348,11 +377,7 @@ struct AdtSession::Impl { if (!res) { const auto http_error = res.error(); return Result::Err( - MakeSessionError("Post", std::string(path), std::nullopt, - "HTTP request failed: " + - httplib::to_string(http_error) + - " (connecting to " + base_url_ + ")", - CategoryFromHttpTransportError(http_error))); + MakeTransportError("Post", std::string(path), http_error)); } LogResponse(res->status, res->headers, res->body); CaptureContextId(res->headers); @@ -378,11 +403,7 @@ struct AdtSession::Impl { if (!res) { const auto http_error = res.error(); return Result::Err( - MakeSessionError("Put", std::string(path), std::nullopt, - "HTTP request failed: " + - httplib::to_string(http_error) + - " (connecting to " + base_url_ + ")", - CategoryFromHttpTransportError(http_error))); + MakeTransportError("Put", std::string(path), http_error)); } LogResponse(res->status, res->headers, res->body); CaptureContextId(res->headers); @@ -405,11 +426,7 @@ struct AdtSession::Impl { if (!res) { const auto http_error = res.error(); return Result::Err( - MakeSessionError("Delete", std::string(path), std::nullopt, - "HTTP request failed: " + - httplib::to_string(http_error) + - " (connecting to " + base_url_ + ")", - CategoryFromHttpTransportError(http_error))); + MakeTransportError("Delete", std::string(path), http_error)); } LogResponse(res->status, res->headers, res->body); CaptureContextId(res->headers); @@ -439,12 +456,7 @@ struct AdtSession::Impl { if (!res) { const auto http_error = res.error(); return Result::Err( - MakeSessionError("FetchCsrfToken", fetch_path, - std::nullopt, - "HTTP request failed: " + - httplib::to_string(http_error) + - " (connecting to " + base_url_ + ")", - CategoryFromHttpTransportError(http_error))); + MakeTransportError("FetchCsrfToken", fetch_path, http_error)); } LogResponse(res->status, res->headers, res->body); if (res->status != 200) { diff --git a/src/cli/command_executor.cpp b/src/cli/command_executor.cpp index d1d81fd..3026e3a 100644 --- a/src/cli/command_executor.cpp +++ b/src/cli/command_executor.cpp @@ -6992,7 +6992,7 @@ void PrintTopLevelHelp(const CommandRouter& router, std::ostream& out, bool colo {"--https", "Use HTTPS"}, {"--insecure", "Skip TLS verification (with --https)"}, {"--json", "JSON output"}, - {"--timeout ", "Request timeout in seconds"}, + {"--timeout ", "Per-request read timeout in seconds (default: 600)"}, {"--session-file ", "Persist session for lock/write/unlock workflows"}, {"--color", "Force colored output"}, {"--no-color", "Disable colored output"}, diff --git a/src/cli/output_formatter.cpp b/src/cli/output_formatter.cpp index 683f7c2..8b8a19e 100644 --- a/src/cli/output_formatter.cpp +++ b/src/cli/output_formatter.cpp @@ -202,6 +202,13 @@ void OutputFormatter::PrintJson(const std::string& json) const { static constexpr const char* kIssueHint = " Unexpected? Please report it: https://github.com/DataZooDE/erpl-adt/issues"; +// ...except where nothing unexpected happened. A read timeout is a limit the +// caller configured and can raise, and the error already says how; inviting a +// bug report for it sends people to the tracker for working software (#42). +static bool WorthReporting(const Error& error) { + return error.category != ErrorCategory::Timeout; +} + void OutputFormatter::PrintError(const Error& error) const { if (json_mode_) { err_ << error.ToJson() << "\n"; @@ -224,7 +231,9 @@ void OutputFormatter::PrintError(const Error& error) const { err_ << " " << kYellow << "Hint: " << kReset << error.hint.value() << "\n"; } - err_ << kDim << kIssueHint << kReset << "\n"; + if (WorthReporting(error)) { + err_ << kDim << kIssueHint << kReset << "\n"; + } return; } @@ -241,7 +250,9 @@ void OutputFormatter::PrintError(const Error& error) const { if (error.hint.has_value() && !error.hint->empty()) { err_ << " Hint: " << error.hint.value() << "\n"; } - err_ << kIssueHint << "\n"; + if (WorthReporting(error)) { + err_ << kIssueHint << "\n"; + } } void OutputFormatter::PrintSuccess(const std::string& message) const { diff --git a/src/config/config_loader.cpp b/src/config/config_loader.cpp index 699bd59..3ae16b4 100644 --- a/src/config/config_loader.cpp +++ b/src/config/config_loader.cpp @@ -214,7 +214,7 @@ Result LoadFromCli(int argc, const char* const* argv) { .default_value(false) .implicit_value(true); program.add_argument("--timeout") - .help("Timeout in seconds") + .help("Per-request read timeout in seconds (default: 600)") .scan<'i', int>(); program.add_argument("--json") .help("JSON output") @@ -380,7 +380,7 @@ AppConfig MergeConfigs(const AppConfig& yaml_base, const AppConfig& cli_override if (cli_overrides.quiet) { merged.quiet = true; } - if (cli_overrides.timeout_seconds != 600) { + if (cli_overrides.timeout_seconds != kDefaultReadTimeoutSeconds) { merged.timeout_seconds = cli_overrides.timeout_seconds; } if (cli_overrides.log_file.has_value()) { diff --git a/src/main.cpp b/src/main.cpp index bd4c94d..fc9931e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -108,7 +108,7 @@ void PrintMcpHelp(std::ostream& out) { out << " --language SAP logon language (2-letter ISO, e.g. EN, DE; default: EN)\n"; out << " --https Use HTTPS\n"; out << " --insecure Skip TLS verification (with --https)\n"; - out << " --timeout Request timeout in seconds\n"; + out << " --timeout Per-request read timeout in seconds (default: 600)\n"; out << " -v Verbose logging (INFO level)\n"; out << " -vv Debug logging (DEBUG level)\n\n"; out << "TRANSPORT\n"; diff --git a/test/adt/test_adt_session.cpp b/test/adt/test_adt_session.cpp index d114c52..62b1631 100644 --- a/test/adt/test_adt_session.cpp +++ b/test/adt/test_adt_session.cpp @@ -1,6 +1,7 @@ #include #include +#include #include "../../test/mocks/mock_adt_session.hpp" #include @@ -1011,3 +1012,67 @@ TEST_CASE("AdtSession: ResetStatefulSession clears cookies and CSRF token", "[ad CHECK(j.contains("cookies")); CHECK(j["cookies"].empty()); } + +// =========================================================================== +// Read timeout: the default, and how exceeding it is reported (#42) +// =========================================================================== + +TEST_CASE("AdtSessionOptions: the default read timeout is the documented one", + "[adt][session]") { + // These two drifted apart once already: the session defaulted to 120s + // while AppConfig documented 600, and because both CLI entry points only + // assign read_timeout when --timeout is passed, 120s was what every call + // actually got. A classrun longer than that failed while the ABAP ran on + // to completion server-side. + CHECK(AdtSessionOptions{}.read_timeout == + std::chrono::seconds(kDefaultReadTimeoutSeconds)); + CHECK(AppConfig{}.timeout_seconds == kDefaultReadTimeoutSeconds); +} + +TEST_CASE("AdtSession: a read timeout is reported as a timeout, naming --timeout", + "[adt][session][live]") { + // "HTTP request failed: Failed to read connection" said nothing about a + // timeout, named no flag, and was followed by an invitation to file a bug + // — for a limit the caller can simply raise. + httplib::Server svr; + svr.Get("/sap/bc/adt/slow", [&](const httplib::Request&, + httplib::Response& res) { + std::this_thread::sleep_for(std::chrono::milliseconds(1500)); + res.set_content("", "text/xml"); + }); + + LocalServer server(svr); + auto client = SapClient::Create("001"); + REQUIRE(client.IsOk()); + AdtSessionOptions opts; + opts.connect_timeout = std::chrono::seconds{5}; + opts.read_timeout = std::chrono::seconds{1}; + AdtSession session("127.0.0.1", static_cast(server.Port()), false, + "testuser", "testpass", client.Value(), opts); + + auto result = session.Get("/sap/bc/adt/slow"); + REQUIRE(result.IsErr()); + const auto& error = result.Error(); + CHECK(error.category == ErrorCategory::Timeout); + CHECK(error.message.find("timed out after 1s") != std::string::npos); + REQUIRE(error.hint.has_value()); + CHECK(error.hint->find("--timeout") != std::string::npos); +} + +TEST_CASE("AdtSession: a connection failure is not dressed up as a timeout", + "[adt][session][live]") { + // Only the timeout family gets the new wording; everything else keeps + // saying what it always said. + auto client = SapClient::Create("001"); + REQUIRE(client.IsOk()); + AdtSessionOptions opts; + opts.connect_timeout = std::chrono::seconds{2}; + opts.read_timeout = std::chrono::seconds{2}; + // Port 1 on loopback: nothing listens, so this is refused, not slow. + AdtSession session("127.0.0.1", 1, false, "testuser", "testpass", + client.Value(), opts); + + auto result = session.Get("/sap/bc/adt/test"); + REQUIRE(result.IsErr()); + CHECK(result.Error().message.find("timed out") == std::string::npos); +} diff --git a/test/cli/test_output_formatter.cpp b/test/cli/test_output_formatter.cpp index 1876c58..1fbb0d8 100644 --- a/test/cli/test_output_formatter.cpp +++ b/test/cli/test_output_formatter.cpp @@ -305,3 +305,53 @@ TEST_CASE("OutputFormatter: IsColorMode", "[cli][formatter]") { OutputFormatter json_color(true, true, out, err); CHECK_FALSE(json_color.IsColorMode()); } + +// =========================================================================== +// The issue-tracker invitation (#42) +// =========================================================================== + +TEST_CASE("OutputFormatter: a timeout is not an invitation to file a bug", + "[cli][formatter]") { + // "Unexpected? Please report it" belongs on surprises. A read timeout is + // a configured limit the caller can raise, and the hint already says how. + std::ostringstream out; + std::ostringstream err; + OutputFormatter fmt(false, false, out, err); + + Error e{"Post", "/sap/bc/adt/oo/classrun/zcl_wait", std::nullopt, + "request timed out after 600s", std::nullopt, + ErrorCategory::Timeout}; + e.hint = "Raise it with --timeout ."; + fmt.PrintError(e); + + CHECK(err.str().find("timed out") != std::string::npos); + CHECK(err.str().find("--timeout") != std::string::npos); + CHECK(err.str().find("Please report it") == std::string::npos); +} + +TEST_CASE("OutputFormatter: other failures still name the issue tracker", + "[cli][formatter]") { + std::ostringstream out; + std::ostringstream err; + OutputFormatter fmt(false, false, out, err); + + Error e{"Search", "/sap/bc/adt/search", 500, "Internal Server Error", + std::nullopt, ErrorCategory::Internal}; + fmt.PrintError(e); + + CHECK(err.str().find("Please report it") != std::string::npos); +} + +TEST_CASE("OutputFormatter: the timeout carve-out holds in color mode too", + "[cli][formatter]") { + std::ostringstream out; + std::ostringstream err; + OutputFormatter fmt(false, true, out, err); + + Error e{"Post", "/sap/bc/adt/oo/classrun/zcl_wait", std::nullopt, + "request timed out after 600s", std::nullopt, + ErrorCategory::Timeout}; + fmt.PrintError(e); + + CHECK(err.str().find("Please report it") == std::string::npos); +} From c0706da2993921fb3e2094e8dbc38e47ced2f644 Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Tue, 1 Sep 2026 04:25:43 +0200 Subject: [PATCH 2/3] fix(cli): name the connect timeout when that is the one that expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows caught it: connecting to a closed port there is not refused, it is dropped until the connect timeout runs out, and the message then said 'request timed out after 600s' and pointed at --timeout — neither of which is the number that expired nor the flag that would help. A connection timeout now says so and suggests checking reachability. --- src/adt/adt_session.cpp | 55 ++++++++++++++++++++++++----------- test/adt/test_adt_session.cpp | 14 ++++++--- 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/src/adt/adt_session.cpp b/src/adt/adt_session.cpp index acef9bd..433d51f 100644 --- a/src/adt/adt_session.cpp +++ b/src/adt/adt_session.cpp @@ -215,25 +215,46 @@ struct AdtSession::Impl { const std::string& path, httplib::Error http_error) const { const auto category = CategoryFromHttpTransportError(http_error); - if (category != ErrorCategory::Timeout) { - return MakeSessionError(operation, path, std::nullopt, - "HTTP request failed: " + - httplib::to_string(http_error) + - " (connecting to " + base_url_ + ")", - category); + + // Never reached the server at all: the limit that expired is the + // connection timeout, and --timeout would not have helped — saying + // otherwise sends people to raise the wrong number. + if (http_error == httplib::Error::ConnectionTimeout) { + auto error = MakeSessionError( + operation, path, std::nullopt, + "connection timed out after " + + std::to_string(options.connect_timeout.count()) + + "s (connecting to " + base_url_ + ")", + category); + if (!error.hint.has_value()) { + error.hint = "The server did not answer. Check the host, port " + "and that it is reachable from here."; + } + return error; } - const auto seconds = std::to_string(options.read_timeout.count()); - auto error = MakeSessionError( - operation, path, std::nullopt, - "request timed out after " + seconds + "s (connecting to " + - base_url_ + ")", - category); - if (!error.hint.has_value()) { - error.hint = "Raise it with --timeout . Work already " - "started on the server may still be running and may " - "complete — check before repeating it."; + + // The request was sent and the answer did not arrive in time. + if (http_error == httplib::Error::Read || + http_error == httplib::Error::Timeout) { + auto error = MakeSessionError( + operation, path, std::nullopt, + "request timed out after " + + std::to_string(options.read_timeout.count()) + + "s (connecting to " + base_url_ + ")", + category); + if (!error.hint.has_value()) { + error.hint = "Raise it with --timeout . Work already " + "started on the server may still be running and " + "may complete — check before repeating it."; + } + return error; } - return error; + + return MakeSessionError(operation, path, std::nullopt, + "HTTP request failed: " + + httplib::to_string(http_error) + + " (connecting to " + base_url_ + ")", + category); } // Check if a request path targets the BW Modeling API. diff --git a/test/adt/test_adt_session.cpp b/test/adt/test_adt_session.cpp index 62b1631..7dea54d 100644 --- a/test/adt/test_adt_session.cpp +++ b/test/adt/test_adt_session.cpp @@ -1059,10 +1059,12 @@ TEST_CASE("AdtSession: a read timeout is reported as a timeout, naming --timeout CHECK(error.hint->find("--timeout") != std::string::npos); } -TEST_CASE("AdtSession: a connection failure is not dressed up as a timeout", +TEST_CASE("AdtSession: an unreachable server is not blamed on the read timeout", "[adt][session][live]") { - // Only the timeout family gets the new wording; everything else keeps - // saying what it always said. + // Whether a closed port is refused (Linux) or quietly dropped until the + // connect timeout expires (Windows), the request never reached the + // server — so the read timeout is not the number that ran out, and + // --timeout is not the flag that would have helped. auto client = SapClient::Create("001"); REQUIRE(client.IsOk()); AdtSessionOptions opts; @@ -1074,5 +1076,9 @@ TEST_CASE("AdtSession: a connection failure is not dressed up as a timeout", auto result = session.Get("/sap/bc/adt/test"); REQUIRE(result.IsErr()); - CHECK(result.Error().message.find("timed out") == std::string::npos); + const auto& error = result.Error(); + CHECK(error.message.find("request timed out after") == std::string::npos); + if (error.hint.has_value()) { + CHECK(error.hint->find("--timeout") == std::string::npos); + } } From 63ff7d87aab420a2ff9495fc809a55e44da1d93a Mon Sep 17 00:00:00 2001 From: Joachim Rosskopf Date: Tue, 1 Sep 2026 04:52:51 +0200 Subject: [PATCH 3/3] test(integration): a classrun on a missing class is a failure, not output Left over from the silent-success fix: test_20 still asserted exit 0 with 'does not exist' as console output, which is exactly the reading that fix removed, while test_24 asserts the opposite. The contract stays in test_24; test_20 now pins that the message still names the cause. --- test/integration_py/test_20_classrun.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/test/integration_py/test_20_classrun.py b/test/integration_py/test_20_classrun.py index caac696..2200a55 100644 --- a/test/integration_py/test_20_classrun.py +++ b/test/integration_py/test_20_classrun.py @@ -14,10 +14,17 @@ def test_run_flight_data_generator(self, cli): assert len(data["output"]) > 0 def test_run_nonexistent_class(self, cli): - """Running a nonexistent class returns output indicating it does not exist.""" - data = cli.run_ok("object", "run", "ZZZZ_NONEXISTENT_99999") - assert "output" in data - assert "does not exist" in data["output"].lower() + """A class that is not there is a failure, not console output. + + classrun answers HTTP 200 and puts "Object X of type CLAS does not + exist." in the *output*, which read literally says the run succeeded. + `object run` now checks existence first and exits 2. The contract + itself lives in test_24_missing_target_contract.py; this pins that the + message still names the cause. + """ + result = cli.run("object", "run", "ZZZZ_NONEXISTENT_99999") + assert result.returncode != 0 + assert "does not exist" in (result.stdout + result.stderr).lower() def test_run_plain_text_output(self, cli): """Without --json flag, output is printed directly to stdout."""