From 02c2e9b596dc2fee3e7cc7ba917b33fe82e30fb8 Mon Sep 17 00:00:00 2001 From: "Brent E. Edwards" Date: Thu, 26 Mar 2026 20:02:52 +0000 Subject: [PATCH] Merge remote-tracking branch 'origin/master' into test/int-wf10-batch --- CHANGELOG.md | 2 + features/lsp_client.feature | 36 +++ features/steps/lsp_client_steps.py | 153 +++++++++ robot/helper_wf10_batch_auto.py | 483 +++++++++++++++++++++++++++++ robot/wf10_batch_auto.robot | 82 +++++ 5 files changed, 756 insertions(+) create mode 100644 features/lsp_client.feature create mode 100644 features/steps/lsp_client_steps.py create mode 100644 robot/helper_wf10_batch_auto.py create mode 100644 robot/wf10_batch_auto.robot diff --git a/CHANGELOG.md b/CHANGELOG.md index 707a2dec7..77a12d220 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added Robot Framework integration test for Specification Workflow Example 10 + (full-auto batch formatting and linting, full-auto profile). (#774) - Added TDD bug-capture tests for bug #1076 — `use_action()` does not propagate `automation_profile` to Plan. Three Behave BDD scenarios (`@tdd_bug @tdd_bug_1076 @tdd_expected_fail`) verify the full precedence diff --git a/features/lsp_client.feature b/features/lsp_client.feature new file mode 100644 index 000000000..6a7e4d66e --- /dev/null +++ b/features/lsp_client.feature @@ -0,0 +1,36 @@ +@lsp @client +Feature: LSP Client + As a developer + I want a robust LSP client + So that I can communicate with language servers + + Scenario: Initialize handshake success + Given an LSP client with a mock transport + When I initialize the client with workspace "/tmp/test-ws" + Then the client should send an "initialize" request + And the client should send an "initialized" notification + And the client should be initialized + + Scenario: Shutdown sequence + Given an initialized LSP client + When I shutdown the client + Then the client should send a "shutdown" request + And the client should send an "exit" notification + And the client state should be uninitialized + + Scenario: Text document notifications + Given an initialized LSP client + When I open a document "file:///test.py" with content "print('hello')" + Then the client should send a "textDocument/didOpen" notification + When I close the document "file:///test.py" + Then the client should send a "textDocument/didClose" notification + + Scenario: Diagnostics handling + Given an initialized LSP client + When the server publishes diagnostics for "file:///test.py" + Then the client should have diagnostics for "file:///test.py" + + Scenario: Completion request + Given an initialized LSP client + When I request completions for "file:///test.py" at line 1 character 5 + Then the client should send a "textDocument/completion" request diff --git a/features/steps/lsp_client_steps.py b/features/steps/lsp_client_steps.py new file mode 100644 index 000000000..7614c4827 --- /dev/null +++ b/features/steps/lsp_client_steps.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from behave import given, then, when + +from cleveragents.lsp.client import LspClient +from cleveragents.lsp.transport import StdioTransport + + +class MockTransport(StdioTransport): + def __init__(self): + self.sent_messages = [] + self.responses = [] + self._is_alive = True + + @property + def is_alive(self) -> bool: + return self._is_alive + + def start(self) -> None: + pass + + def stop(self, timeout=None) -> None: + pass + + def send_message(self, body: dict) -> None: + self.sent_messages.append(body) + + def read_message(self, timeout=None) -> dict | None: + if self.responses: + return self.responses.pop(0) + return None + + def queue_response(self, response: dict): + self.responses.append(response) + + +@given("an LSP client with a mock transport") +def step_client_with_mock_transport(context): + context.transport = MockTransport() + # Mock __init__ of StdioTransport is bypassed by our subclass, + # but LspClient checks it. + context.client = LspClient(context.transport, server_name="test-server") + + +@when('I initialize the client with workspace "{workspace}"') +def step_initialize_client(context, workspace): + # Queue the response for the initialize request + # ID will be 1 because it's the first request + context.transport.queue_response( + {"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {"textDocumentSync": 1}}} + ) + context.client.initialize(workspace) + + +@then('the client should send an "initialize" request') +def step_check_initialize_request(context): + msg = context.transport.sent_messages[0] + assert msg["method"] == "initialize" + + +@then('the client should send an "initialized" notification') +def step_check_initialized_notification(context): + msg = context.transport.sent_messages[1] + assert msg["method"] == "initialized" + + +@then("the client should be initialized") +def step_check_initialized(context): + assert context.client.is_initialized + + +@given("an initialized LSP client") +def step_initialized_client(context): + step_client_with_mock_transport(context) + step_initialize_client(context, "/tmp/ws") + context.transport.sent_messages.clear() # Clear init messages + # Reset request ID counter if needed, or track IDs. + # LspClient._request_id will be 1. Next request is 2. + + +@when("I shutdown the client") +def step_shutdown_client(context): + # Next ID is 2 + context.transport.queue_response({"jsonrpc": "2.0", "id": 2, "result": None}) + context.client.shutdown() + + +@then('the client should send a "shutdown" request') +def step_check_shutdown_request(context): + msg = next(m for m in context.transport.sent_messages if m["method"] == "shutdown") + assert msg + + +@then('the client should send an "exit" notification') +def step_check_exit_notification(context): + msg = next(m for m in context.transport.sent_messages if m["method"] == "exit") + assert msg + + +@then("the client state should be uninitialized") +def step_check_not_initialized(context): + assert not context.client.is_initialized + + +@when('I open a document "{uri}" with content "{content}"') +def step_open_document(context, uri, content): + context.client.did_open(uri, "python", 1, content) + + +@then('the client should send a "textDocument/didOpen" notification') +def step_check_did_open(context): + msg = context.transport.sent_messages[-1] + assert msg["method"] == "textDocument/didOpen" + + +@when('I close the document "{uri}"') +def step_close_document(context, uri): + context.client.did_close(uri) + + +@then('the client should send a "textDocument/didClose" notification') +def step_check_did_close(context): + msg = context.transport.sent_messages[-1] + assert msg["method"] == "textDocument/didClose" + + +@when('the server publishes diagnostics for "{uri}"') +def step_server_publishes_diagnostics(context, uri): + notification = { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "diagnostics": [{"message": "error", "range": {}}]}, + } + context.client._handle_notification(notification) + + +@then('the client should have diagnostics for "{uri}"') +def step_check_diagnostics(context, uri): + diags = context.client.get_diagnostics(uri) + assert len(diags) > 0 + + +@when('I request completions for "{uri}" at line {line:d} character {char:d}') +def step_request_completions(context, uri, line, char): + # Next ID is 2 (after init) + context.transport.queue_response({"jsonrpc": "2.0", "id": 2, "result": []}) + context.client.get_completions(uri, line, char) + + +@then('the client should send a "textDocument/completion" request') +def step_check_completion_request(context): + msg = context.transport.sent_messages[-1] + assert msg["method"] == "textDocument/completion" diff --git a/robot/helper_wf10_batch_auto.py b/robot/helper_wf10_batch_auto.py new file mode 100644 index 000000000..0bad4b73a --- /dev/null +++ b/robot/helper_wf10_batch_auto.py @@ -0,0 +1,483 @@ +"""Helper script for wf10_batch_auto.robot E2E tests. + +Workflow Example 10: Full-Auto Batch Operations — Formatting and Linting. +Exercises the ``full-auto`` automation profile with batch plan execution +across multiple packages using mocked LLM providers. + +Each subcommand is self-contained, sets up its own workspace, and prints +a sentinel on success. Exit code 0 = pass, 1 = failure. + +Usage:: + + python robot/helper_wf10_batch_auto.py +""" + +from __future__ import annotations + +import os +import shutil +import sys +from collections.abc import Callable +from pathlib import Path + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) +_ROBOT = str(Path(__file__).resolve().parent) +if _ROBOT not in sys.path: + sys.path.insert(0, _ROBOT) + +from helper_e2e_common import ( # noqa: E402 + cleanup_workspace, + fail, + init_bare_git_repo, + run_cli, + setup_workspace, + write_yaml, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _init_workspace(prefix: str) -> str: + """Create a workspace with Alembic migrations applied.""" + return setup_workspace(prefix=prefix) + + +def _create_action(workspace: str, name: str) -> str: + """Create a reusable full-auto action. + + Returns the path to the temporary YAML file so callers can clean it up. + """ + yaml_path = write_yaml( + f"name: {name}\n" + "description: Batch format and lint package\n" + "automation_profile: full-auto\n" + "strategy_actor: openai/gpt-4\n" + "execution_actor: openai/gpt-4\n" + "definition_of_done: Code is formatted and lint-clean\n" + ) + result = run_cli("action", "create", "--config", yaml_path, workspace=workspace) + if result.returncode != 0: + fail( + f"action create {name} rc={result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + return yaml_path + + +def _register_resource(workspace: str) -> str: + """Register a git-checkout resource. + + Returns the path to the temporary git repo so callers can clean it up. + """ + repo_dir = init_bare_git_repo() + result = run_cli( + "resource", + "add", + "git-checkout", + "local/wf10-repo", + "--path", + repo_dir, + "--branch", + "main", + workspace=workspace, + ) + if result.returncode != 0: + fail( + f"resource add rc={result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + return repo_dir + + +def _create_project( + workspace: str, + name: str, + resource_name: str = "local/wf10-repo", +) -> None: + """Create a project linked to a resource.""" + result = run_cli( + "project", + "create", + name, + "--resource", + resource_name, + workspace=workspace, + ) + if result.returncode != 0: + fail( + f"project create {name} rc={result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def create_reusable_action() -> None: + """Create a full-auto action that can be reused across packages.""" + workspace = _init_workspace("wf10_action_") + yaml_path: str | None = None + try: + yaml_path = _create_action(workspace, "local/batch-format-lint") + print("action local/batch-format-lint created") + print("wf10-create-action-ok") + finally: + if yaml_path: + os.unlink(yaml_path) + cleanup_workspace(workspace) + + +def batch_plan_launch() -> None: + """Launch plans for 3 packages using the same action.""" + workspace = _init_workspace("wf10_batch_") + yaml_path: str | None = None + repo_dir: str | None = None + try: + yaml_path = _create_action(workspace, "local/batch-fmt") + repo_dir = _register_resource(workspace) + + # Create 3 projects simulating 3 packages + projects = ["local/pkg-alpha", "local/pkg-beta", "local/pkg-gamma"] + for proj in projects: + _create_project(workspace, proj) + + # Launch plan for each project using the same action + plan_ids: list[str] = [] + for proj in projects: + result = run_cli( + "plan", + "use", + "local/batch-fmt", + proj, + "--automation-profile", + "full-auto", + "--format", + "plain", + workspace=workspace, + ) + if result.returncode != 0: + fail( + f"plan use for {proj} rc={result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + # Extract plan ID from plain-format output + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("plan_id:"): + pid = stripped.split(":", 1)[1].strip() + plan_ids.append(pid) + break + + if not plan_ids: + fail( + "No plan IDs extracted from output after launching " + f"{len(projects)} plans" + ) + + print(f"Launched {len(plan_ids)} plans: {plan_ids}") + for proj in projects: + print(f"project: {proj}") + print("wf10-batch-plan-launch-ok") + finally: + if yaml_path: + os.unlink(yaml_path) + if repo_dir: + shutil.rmtree(repo_dir, ignore_errors=True) + cleanup_workspace(workspace) + + +def plan_list_monitoring() -> None: + """Launch plans and verify ``plan lifecycle-list`` shows them.""" + workspace = _init_workspace("wf10_list_") + yaml_path: str | None = None + repo_dir: str | None = None + try: + yaml_path = _create_action(workspace, "local/lint-batch") + repo_dir = _register_resource(workspace) + + projects = ["local/svc-one", "local/svc-two", "local/svc-three"] + for proj in projects: + _create_project(workspace, proj) + + # Launch plans + for proj in projects: + result = run_cli( + "plan", + "use", + "local/lint-batch", + proj, + "--automation-profile", + "full-auto", + workspace=workspace, + ) + if result.returncode != 0: + fail(f"plan use {proj} rc={result.returncode}\nstderr: {result.stderr}") + + # List all plans + result = run_cli( + "plan", + "lifecycle-list", + "--format", + "plain", + workspace=workspace, + ) + if result.returncode != 0: + fail( + f"plan lifecycle-list rc={result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + if "Traceback" in result.stderr or "INTERNAL" in result.stderr: + fail(f"plan lifecycle-list errors:\nstderr: {result.stderr}") + + # Verify that we see plans in the output + stdout_lower = result.stdout.lower() + if "total: 0" in stdout_lower or not result.stdout.strip(): + fail( + "plan lifecycle-list shows no plans after launching 3\n" + f"stdout: {result.stdout}" + ) + print(f"plan-count: {len(projects)}") + print("Plan lifecycle-list monitoring: OK") + print("wf10-plan-list-monitoring-ok") + finally: + if yaml_path: + os.unlink(yaml_path) + if repo_dir: + shutil.rmtree(repo_dir, ignore_errors=True) + cleanup_workspace(workspace) + + +def plan_list_filter_by_phase() -> None: + """Verify ``plan lifecycle-list --phase`` filtering works.""" + workspace = _init_workspace("wf10_filter_") + yaml_path: str | None = None + repo_dir: str | None = None + try: + yaml_path = _create_action(workspace, "local/fmt-filter") + repo_dir = _register_resource(workspace) + _create_project(workspace, "local/filter-proj") + + result = run_cli( + "plan", + "use", + "local/fmt-filter", + "local/filter-proj", + "--automation-profile", + "full-auto", + workspace=workspace, + ) + if result.returncode != 0: + fail(f"plan use rc={result.returncode}\nstderr: {result.stderr}") + + # Filter by strategize phase (plans start in strategize/queued) + result = run_cli( + "plan", + "lifecycle-list", + "--phase", + "strategize", + "--format", + "plain", + workspace=workspace, + ) + if result.returncode != 0: + fail( + "plan lifecycle-list --phase strategize " + f"rc={result.returncode}\nstderr: {result.stderr}" + ) + if "Traceback" in result.stderr or "INTERNAL" in result.stderr: + fail(f"lifecycle-list --phase errors:\nstderr: {result.stderr}") + print("phase: strategize") + print("Plan lifecycle-list phase filter: OK") + print("wf10-plan-filter-phase-ok") + finally: + if yaml_path: + os.unlink(yaml_path) + if repo_dir: + shutil.rmtree(repo_dir, ignore_errors=True) + cleanup_workspace(workspace) + + +def batch_execute_graceful() -> None: + """Execute plans in batch; verify graceful handling with mock AI.""" + workspace = _init_workspace("wf10_exec_") + yaml_path: str | None = None + repo_dir: str | None = None + try: + yaml_path = _create_action(workspace, "local/batch-exec") + repo_dir = _register_resource(workspace) + + projects = ["local/exec-a", "local/exec-b"] + for proj in projects: + _create_project(workspace, proj) + + # Launch plans for each project + plan_ids: list[str] = [] + for proj in projects: + result = run_cli( + "plan", + "use", + "local/batch-exec", + proj, + "--automation-profile", + "full-auto", + "--format", + "plain", + workspace=workspace, + ) + if result.returncode != 0: + fail(f"plan use {proj} rc={result.returncode}\nstderr: {result.stderr}") + # Extract plan ID from plain-format output + for line in result.stdout.splitlines(): + stripped = line.strip() + if stripped.startswith("plan_id:"): + pid = stripped.split(":", 1)[1].strip() + plan_ids.append(pid) + break + + # Execute each plan — with mock AI the plans are in + # strategize/queued so execute returns gracefully + for pid in plan_ids: + result = run_cli( + "plan", + "execute", + pid, + workspace=workspace, + ) + # Non-zero is acceptable (plan not ready) but no crashes + if "Traceback" in result.stderr: + fail(f"plan execute {pid} produced Traceback:\nstderr: {result.stderr}") + print(f"plan execute {pid}: rc={result.returncode}") + + # Verify lifecycle-list still works after execute attempts + result = run_cli( + "plan", + "lifecycle-list", + "--format", + "plain", + workspace=workspace, + ) + if result.returncode != 0: + fail(f"lifecycle-list rc={result.returncode}\nstderr: {result.stderr}") + if "Traceback" in result.stderr or "INTERNAL" in result.stderr: + fail(f"batch execute errors:\nstderr: {result.stderr}") + + print("Batch execute graceful: OK") + print("wf10-batch-execute-ok") + finally: + if yaml_path: + os.unlink(yaml_path) + if repo_dir: + shutil.rmtree(repo_dir, ignore_errors=True) + cleanup_workspace(workspace) + + +def error_handling() -> None: + """Demonstrate error handling when a plan references invalid action.""" + workspace = _init_workspace("wf10_error_") + repo_dir: str | None = None + try: + repo_dir = _register_resource(workspace) + _create_project(workspace, "local/error-proj") + + # Try to use a non-existent action + result = run_cli( + "plan", + "use", + "local/nonexistent-action", + "local/error-proj", + "--automation-profile", + "full-auto", + workspace=workspace, + ) + # Should fail gracefully (non-zero exit code, no crash) + if result.returncode == 0: + fail("plan use with nonexistent action should fail") + if "Traceback" in result.stderr: + fail( + f"plan use with bad action produced Traceback:\nstderr: {result.stderr}" + ) + print(f"error-rc: {result.returncode}") + print("Error handling for invalid action: graceful failure") + print("wf10-error-handling-ok") + finally: + if repo_dir: + shutil.rmtree(repo_dir, ignore_errors=True) + cleanup_workspace(workspace) + + +def full_auto_profile_verification() -> None: + """Verify full-auto profile is accepted in plan use.""" + workspace = _init_workspace("wf10_profile_") + yaml_path: str | None = None + repo_dir: str | None = None + try: + yaml_path = _create_action(workspace, "local/auto-lint") + repo_dir = _register_resource(workspace) + _create_project(workspace, "local/auto-proj") + + result = run_cli( + "plan", + "use", + "local/auto-lint", + "local/auto-proj", + "--automation-profile", + "full-auto", + workspace=workspace, + ) + if result.returncode != 0: + fail( + "plan use --automation-profile full-auto " + f"rc={result.returncode}\n" + f"stdout: {result.stdout}\nstderr: {result.stderr}" + ) + if "Traceback" in result.stderr or "INTERNAL" in result.stderr: + fail(f"full-auto profile errors:\nstderr: {result.stderr}") + print("automation-profile: full-auto") + print("Full-auto profile accepted: OK") + print("wf10-full-auto-profile-ok") + finally: + if yaml_path: + os.unlink(yaml_path) + if repo_dir: + shutil.rmtree(repo_dir, ignore_errors=True) + cleanup_workspace(workspace) + + +# --------------------------------------------------------------------------- +# Dispatcher +# --------------------------------------------------------------------------- + +_COMMANDS: dict[str, Callable[[], None]] = { + "create-action": create_reusable_action, + "batch-plan-launch": batch_plan_launch, + "plan-list-monitoring": plan_list_monitoring, + "plan-filter-phase": plan_list_filter_by_phase, + "batch-execute": batch_execute_graceful, + "error-handling": error_handling, + "full-auto-profile": full_auto_profile_verification, +} + + +def main() -> int: + """Dispatch to the requested subcommand.""" + if len(sys.argv) < 2: + cmds = "|".join(_COMMANDS) + print(f"Usage: helper_wf10_batch_auto.py <{cmds}>") + return 1 + command = sys.argv[1] + handler = _COMMANDS.get(command) + if handler is None: + print(f"Unknown command: {command}") + return 1 + handler() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/wf10_batch_auto.robot b/robot/wf10_batch_auto.robot new file mode 100644 index 000000000..12f81ab79 --- /dev/null +++ b/robot/wf10_batch_auto.robot @@ -0,0 +1,82 @@ +*** Settings *** +Documentation WF10: Full-auto batch formatting and linting E2E tests. +... Exercises the full-auto profile with batch plan execution +... across multiple packages using mocked LLM providers. +Resource ${CURDIR}/common.resource +Force Tags wf10 batch full-auto integration +Suite Setup Setup Test Environment +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_wf10_batch_auto.py + +*** Test Cases *** +WF10 Create Reusable Full Auto Action + [Documentation] Create a full-auto action for batch formatting. + [Tags] action create + ${result}= Run Process ${PYTHON} ${HELPER} create-action cwd=${SUITE_HOME} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf10-create-action-ok + Should Contain ${result.stdout} batch-format-lint + +WF10 Batch Plan Launch Three Packages + [Documentation] Launch plans for 3 packages using the same action. + [Tags] plan batch launch + ${result}= Run Process ${PYTHON} ${HELPER} batch-plan-launch cwd=${SUITE_HOME} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf10-batch-plan-launch-ok + Should Contain ${result.stdout} project: local/pkg-alpha + +WF10 Plan List Monitoring + [Documentation] Verify plan lifecycle-list shows launched plans. + [Tags] plan lifecycle list + ${result}= Run Process ${PYTHON} ${HELPER} plan-list-monitoring cwd=${SUITE_HOME} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf10-plan-list-monitoring-ok + Should Contain ${result.stdout} plan-count: 3 + +WF10 Plan List Filter By Phase + [Documentation] Verify plan lifecycle-list --phase filtering works. + [Tags] plan lifecycle filter + ${result}= Run Process ${PYTHON} ${HELPER} plan-filter-phase cwd=${SUITE_HOME} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf10-plan-filter-phase-ok + Should Contain ${result.stdout} phase: strategize + +WF10 Batch Execute Graceful + [Documentation] Execute plans in batch with graceful mock-AI handling. + [Tags] plan execute batch + ${result}= Run Process ${PYTHON} ${HELPER} batch-execute cwd=${SUITE_HOME} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf10-batch-execute-ok + Should Contain ${result.stdout} plan execute + +WF10 Error Handling Invalid Action + [Documentation] Demonstrate graceful error when referencing invalid action. + [Tags] plan error negative + ${result}= Run Process ${PYTHON} ${HELPER} error-handling cwd=${SUITE_HOME} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf10-error-handling-ok + Should Contain ${result.stdout} error-rc: + +WF10 Full Auto Profile Verification + [Documentation] Verify full-auto automation profile is accepted. + [Tags] plan profile full-auto + ${result}= Run Process ${PYTHON} ${HELPER} full-auto-profile cwd=${SUITE_HOME} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf10-full-auto-profile-ok + Should Contain ${result.stdout} automation-profile: full-auto