Files
crewAI/lib/cli/tests/deploy/test_deploy_main.py
T
João MouraandClaude Opus 5.5 1b9bbccbed
Build uv cache / build-cache (3.10) (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / Detect changes (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
feat(cli): crewai eval --models, and llm_overlay swaps models as well as roles (#7811)
* feat(llm): llm_overlay maps models too, not only agent roles

A key `model:<provider/model>` maps every LLM built from that model string
inside the block, and `model:*` every LLM built from any model string. That
reaches what a role cannot: a flow step's own `LLM(...).call()`. The key is
read in `LLM.__new__` (and so `create_llm` and an agent's `llm="..."`), and
on an agent's declared llm built outside the block; a role key still wins for
that agent. The caller's settings follow the mapped model by the rule a
declared llm's do. `MODEL_KEY_PREFIX` is public so another package can
feature-detect the form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(telemetry): allow-list cli_usage:eval_models attributes

The span `crewai eval --models` records carries whether the caller was
logged in, the models compared (provider/model names) and how many, in both
telemetry classes. Nothing about the run, its output or the organization.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(cli): crewai eval --models compares models on the deployment

`crewai eval --models "a,b"` (and `--deployment UUID` when AMP cannot tell
the deployment from the project id) asks AMP for a models evaluation: the
project id, the models from one comma-separated list (stripped, repeats
dropped, provider/model required, 1-5) and the project's eval.jsonc. It
needs the login, prints and opens the report link, says where the
comparison is while it runs, then prints one row per model (goal, tasks,
agents, tools, cost, time; the deployed one marked, the best per column
starred) and the top three suggestions. Exit 0 when it finished, 1 when it
failed or could not start. Everything from the wire is printed as Text.
The comparison is counted as `cli_usage:eval_models` once AMP accepts it.

Mode 1 now sends the project id with `create_evaluation` too, so every
evaluation is filed under the project it is about.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(cli): crewai deploy create and push send the project id

Every create (git or ZIP) and every push (redeploy by name or uuid, or a
ZIP update) carries `[tool.crewai].project_id` when the project has one, so
AMP knows which project a deployment runs and `crewai eval --models` can find
it. The id is read, never minted: a deploy does not rewrite pyproject.toml.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(llm): a model key maps an LLM whose declared SDK is not installed

Nothing is built on the declared model, so a missing SDK for it is no
reason to fail the mapped build; its credentials cannot be matched to the
new provider, so none are carried.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* feat(cli): crewai eval --models leaves its criteria behind as eval.jsonc

When the comparison's answer carries `eval_config` and the project has no
eval.jsonc, it is written through `write_eval_config` and the same one line
Mode 1 prints says so. An existing file is never overwritten.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(llm): a model key compares providers by provider, not by class

`_same_route_provider` now follows `_same_provider`'s rule case for case: a
LiteLLM-routed `LLM(...)` is on the same provider as a native route when its
provider names that native class, so its key and endpoint follow a model
key to another model of the same provider. Another provider still gets none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* chore(llm): say why mark_mapped may skip an object, and one import form in its tests

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(cli): usage stats name a compared model only when it is a public one

`cli_usage:eval_models` carried the `--models` strings as typed, and those
can identify a customer: a fine-tune id, an Azure deployment name, a
self-hosted model or host. A model is now sent by name only when it is an
exact entry of crewAI's model catalog (`LLM_CONTEXT_WINDOW_SIZES`), else as
`<provider>/other`, and a provider crewAI does not route as `other/other`.
An `ft:` id is never sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(cli): a refused comparison's test no longer keeps a None return

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(llms): re-importing OpenAI's completion module restores its package attribute

`test_openai_completion_module_is_imported` deletes the module from
sys.modules and lets `LLM(...)` import it again, which binds the new module
on `crewai.llms.providers.openai` as well. monkeypatch restored only the
sys.modules entry, so every later test on that worker saw two module
objects for one name: `patch("crewai.llms.providers.openai.completion.X")`
patched the attribute's module while `from ... import X` read sys.modules'.
On Python 3.10 that made tests/llms/azure/test_azure_responses.py fail
whenever it shared a worker with test_openai.py and test_crew_loader.py,
which the new tests' even split now did. The attribute is restored too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(llm): model keys strip only a model's own prefix; a role builds from the declaration

Two review findings on the model-for-model overlay:

- `overlay_model_for_model` took the text after the first slash as another
  name for the model, so an aggregator's route (`openrouter/openai/gpt-4o`)
  matched a `model:openai/gpt-4o` or `model:gpt-4o` key meant for the
  native model. A prefix is now stripped only when it is a native provider's
  own and what remains has no slash; an instance on an aggregator is compared
  as `<provider>/<its model id>`.
- With a role key and a model key both active, the model key mapped the
  declared llm first and the role's model was built like that mapped
  instance, losing the caller's key and endpoint. The overlay now remembers
  what the caller declared for every llm it built, and a role key is built
  from that declaration.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(llm): model keys read a provider's other names; a LiteLLM choice survives a missing SDK

- The router's provider aliases are now one table, `crewai.llm.PROVIDER_ALIASES`,
  and `llm_overlay` compares model names through it: `google/x` and
  `gemini/x` are one model whether the llm is built in the block or was
  built before it (a prebuilt instance records `gemini`). An exact key still
  wins, and an aggregator's route is still compared whole.
- When the declared model's SDK is missing, a caller's explicit
  `is_litellm=True` is kept for the mapped model instead of dropping to a
  native route.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(deploy): a git create sends the project id once, at the top level

AMP reads project_id at the top level on every deploy route, so the git
create no longer repeats it inside `deploy`. The redeploy docstring records
why its optional body is safe on an AMP that predates project ids.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 10:53:42 -07:00

1444 lines
60 KiB
Python

import sys
import unittest
from io import StringIO
from pathlib import Path
import subprocess
from unittest.mock import MagicMock, Mock, patch
import pytest
import json
import crewai_cli.deploy.main as deploy_main
from crewai_cli.deploy.archive import ArchiveError
import httpx
from crewai_cli.deploy.validate import Severity, ValidationResult
from crewai_cli.utils import parse_toml
def test_ensure_lockfile_for_deploy_runs_install_when_lock_missing(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
calls = []
def fake_install_crew(proxy_options, *, raise_on_error=False):
calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
deploy_main._ensure_lockfile_for_deploy()
assert calls == [([], True)]
def test_ensure_lockfile_for_deploy_skips_when_lock_exists(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
(tmp_path / "uv.lock").write_text("# lock\n")
calls = []
def fake_install_crew(proxy_options, *, raise_on_error=False):
calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
deploy_main._ensure_lockfile_for_deploy()
assert calls == []
def test_ensure_lockfile_for_deploy_skips_without_pyproject(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
calls = []
def fake_install_crew(proxy_options, *, raise_on_error=False):
calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
deploy_main._ensure_lockfile_for_deploy()
assert calls == []
def test_ensure_lockfile_for_deploy_failure_exits_nonzero(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
def fake_install_crew(proxy_options, *, raise_on_error=False):
raise subprocess.CalledProcessError(42, ["uv", "sync"])
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
with pytest.raises(SystemExit) as exc_info:
deploy_main._ensure_lockfile_for_deploy()
assert exc_info.value.code == 42
class _FakeDeployValidator:
def __init__(self, results: list[ValidationResult]):
self.results = results
@property
def errors(self) -> list[ValidationResult]:
return [
result
for result in self.results
if result.severity is Severity.ERROR
]
def run(self) -> list[ValidationResult]:
return self.results
def test_prepare_project_for_deploy_blocks_install_when_validation_fails(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
install_calls = []
rendered_results = []
missing_lockfile = ValidationResult(
Severity.ERROR,
"missing_lockfile",
"Expected to find a lockfile",
)
invalid_config = ValidationResult(
Severity.ERROR,
"invalid_crew_json",
"crew.jsonc has invalid configuration",
)
monkeypatch.setattr(
deploy_main,
"DeployValidator",
lambda: _FakeDeployValidator([missing_lockfile, invalid_config]),
)
monkeypatch.setattr(deploy_main, "render_report", rendered_results.append)
def fake_install_crew(proxy_options, *, raise_on_error=False):
install_calls.append((proxy_options, raise_on_error))
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
assert deploy_main._prepare_project_for_deploy(skip_validate=False) is False
assert install_calls == []
assert [[result.code for result in results] for results in rendered_results] == [
["invalid_crew_json"]
]
def test_prepare_project_for_deploy_creates_missing_lock_after_validation(
monkeypatch, tmp_path: Path
):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
install_calls = []
missing_lockfile = ValidationResult(
Severity.ERROR,
"missing_lockfile",
"Expected to find a lockfile",
)
validators = [
_FakeDeployValidator([missing_lockfile]),
_FakeDeployValidator([]),
]
def fake_validator():
return validators.pop(0)
def fake_install_crew(proxy_options, *, raise_on_error=False):
install_calls.append((proxy_options, raise_on_error))
(tmp_path / "uv.lock").write_text("# lock\n")
monkeypatch.setattr(deploy_main, "DeployValidator", fake_validator)
monkeypatch.setattr(deploy_main, "render_report", lambda results: None)
monkeypatch.setattr("crewai_cli.install_crew.install_crew", fake_install_crew)
assert deploy_main._prepare_project_for_deploy(skip_validate=False) is True
assert install_calls == [([], True)]
assert validators == []
def test_deployment_page_url_prefers_deployment_id():
assert (
deploy_main._deployment_page_url(
"https://app.crewai.com",
{"uuid": "crew-uuid", "deployment_id": 128687},
)
== "https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_deployment_page_url_prefers_nested_deployment_id_over_crew_uuid():
assert (
deploy_main._deployment_page_url(
"https://app.crewai.com",
{"uuid": "crew-uuid", "deployment": {"deployment_id": 128687}},
)
== "https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_deployment_page_url_does_not_use_uuid_as_deployment_id():
assert (
deploy_main._deployment_page_url(
"https://app.crewai.com/",
{"uuid": "crew-uuid", "deployment": {"uuid": "deployment-uuid"}},
)
is None
)
class TestDeployCommand(unittest.TestCase):
@patch("crewai_cli.command.get_auth_token")
@patch("crewai_cli.deploy.main.get_project_name")
@patch("crewai_cli.command.PlusAPI")
def setUp(
self,
mock_plus_api,
mock_get_project_name,
mock_get_auth_token,
):
self.mock_get_auth_token = mock_get_auth_token
self.mock_get_project_name = mock_get_project_name
self.mock_plus_api = mock_plus_api
self.mock_get_auth_token.return_value = "test_token"
self.mock_get_project_name.return_value = "test_project"
self.deploy_command = deploy_main.DeployCommand()
self.mock_client = self.deploy_command.plus_api_client
self.mock_client.base_url = "https://app.crewai.com"
self.mock_browser_open_patcher = patch(
"crewai_cli.deploy.main.webbrowser.open"
)
self.mock_browser_open = self.mock_browser_open_patcher.start()
self.addCleanup(self.mock_browser_open_patcher.stop)
def _status_response(
self,
*,
uuid: str = "test-uuid",
zip_deployment: bool | None = None,
is_success: bool = True,
) -> MagicMock:
response = MagicMock()
response.is_success = is_success
payload: dict = {"uuid": uuid}
if zip_deployment is not None:
payload["zip_deployment"] = zip_deployment
response.json.return_value = payload
return response
def test_init_success(self):
self.assertEqual(self.deploy_command.project_name, "test_project")
self.mock_plus_api.assert_called_once_with(api_key="test_token")
@patch("builtins.input")
def test_confirm_zip_input_only_confirms_env_vars(self, mock_input):
self.deploy_command._confirm_zip_input({"MODEL": "openai/gpt-5"}, False)
mock_input.assert_called_once_with("Press Enter to continue with 1 env vars: MODEL")
@patch("crewai_cli.command.get_auth_token")
def test_init_failure(self, mock_get_auth_token):
mock_get_auth_token.side_effect = Exception("Auth failed")
with self.assertRaises(SystemExit):
deploy_main.DeployCommand()
def test_validate_response_successful_response(self):
mock_response = Mock(spec=httpx.Response)
mock_response.json.return_value = {"message": "Success"}
mock_response.status_code = 200
mock_response.is_success = True
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._validate_response(mock_response)
assert fake_out.getvalue() == ""
def test_validate_response_json_decode_error(self):
mock_response = Mock(spec=httpx.Response)
mock_response.json.side_effect = json.JSONDecodeError("Decode error", "", 0)
mock_response.status_code = 500
mock_response.content = b"Invalid JSON"
with patch("sys.stdout", new=StringIO()) as fake_out:
with pytest.raises(SystemExit):
self.deploy_command._validate_response(mock_response)
output = fake_out.getvalue()
assert (
"Failed to parse response from Enterprise API failed. Details:"
in output
)
assert "Status Code: 500" in output
assert "Response:\nInvalid JSON" in output
def test_validate_response_422_error(self):
mock_response = Mock(spec=httpx.Response)
mock_response.json.return_value = {
"field1": ["Error message 1"],
"field2": ["Error message 2"],
}
mock_response.status_code = 422
mock_response.is_success = False
with patch("sys.stdout", new=StringIO()) as fake_out:
with pytest.raises(SystemExit):
self.deploy_command._validate_response(mock_response)
output = fake_out.getvalue()
assert (
"Failed to complete operation. Please fix the following errors:"
in output
)
assert "Field1 Error message 1" in output
assert "Field2 Error message 2" in output
def test_validate_response_other_error(self):
mock_response = Mock(spec=httpx.Response)
mock_response.json.return_value = {"error": "Something went wrong"}
mock_response.status_code = 500
mock_response.is_success = False
with patch("sys.stdout", new=StringIO()) as fake_out:
with pytest.raises(SystemExit):
self.deploy_command._validate_response(mock_response)
output = fake_out.getvalue()
assert "Request to Enterprise API failed. Details:" in output
assert "Details:\nSomething went wrong" in output
def test_standard_no_param_error_message(self):
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._standard_no_param_error_message()
self.assertIn("No UUID provided", fake_out.getvalue())
def test_display_deployment_info(self):
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._display_deployment_info(
{"uuid": "test-uuid", "id": 128687, "status": "deployed"}
)
self.assertIn("Deploying the crew...", fake_out.getvalue())
self.assertIn("test-uuid", fake_out.getvalue())
self.assertIn("deployed", fake_out.getvalue())
self.assertIn(
"https://app.crewai.com/crewai_plus/deployments/128687",
fake_out.getvalue(),
)
self.mock_browser_open.assert_called_once_with(
"https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_display_deployment_info_warns_when_browser_open_returns_false(self):
self.mock_browser_open.return_value = False
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._display_deployment_info(
{"uuid": "test-uuid", "id": 128687, "status": "deployed"}
)
self.assertIn(
"Could not open the deployment page automatically.",
fake_out.getvalue(),
)
self.mock_browser_open.assert_called_once_with(
"https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_display_deployment_info_warns_when_browser_open_raises(self):
self.mock_browser_open.side_effect = RuntimeError("no browser")
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._display_deployment_info(
{"uuid": "test-uuid", "id": 128687, "status": "deployed"}
)
self.assertIn(
"Could not open the deployment page automatically.",
fake_out.getvalue(),
)
self.mock_browser_open.assert_called_once_with(
"https://app.crewai.com/crewai_plus/deployments/128687"
)
def test_display_creation_success_resolves_deployment_page_id_from_status(self):
status_response = MagicMock()
status_response.is_success = True
status_response.json.return_value = {
"uuid": "new-uuid",
"id": 128774,
"status": "created",
}
self.mock_client.crew_status_by_uuid.return_value = status_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._display_creation_success(
{"uuid": "new-uuid", "status": "created"}
)
output = fake_out.getvalue()
self.assertIn("crewai deploy push --uuid new-uuid", output)
self.assertIn(
"https://app.crewai.com/crewai_plus/deployments/128774",
output,
)
self.assertNotIn(
"https://app.crewai.com/crewai_plus/deployments/new-uuid",
output,
)
self.mock_client.crew_status_by_uuid.assert_called_once_with("new-uuid")
self.mock_browser_open.assert_called_once_with(
"https://app.crewai.com/crewai_plus/deployments/128774"
)
def test_open_deployment_page_does_not_open_uuid_url_when_status_lookup_fails(
self,
):
status_response = MagicMock()
status_response.is_success = False
self.mock_client.crew_status_by_uuid.return_value = status_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._open_deployment_page({"uuid": "new-uuid"})
output = fake_out.getvalue()
self.mock_client.crew_status_by_uuid.assert_called_once_with("new-uuid")
self.mock_browser_open.assert_not_called()
self.assertNotIn("deployments/new-uuid", output)
def test_display_logs(self):
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command._display_logs(
[{"timestamp": "2023-01-01", "level": "INFO", "message": "Test log"}]
)
self.assertIn("2023-01-01 - INFO: Test log", fake_out.getvalue())
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_uuid(self, mock_display, mock_repository):
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
self.mock_client.crew_status_by_uuid.return_value = self._status_response(
zip_deployment=False
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_uuid.return_value = mock_response
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.crew_status_by_uuid.assert_called_once_with("test-uuid")
self.mock_client.deploy_by_uuid.assert_called_once_with("test-uuid")
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_project_name(self, mock_display, mock_repository):
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
self.mock_client.crew_status_by_name.return_value = self._status_response(
zip_deployment=False
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_name.return_value = mock_response
self.deploy_command.deploy(skip_validate=True)
self.mock_client.crew_status_by_name.assert_called_once_with("test_project")
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_remote_keeps_remote_path_when_fetch_fails(
self, mock_display, mock_repository, mock_create_project_zip
):
repository = mock_repository.return_value
repository.origin_url.return_value = "https://github.com/test/repo.git"
repository.fetch.side_effect = ValueError("fetch failed")
repository.create_initial_commit_if_needed.return_value = False
self.mock_client.crew_status_by_name.return_value = self._status_response(
zip_deployment=False
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_name.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.deploy(skip_validate=True)
output = fake_out.getvalue()
mock_repository.assert_called_once_with(fetch=False)
repository.fetch.assert_called_once_with()
self.assertIn("Continuing with remote deployment", output)
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
self.mock_client.update_crew_from_zip.assert_not_called()
mock_create_project_zip.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_remote_keeps_remote_path_when_initial_commit_fails(
self, mock_display, mock_repository, mock_create_project_zip
):
repository = mock_repository.return_value
repository.origin_url.return_value = "https://github.com/test/repo.git"
repository.create_initial_commit_if_needed.side_effect = RuntimeError(
"commit failed"
)
self.mock_client.crew_status_by_name.return_value = self._status_response(
zip_deployment=False
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_name.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.deploy(skip_validate=True)
output = fake_out.getvalue()
mock_repository.assert_called_once_with(fetch=False)
repository.fetch.assert_called_once_with()
self.assertIn("Continuing with remote deployment", output)
self.mock_client.deploy_by_name.assert_called_once_with("test_project")
self.mock_client.update_crew_from_zip.assert_not_called()
mock_create_project_zip.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_uuid_without_remote_updates_from_zip(
self, mock_display, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
self.mock_client.crew_status_by_uuid.return_value = self._status_response(
zip_deployment=True
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.update_crew_from_zip.return_value = mock_response
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.crew_status_by_uuid.assert_called_once_with("test-uuid")
self.mock_client.update_crew_from_zip.assert_called_once_with(
"test-uuid",
Path("/tmp/test_project.zip"),
env={"ENV_VAR": "value"},
)
self.mock_client.deploy_by_uuid.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_with_project_name_without_remote_updates_from_zip(
self, mock_display, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
update_response = MagicMock()
update_response.status_code = 200
update_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.crew_status_by_name.return_value = self._status_response(
zip_deployment=True
)
self.mock_client.update_crew_from_zip.return_value = update_response
self.deploy_command.deploy(skip_validate=True)
self.mock_client.crew_status_by_name.assert_called_once_with("test_project")
self.mock_client.update_crew_from_zip.assert_called_once_with(
"test-uuid",
Path("/tmp/test_project.zip"),
env={"ENV_VAR": "value"},
)
self.mock_client.deploy_by_name.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_zip_amp_source_uploads_even_when_origin_exists(
self, mock_display, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
self.mock_client.crew_status_by_uuid.return_value = self._status_response(
zip_deployment=True
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.update_crew_from_zip.return_value = mock_response
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.update_crew_from_zip.assert_called_once_with(
"test-uuid",
Path("/tmp/test_project.zip"),
env={"ENV_VAR": "value"},
)
self.mock_client.deploy_by_uuid.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_falls_back_to_origin_when_zip_deployment_is_missing(
self, mock_display, mock_repository
):
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
self.mock_client.crew_status_by_uuid.return_value = self._status_response()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_uuid.return_value = mock_response
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.deploy_by_uuid.assert_called_once_with("test-uuid")
self.mock_client.update_crew_from_zip.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_falls_back_to_origin_when_status_request_fails(
self, mock_display, mock_repository
):
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
self.mock_client.crew_status_by_uuid.return_value = self._status_response(
zip_deployment=True, is_success=False
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.deploy_by_uuid.return_value = mock_response
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.deploy_by_uuid.assert_called_once_with("test-uuid")
self.mock_client.update_crew_from_zip.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_deploy_falls_back_to_zip_when_status_fails_and_origin_is_missing(
self, mock_display, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
self.mock_client.crew_status_by_uuid.side_effect = httpx.ConnectError(
"offline"
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"uuid": "test-uuid"}
self.mock_client.update_crew_from_zip.return_value = mock_response
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.update_crew_from_zip.assert_called_once_with(
"test-uuid",
Path("/tmp/test_project.zip"),
env={"ENV_VAR": "value"},
)
self.mock_client.deploy_by_uuid.assert_not_called()
mock_display.assert_called_once_with({"uuid": "test-uuid"})
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
@pytest.mark.timeout(180)
def test_create_crew(self, mock_input, mock_repository, mock_fetch_env):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_input.return_value = ""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "new-uuid", "status": "created"}
self.mock_client.create_crew.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(skip_validate=True)
self.assertIn("Deployment created successfully!", fake_out.getvalue())
self.assertIn("new-uuid", fake_out.getvalue())
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
@pytest.mark.timeout(180)
def test_create_crew_reports_the_created_uuid_from_the_git_path(
self, mock_input, mock_repository, mock_fetch_env
):
"""The attempt span cannot carry the uuid; the post-success span must."""
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_input.return_value = ""
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "new-uuid", "status": "created"}
self.mock_client.create_crew.return_value = mock_response
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_created_span.assert_called_once_with(
uuid="new-uuid", source="cli"
)
telemetry.create_crew_deployment_span.assert_called_once_with(source="cli")
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_reports_the_created_uuid_from_the_zip_path(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
"""The two creation paths converge, so the uuid must arrive from both."""
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.side_effect = ValueError("not a Git repository")
initialized_repository = MagicMock()
initialized_repository.origin_url.return_value = None
mock_repository.initialize.return_value = initialized_repository
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
self.mock_client.create_crew_from_zip.return_value = mock_response
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
self.deploy_command.create_crew(skip_validate=True, confirm=True)
telemetry.crew_deployment_created_span.assert_called_once_with(
uuid="zip-uuid", source="cli"
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_failed_create_counts_the_attempt_but_reports_no_creation(
self, mock_input, mock_repository, mock_fetch_env
):
"""The whole point of two spans: a failed create is an attempt, not a success.
Collapsing them into one post-success span would silently convert the
creation-attempt metric into a creation-success metric, which the churn
figures are built on.
"""
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_input.return_value = ""
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.is_success = False
mock_response.json.return_value = {"error": "boom"}
self.mock_client.create_crew.return_value = mock_response
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(skip_validate=True)
telemetry.create_crew_deployment_span.assert_called_once_with(source="cli")
telemetry.crew_deployment_created_span.assert_not_called()
# --- why a create failed (the Crew Deployment Failed span) ---------------
def _git_path(self, mock_input, mock_repository, mock_fetch_env):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = (
"https://github.com/test/repo.git"
)
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_input.return_value = ""
@staticmethod
def _api_response(status_code: int, body: object) -> MagicMock:
response = MagicMock()
response.status_code = status_code
response.is_success = 200 <= status_code < 300
if isinstance(body, Exception):
response.json.side_effect = body
else:
response.json.return_value = body
return response
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_rejected_create_reports_the_api_class_and_status(
self, mock_input, mock_repository, mock_fetch_env
):
"""A 4xx names the class and carries the exact code; the message never leaves."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
422, {"name": ["has already been taken"]}
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"api_4xx", source="cli", status_code=422
)
telemetry.crew_deployment_created_span.assert_not_called()
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_server_error_reports_api_5xx(
self, mock_input, mock_repository, mock_fetch_env
):
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
503, {"error": "upstream unavailable"}
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"api_5xx", source="cli", status_code=503
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_non_json_success_body_reports_invalid_json(
self, mock_input, mock_repository, mock_fetch_env
):
"""A proxy's 200 HTML page: _validate_response rejects it, so it is a
failure with a 2xx attached, and it is not the same failure as a JSON
body that is missing the creation fields."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
200, ValueError("not json")
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"invalid_json", source="cli", status_code=200
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_transport_failure_reports_network_error_and_propagates(
self, mock_input, mock_repository, mock_fetch_env
):
"""No response, so no status; the exception reaches the caller unchanged."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.side_effect = httpx.ConnectError("refused")
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with pytest.raises(httpx.ConnectError, match="refused"):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"network_error", source="cli"
)
telemetry.crew_deployment_created_span.assert_not_called()
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_a_failed_archive_reports_zip_error(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.side_effect = ValueError("not a Git repository")
initialized_repository = MagicMock()
initialized_repository.origin_url.return_value = None
mock_repository.initialize.return_value = initialized_repository
mock_create_project_zip.side_effect = ArchiveError(
"No deployable project files were found."
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with pytest.raises(ArchiveError, match="No deployable project files"):
self.deploy_command.create_crew(skip_validate=True, confirm=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"zip_error", source="cli"
)
self.mock_client.create_crew_from_zip.assert_not_called()
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_git_helper_error_is_not_a_zip_error(
self, mock_input, mock_repository, mock_fetch_env
):
"""The git helpers raise plain ValueError; only ArchiveError is a ZIP problem."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
mock_repository.return_value.origin_url.side_effect = ValueError(
"Git remote lookup failed"
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with pytest.raises(ValueError, match="Git remote lookup failed"):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"unexpected", source="cli"
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_non_json_error_page_keeps_its_api_class(
self, mock_input, mock_repository, mock_fetch_env
):
"""A gateway's HTML 502 is an api_5xx, not an invalid response."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
502, ValueError("not json")
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"api_5xx", source="cli", status_code=502
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_success_body_that_is_not_a_creation_reports_invalid_creation_response(
self, mock_input, mock_repository, mock_fetch_env
):
"""A 2xx JSON body without a deployment uuid is a broken creation
contract, not a success, and must not crash."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
for body in ([{"uuid": "in-a-list"}], {"status": "created"}, {}):
with self.subTest(body=body):
self.mock_client.create_crew.return_value = self._api_response(
200, body
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()) as fake_out:
with self.assertRaises(SystemExit) as exit_info:
self.deploy_command.create_crew(skip_validate=True)
assert exit_info.exception.code == 1
assert "no deployment uuid was returned" in fake_out.getvalue()
telemetry.crew_deployment_failed_span.assert_called_once_with(
"invalid_creation_response", source="cli", status_code=200
)
telemetry.crew_deployment_created_span.assert_not_called()
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_an_abort_at_the_prompt_reports_user_declined(
self, mock_input, mock_repository, mock_fetch_env
):
self._git_path(mock_input, mock_repository, mock_fetch_env)
mock_input.side_effect = KeyboardInterrupt
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(KeyboardInterrupt):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"user_declined", source="cli"
)
self.mock_client.create_crew.assert_not_called()
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_failure_from_the_run_tui_keeps_its_source(
self, mock_input, mock_repository, mock_fetch_env
):
"""The TUI succeeds far less often than the CLI; the split must survive."""
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
403, {"error": "forbidden"}
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
with self.assertRaises(SystemExit):
self.deploy_command.create_crew(
skip_validate=True, confirm=True, source="tui"
)
telemetry.crew_deployment_failed_span.assert_called_once_with(
"api_4xx", source="tui", status_code=403
)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
@patch("builtins.input")
def test_a_successful_create_reports_no_failure(
self, mock_input, mock_repository, mock_fetch_env
):
self._git_path(mock_input, mock_repository, mock_fetch_env)
self.mock_client.create_crew.return_value = self._api_response(
201, {"uuid": "new-uuid", "status": "created"}
)
with patch.object(self.deploy_command, "_telemetry") as telemetry:
with patch("sys.stdout", new=StringIO()):
self.deploy_command.create_crew(skip_validate=True)
telemetry.crew_deployment_failed_span.assert_not_called()
telemetry.crew_deployment_created_span.assert_called_once_with(
uuid="new-uuid", source="cli"
)
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_without_git_repo_initializes_and_uses_zip(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.side_effect = ValueError("not a Git repository")
initialized_repository = MagicMock()
initialized_repository.origin_url.return_value = None
mock_repository.initialize.return_value = initialized_repository
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
self.mock_client.create_crew_from_zip.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(confirm=True, skip_validate=True)
output = fake_out.getvalue()
self.assertIn("Initialized a local Git repository", output)
self.assertIn("Deploying from a ZIP upload", output)
mock_repository.initialize.assert_called_once_with()
mock_create_project_zip.assert_called_once_with(
"test_project", repository=initialized_repository
)
self.mock_client.create_crew_from_zip.assert_called_once_with(
Path("/tmp/test_project.zip"),
name="test_project",
env={"ENV_VAR": "value"},
)
self.mock_client.create_crew.assert_not_called()
@patch("crewai_cli.deploy.main.git.Repository")
def test_prepare_git_repository_returns_repo_when_init_commit_fails(
self, mock_repository
):
recovered_repository = MagicMock()
mock_repository.side_effect = [
ValueError("not a Git repository"),
recovered_repository,
]
mock_repository.initialize.side_effect = RuntimeError("commit failed")
with patch("sys.stdout", new=StringIO()) as fake_out:
repository = self.deploy_command._prepare_git_repository()
self.assertIs(repository, recovered_repository)
self.assertIn("Git auto-setup did not complete", fake_out.getvalue())
mock_repository.initialize.assert_called_once_with()
self.assertEqual(mock_repository.call_count, 2)
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_without_remote_uses_zip(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = (
False
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
self.mock_client.create_crew_from_zip.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(confirm=True, skip_validate=True)
output = fake_out.getvalue()
self.assertIn("No origin remote found.", output)
self.assertIn("Deploying from a ZIP upload", output)
mock_create_project_zip.assert_called_once_with(
"test_project", repository=mock_repository.return_value
)
self.mock_client.create_crew_from_zip.assert_called_once_with(
Path("/tmp/test_project.zip"),
name="test_project",
env={"ENV_VAR": "value"},
)
self.mock_client.create_crew.assert_not_called()
@patch("crewai_cli.deploy.main.create_project_zip")
@patch("crewai_cli.deploy.main.fetch_and_json_env_file")
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_crew_without_remote_uses_git_file_list_when_commit_fails(
self, mock_repository, mock_fetch_env, mock_create_project_zip
):
mock_fetch_env.return_value = {"ENV_VAR": "value"}
repository = mock_repository.return_value
repository.origin_url.return_value = None
repository.create_initial_commit_if_needed.side_effect = RuntimeError(
"commit failed"
)
mock_create_project_zip.return_value = Path("/tmp/test_project.zip")
mock_response = MagicMock()
mock_response.status_code = 201
mock_response.is_success = True
mock_response.json.return_value = {"uuid": "zip-uuid", "status": "created"}
self.mock_client.create_crew_from_zip.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.create_crew(confirm=True, skip_validate=True)
output = fake_out.getvalue()
self.assertIn("Continuing with ZIP deployment using Git", output)
self.assertIn("file listing", output)
mock_create_project_zip.assert_called_once_with(
"test_project", repository=repository
)
self.mock_client.create_crew_from_zip.assert_called_once_with(
Path("/tmp/test_project.zip"),
name="test_project",
env={"ENV_VAR": "value"},
)
self.mock_client.create_crew.assert_not_called()
def test_list_crews(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{"name": "Crew1", "uuid": "uuid1", "status": "active"},
{"name": "Crew2", "uuid": "uuid2", "status": "inactive"},
]
self.mock_client.list_crews.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.list_crews()
self.assertIn("Crew1 (uuid1) active", fake_out.getvalue())
self.assertIn("Crew2 (uuid2) inactive", fake_out.getvalue())
def test_get_crew_status(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"name": "InternalCrew", "status": "active"}
self.mock_client.crew_status_by_name.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.get_crew_status()
self.assertIn("InternalCrew", fake_out.getvalue())
self.assertIn("active", fake_out.getvalue())
def test_get_crew_logs(self):
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = [
{"timestamp": "2023-01-01", "level": "INFO", "message": "Log1"},
{"timestamp": "2023-01-02", "level": "ERROR", "message": "Log2"},
]
self.mock_client.crew_by_name.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.get_crew_logs(None)
self.assertIn("2023-01-01 - INFO: Log1", fake_out.getvalue())
self.assertIn("2023-01-02 - ERROR: Log2", fake_out.getvalue())
def test_remove_crew(self):
mock_response = MagicMock()
mock_response.status_code = 204
self.mock_client.delete_crew_by_name.return_value = mock_response
with patch("sys.stdout", new=StringIO()) as fake_out:
self.deploy_command.remove_crew(None)
self.assertIn(
"Crew 'test_project' removed successfully", fake_out.getvalue()
)
@unittest.skipIf(sys.version_info < (3, 11), "Requires Python 3.11+")
def test_parse_toml_python_311_plus(self):
toml_content = """
[tool.poetry]
name = "test_project"
version = "0.1.0"
[tool.poetry.dependencies]
python = "^3.11"
crewai = { extras = ["tools"], version = ">=0.51.0,<1.0.0" }
"""
parsed = parse_toml(toml_content)
self.assertEqual(parsed["tool"]["poetry"]["name"], "test_project")
@patch(
"builtins.open",
new_callable=unittest.mock.mock_open,
read_data="""
[project]
name = "test_project"
version = "0.1.0"
requires-python = ">=3.10,<3.14"
dependencies = ["crewai"]
""",
)
def test_get_project_name_python_310(self, mock_open):
from crewai_cli.utils import get_project_name
project_name = get_project_name()
print("project_name", project_name)
self.assertEqual(project_name, "test_project")
@unittest.skipIf(sys.version_info < (3, 11), "Requires Python 3.11+")
@patch(
"builtins.open",
new_callable=unittest.mock.mock_open,
read_data="""
[project]
name = "test_project"
version = "0.1.0"
requires-python = ">=3.10,<3.14"
dependencies = ["crewai"]
""",
)
def test_get_project_name_python_311_plus(self, mock_open):
from crewai_cli.utils import get_project_name
project_name = get_project_name()
self.assertEqual(project_name, "test_project")
def test_get_crewai_version(self):
from crewai_cli.version import get_crewai_version
assert isinstance(get_crewai_version(), str)
PROJECT_ID = "1f0c6a3e-8d0b-4c4e-9a52-6a9f6d1d2b7e"
class TestDeploySendsTheProjectId(unittest.TestCase):
"""Every create and push carries `[tool.crewai].project_id` when the
project has one, so AMP knows which project a deployment runs."""
@patch("crewai_cli.command.get_auth_token", return_value="test_token")
@patch("crewai_cli.deploy.main.get_project_id", return_value=PROJECT_ID)
@patch("crewai_cli.deploy.main.get_project_name", return_value="test_project")
@patch("crewai_cli.command.PlusAPI")
def setUp(self, _plus_api, _name, _project_id, _token):
self.deploy_command = deploy_main.DeployCommand()
self.mock_client = self.deploy_command.plus_api_client
self.mock_client.base_url = "https://app.crewai.com"
browser = patch("crewai_cli.deploy.main.webbrowser.open")
browser.start()
self.addCleanup(browser.stop)
def _created(self, uuid: str = "new-uuid") -> MagicMock:
response = MagicMock()
response.status_code = 201
response.is_success = True
response.json.return_value = {"uuid": uuid, "status": "created"}
return response
def _status(self, zip_deployment: bool) -> MagicMock:
response = MagicMock()
response.is_success = True
response.json.return_value = {"uuid": "test-uuid", "zip_deployment": zip_deployment}
return response
def test_the_command_reads_the_project_id(self):
self.assertEqual(self.deploy_command.project_id, PROJECT_ID)
@patch("crewai_cli.deploy.main.fetch_and_json_env_file", return_value={})
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_from_git_sends_it(self, mock_repository, _env):
mock_repository.return_value.origin_url.return_value = "https://github.com/t/r.git"
mock_repository.return_value.create_initial_commit_if_needed.return_value = False
self.mock_client.create_crew.return_value = self._created()
with patch("sys.stdout", new=StringIO()):
self.deploy_command.create_crew(confirm=True, skip_validate=True)
payload = self.mock_client.create_crew.call_args.args[0]
# Once, at the top level, where AMP reads it on every deploy route.
self.assertEqual(payload["project_id"], PROJECT_ID)
self.assertNotIn("project_id", payload["deploy"])
@patch("crewai_cli.deploy.main.create_project_zip", return_value=Path("/tmp/p.zip"))
@patch("crewai_cli.deploy.main.fetch_and_json_env_file", return_value={})
@patch("crewai_cli.deploy.main.git.Repository")
def test_create_from_a_zip_sends_it(self, mock_repository, _env, _zip):
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = False
self.mock_client.create_crew_from_zip.return_value = self._created()
with patch("sys.stdout", new=StringIO()):
self.deploy_command.create_crew(confirm=True, skip_validate=True)
self.mock_client.create_crew_from_zip.assert_called_once_with(
Path("/tmp/p.zip"), name="test_project", env={}, project_id=PROJECT_ID
)
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_a_push_from_git_sends_it(self, _display, mock_repository):
mock_repository.return_value.origin_url.return_value = "https://github.com/t/r.git"
mock_repository.return_value.create_initial_commit_if_needed.return_value = False
self.mock_client.crew_status_by_uuid.return_value = self._status(False)
self.mock_client.crew_status_by_name.return_value = self._status(False)
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.deploy_command.deploy(skip_validate=True)
self.mock_client.deploy_by_uuid.assert_called_once_with(
"test-uuid", project_id=PROJECT_ID
)
self.mock_client.deploy_by_name.assert_called_once_with(
"test_project", project_id=PROJECT_ID
)
@patch("crewai_cli.deploy.main.create_project_zip", return_value=Path("/tmp/p.zip"))
@patch("crewai_cli.deploy.main.fetch_and_json_env_file", return_value={})
@patch("crewai_cli.deploy.main.git.Repository")
@patch("crewai_cli.deploy.main.DeployCommand._display_deployment_info")
def test_a_push_from_a_zip_sends_it(self, _display, mock_repository, _env, _zip):
mock_repository.return_value.origin_url.return_value = None
mock_repository.return_value.create_initial_commit_if_needed.return_value = False
self.mock_client.crew_status_by_uuid.return_value = self._status(True)
self.deploy_command.deploy(uuid="test-uuid", skip_validate=True)
self.mock_client.update_crew_from_zip.assert_called_once_with(
"test-uuid", Path("/tmp/p.zip"), env={}, project_id=PROJECT_ID
)
def test_a_project_without_an_id_sends_none(monkeypatch, tmp_path: Path):
"""Read, never minted: a deploy does not rewrite pyproject.toml."""
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname = 'demo'\n")
monkeypatch.setattr("crewai_cli.command.get_auth_token", lambda: "t")
monkeypatch.setattr("crewai_cli.command.PlusAPI", MagicMock())
monkeypatch.setattr(deploy_main, "get_project_name", lambda require=False: "demo")
command = deploy_main.DeployCommand()
assert command.project_id is None and command._project() == {}
assert "project_id" not in (tmp_path / "pyproject.toml").read_text()
def test_the_project_id_is_read_from_pyproject(monkeypatch, tmp_path: Path):
monkeypatch.chdir(tmp_path)
(tmp_path / "pyproject.toml").write_text(
f"[project]\nname = 'demo'\n\n[tool.crewai]\nproject_id = \"{PROJECT_ID}\"\n"
)
monkeypatch.setattr("crewai_cli.command.get_auth_token", lambda: "t")
monkeypatch.setattr("crewai_cli.command.PlusAPI", MagicMock())
monkeypatch.setattr(deploy_main, "get_project_name", lambda require=False: "demo")
assert deploy_main.DeployCommand()._project() == {"project_id": PROJECT_ID}