Skip to content

Commit 448c9f3

Browse files
Anilreddy2309claude
andcommitted
Add test coverage for the nflow CLI (list-stages, run, run-all, validate, stage-info)
nvflow/cli/main.py had zero dedicated test coverage -- tests/test_cli_cmd.py tests a different module (nvflow/lib/cli_cmd.py, a shell-command builder) with a similarly-named file, which is easy to mistake for CLI coverage. Add tests/test_cli_main.py using typer.testing.CliRunner, exercised against the real auto-discovered stage registry via the `example` recipe (which needs none of the heavy nemo-skills/torch stack, so it registers even in the CI-lightweight test environment). Covers the happy path and the main error paths for each command: unknown recipe/workflow/stage, invalid stage-info path formats, and missing config files. Add `typer` to tests/requirements-ci.txt so this file is actually collected and run in CI rather than silently needing typer at collection time with nothing to provide it. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Anil Balireddy <anilbalireddi@gmail.com>
1 parent 9f34b6b commit 448c9f3

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

tests/requirements-ci.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ rich
1616
orjson
1717
pandas
1818
pyarrow
19+
typer

tests/test_cli_main.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
#
15+
"""Tests for the ``nflow`` CLI (``nvflow.cli.main``).
16+
17+
Exercises the Typer app end-to-end via ``CliRunner`` against the real,
18+
auto-discovered stage registry (the lightweight ``example`` recipe requires
19+
none of the heavy ``nemo-skills``/``torch`` stack, so it registers even in
20+
the CI-lightweight test environment -- see ``tests/requirements-ci.txt``).
21+
"""
22+
23+
from __future__ import annotations
24+
25+
from pathlib import Path
26+
27+
import pytest
28+
from typer.testing import CliRunner
29+
30+
from nvflow.cli.main import app
31+
32+
pytest.importorskip("typer")
33+
34+
runner = CliRunner()
35+
36+
REPO_ROOT = Path(__file__).parent.parent
37+
EXAMPLE_CONFIG = REPO_ROOT / "nvflow/recipes/example/workflows/sdg_simple.yaml"
38+
39+
40+
class TestVersion:
41+
def test_version_prints_version_string(self) -> None:
42+
result = runner.invoke(app, ["version"])
43+
assert result.exit_code == 0
44+
assert "NVFlow version" in result.stdout
45+
46+
47+
class TestListStages:
48+
def test_no_args_lists_all_recipes(self) -> None:
49+
result = runner.invoke(app, ["list-stages"])
50+
assert result.exit_code == 0
51+
assert "example:" in result.stdout
52+
assert "generate_answer" in result.stdout
53+
54+
def test_filter_by_known_recipe(self) -> None:
55+
result = runner.invoke(app, ["list-stages", "--recipe", "example"])
56+
assert result.exit_code == 0
57+
assert "example" in result.stdout
58+
assert "generate_answer" in result.stdout
59+
60+
def test_filter_by_unknown_recipe_errors(self) -> None:
61+
result = runner.invoke(app, ["list-stages", "--recipe", "nonexistent"])
62+
assert result.exit_code == 1
63+
assert "not found" in result.stdout
64+
65+
def test_filter_by_unknown_workflow_reports_no_stages(self) -> None:
66+
result = runner.invoke(app, ["list-stages", "--workflow", "nonexistent_workflow"])
67+
assert result.exit_code == 0
68+
assert "No stages found" in result.stdout
69+
70+
def test_from_config_file(self) -> None:
71+
result = runner.invoke(app, ["list-stages", "--config", str(EXAMPLE_CONFIG)])
72+
assert result.exit_code == 0
73+
assert "generate_answer" in result.stdout
74+
assert "Total: 1 stages" in result.stdout
75+
76+
def test_from_nonexistent_config_file_errors(self) -> None:
77+
result = runner.invoke(app, ["list-stages", "--config", "does_not_exist.yaml"])
78+
assert result.exit_code == 1
79+
assert "Error loading config" in result.stdout
80+
81+
82+
class TestStageInfo:
83+
def test_full_path(self) -> None:
84+
result = runner.invoke(app, ["stage-info", "example.sdg_simple.generate_answer"])
85+
assert result.exit_code == 0
86+
assert "GenerateAnswerStage" in result.stdout
87+
88+
def test_short_name_with_recipe_and_workflow(self) -> None:
89+
result = runner.invoke(
90+
app,
91+
["stage-info", "generate_answer", "--recipe", "example", "--workflow", "sdg_simple"],
92+
)
93+
assert result.exit_code == 0
94+
assert "GenerateAnswerStage" in result.stdout
95+
96+
def test_short_name_without_recipe_and_workflow_errors(self) -> None:
97+
result = runner.invoke(app, ["stage-info", "generate_answer"])
98+
assert result.exit_code == 1
99+
assert "must provide --recipe and --workflow" in result.stdout
100+
101+
def test_invalid_path_format_errors(self) -> None:
102+
result = runner.invoke(app, ["stage-info", "a.b.c.d"])
103+
assert result.exit_code == 1
104+
assert "Invalid stage path" in result.stdout
105+
106+
def test_unknown_stage_errors(self) -> None:
107+
result = runner.invoke(app, ["stage-info", "example.sdg_simple.nonexistent_stage"])
108+
assert result.exit_code == 1
109+
assert "not found" in result.stdout
110+
111+
112+
class TestValidate:
113+
def test_valid_config(self) -> None:
114+
result = runner.invoke(app, ["validate", "--config", str(EXAMPLE_CONFIG)])
115+
assert result.exit_code == 0
116+
assert "Configuration is valid" in result.stdout
117+
assert "Recipe: example" in result.stdout
118+
119+
def test_missing_config_file_errors(self) -> None:
120+
result = runner.invoke(app, ["validate", "--config", "does_not_exist.yaml"])
121+
assert result.exit_code == 1
122+
assert "Configuration is invalid" in result.stdout
123+
124+
def test_requires_config_option(self) -> None:
125+
result = runner.invoke(app, ["validate"])
126+
assert result.exit_code != 0
127+
128+
129+
class TestRun:
130+
def test_missing_config_file_errors(self) -> None:
131+
result = runner.invoke(app, ["run", "generate_answer", "--config", "does_not_exist.yaml"])
132+
assert result.exit_code == 1
133+
assert "Error:" in result.stdout
134+
135+
def test_requires_config_option(self) -> None:
136+
result = runner.invoke(app, ["run", "generate_answer"])
137+
assert result.exit_code != 0
138+
139+
140+
class TestRunAll:
141+
def test_missing_config_file_errors(self) -> None:
142+
result = runner.invoke(app, ["run-all", "--config", "does_not_exist.yaml"])
143+
assert result.exit_code == 1
144+
assert "Error:" in result.stdout

0 commit comments

Comments
 (0)