-
Notifications
You must be signed in to change notification settings - Fork 6
fix(list): list a namespace's commands the same way everywhere #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
bfa9ac8
eac88b2
b4c3ac5
2315faa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| package tests | ||
|
|
||
| import ( | ||
| "os" | ||
| "path/filepath" | ||
| "regexp" | ||
| "slices" | ||
| "sort" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
| "gopkg.in/yaml.v3" | ||
| ) | ||
|
|
||
| var commandNamePattern = regexp.MustCompile(`(?m)^\s{2}(project:[a-z0-9:-]+)`) | ||
|
|
||
| // commandNames returns the command names listed in a command listing. | ||
| func commandNames(listing string) []string { | ||
| matches := commandNamePattern.FindAllStringSubmatch(listing, -1) | ||
| names := make([]string, 0, len(matches)) | ||
| for _, m := range matches { | ||
| names = append(names, m[1]) | ||
| } | ||
| sort.Strings(names) | ||
| return names | ||
| } | ||
|
|
||
| // TestListNamespace checks that naming a namespace lists the same commands as | ||
| // "list <namespace>". Symfony lists the namespace itself when the name is not a | ||
| // command, but it builds its own descriptor, which describes lazily-loaded | ||
| // commands without resolving them: hidden and disabled commands were listed, | ||
| // and the formatting differed from the rest of the CLI. | ||
| func TestListNamespace(t *testing.T) { | ||
| f := newCommandFactory(t, "", "") | ||
|
|
||
| viaList, _, err := f.RunCombinedOutput("list", "project") | ||
| require.NoError(t, err) | ||
|
|
||
| // A namespace is not a command, so the CLI reports a failure, as before. | ||
| viaNamespace, viaNamespaceErr, _ := f.RunCombinedOutput("project") | ||
| // Symfony writes this listing to stderr. | ||
| namespaceListing := viaNamespace + viaNamespaceErr | ||
|
|
||
| // The listing comes from the legacy CLI, which does not know the commands | ||
| // implemented in Go: the list command adds those to its own output. So the | ||
| // namespace listing is the list output minus the Go commands. | ||
| goCommands := []string{"project:init"} | ||
| var expected []string | ||
| for _, name := range commandNames(viaList) { | ||
| if !slices.Contains(goCommands, name) { | ||
| expected = append(expected, name) | ||
| } | ||
| } | ||
| assert.Equal(t, expected, commandNames(namespaceListing), | ||
| "naming a namespace must list the same commands as 'list <namespace>'") | ||
| for _, name := range goCommands { | ||
| assert.Contains(t, commandNames(viaList), name, | ||
| "the list command must still add the commands implemented in Go") | ||
| } | ||
|
|
||
| // Asking for help on a namespace lists it too, rather than reporting an | ||
| // ambiguous command name. Unlike the bare namespace, this was asked for | ||
| // explicitly, so it succeeds and writes to stdout. | ||
| viaHelp, _, err := f.RunCombinedOutput("help", "project") | ||
| require.NoError(t, err) | ||
| assert.Equal(t, commandNames(namespaceListing), commandNames(viaHelp), | ||
| "'help <namespace>' must list the same commands as the namespace itself") | ||
| assert.NotContains(t, viaHelp, "is ambiguous") | ||
|
|
||
| // Hidden commands stay hidden: project:curl, and the deprecated | ||
| // project:variable:* commands, are only listed by 'list --all'. | ||
| for _, listing := range []string{namespaceListing, viaHelp} { | ||
| for _, hidden := range []string{"project:curl", "project:variable:get"} { | ||
| assert.NotContains(t, listing, hidden) | ||
| } | ||
| } | ||
| viaListAll, _, err := f.RunCombinedOutput("list", "project", "--all") | ||
| require.NoError(t, err) | ||
| assert.Contains(t, viaListAll, "project:curl", "--all must still show hidden commands") | ||
|
|
||
| // The listing uses the CLI's own descriptor: aliases in parentheses, not | ||
| // Symfony's "[alias|alias]" form. | ||
| assert.Contains(t, namespaceListing, "(projects, pro)") | ||
| assert.NotContains(t, namespaceListing, "[projects|pro]") | ||
|
|
||
| // An abbreviated namespace resolves as well. It used to be reported as | ||
| // ambiguous, because the hidden project:variable:* commands made | ||
| // "project:variable" count as a second namespace matching "proj". | ||
| viaAbbreviation, viaAbbreviationErr, _ := f.RunCombinedOutput("proj") | ||
| assert.Equal(t, commandNames(namespaceListing), commandNames(viaAbbreviation+viaAbbreviationErr)) | ||
| assert.NotContains(t, viaAbbreviation+viaAbbreviationErr, "is ambiguous") | ||
| } | ||
|
|
||
| // TestCompletionHidesHiddenCommands checks that command name completion does not | ||
| // suggest hidden commands. Symfony skips them, but it read the hidden state from | ||
| // the LazyCommand wrapper, which does not have the one this CLI decides on. | ||
| func TestCompletionHidesHiddenCommands(t *testing.T) { | ||
| f := newCommandFactory(t, "", "") | ||
|
|
||
| // The long options are what the completion scripts send. | ||
| suggestions, _, err := f.RunCombinedOutput("_complete", "--no-interaction", | ||
| "--shell=zsh", "--api-version=1", "--current=1", "--input=platform-test", "--input=") | ||
| require.NoError(t, err) | ||
|
|
||
| // Each suggestion is a name and a description, separated by a tab. | ||
| lines := strings.Split(strings.TrimSpace(suggestions), "\n") | ||
| names := make([]string, 0, len(lines)) | ||
| for _, line := range lines { | ||
| names = append(names, strings.SplitN(line, "\t", 2)[0]) | ||
| } | ||
| require.Greater(t, len(names), 100, "the whole command list should be suggested") | ||
|
|
||
| // Hidden commands: project:curl and its siblings, and the deprecated | ||
| // project:variable:* commands. | ||
| for _, hidden := range []string{"project:curl", "api:curl", "project:variable:get"} { | ||
| assert.NotContains(t, names, hidden, "a hidden command must not be suggested") | ||
| } | ||
| // Ordinary commands are still suggested. | ||
| for _, visible := range []string{"project:list", "environment:list"} { | ||
| assert.Contains(t, names, visible) | ||
| } | ||
| } | ||
|
|
||
| // TestListNamespaceDisabledCommand checks that a command disabled by | ||
| // configuration, as a vendor distribution does, is not listed by any of the | ||
| // listing paths. | ||
| func TestListNamespaceDisabledCommand(t *testing.T) { | ||
| baseConfig, err := os.ReadFile("config.yaml") | ||
| require.NoError(t, err) | ||
|
|
||
| // Disable project:create, the way a vendor configuration does. | ||
| var cnf map[string]any | ||
| require.NoError(t, yaml.Unmarshal(baseConfig, &cnf)) | ||
| application, ok := cnf["application"].(map[string]any) | ||
| require.True(t, ok, "the test config must have an application section") | ||
| application["disabled_commands"] = []string{"project:create"} | ||
|
|
||
| out, err := yaml.Marshal(cnf) | ||
| require.NoError(t, err) | ||
| configPath := filepath.Join(t.TempDir(), "config.yaml") | ||
| require.NoError(t, os.WriteFile(configPath, out, 0o600)) | ||
|
|
||
| f := newCommandFactory(t, "", "") | ||
| // A later CLI_CONFIG_FILE wins over the one testEnv sets. | ||
| f.extraEnv = []string{"CLI_CONFIG_FILE=" + configPath} | ||
|
|
||
| viaList, _, err := f.RunCombinedOutput("list", "project") | ||
| require.NoError(t, err) | ||
| assert.NotContains(t, viaList, "project:create") | ||
|
|
||
| viaNamespace, viaNamespaceErr, _ := f.RunCombinedOutput("project") | ||
| assert.NotContains(t, viaNamespace+viaNamespaceErr, "project:create", | ||
| "a disabled command must not be listed for its namespace") | ||
|
|
||
| viaHelp, _, err := f.RunCombinedOutput("help", "project") | ||
| require.NoError(t, err) | ||
| assert.NotContains(t, viaHelp, "project:create", | ||
| "a disabled command must not be listed by 'help <namespace>'") | ||
|
|
||
| // It is not listed even with --all: it cannot be run at all. | ||
| viaListAll, _, err := f.RunCombinedOutput("list", "project", "--all") | ||
| require.NoError(t, err) | ||
| assert.NotContains(t, viaListAll, "project:create") | ||
|
|
||
| // Sanity check: the same listing without the override does show it. | ||
| f.extraEnv = nil | ||
| plain, _, err := f.RunCombinedOutput("list", "project") | ||
| require.NoError(t, err) | ||
| assert.Contains(t, plain, "project:create") | ||
| assert.True(t, strings.Contains(plain, "project:list")) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| namespace Platformsh\Cli; | ||
|
|
||
| use Doctrine\Common\Cache\CacheProvider; | ||
| use Platformsh\Cli\Command\CommandBase; | ||
| use Platformsh\Cli\Command\HelpCommand; | ||
| use Platformsh\Cli\Command\ListCommand; | ||
| use Platformsh\Cli\Command\WelcomeCommand; | ||
|
|
@@ -21,9 +22,13 @@ | |
| use Symfony\Component\Console\Command\Command as ConsoleCommand; | ||
| use Symfony\Component\Console\Command\CompleteCommand; | ||
| use Symfony\Component\Console\Command\DumpCompletionCommand; | ||
| use Symfony\Component\Console\Command\LazyCommand; | ||
| use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; | ||
| use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; | ||
| use Symfony\Component\Console\Exception\CommandNotFoundException; | ||
| use Symfony\Component\Console\Exception\ExceptionInterface as ConsoleExceptionInterface; | ||
| use Symfony\Component\Console\Input\ArgvInput; | ||
| use Symfony\Component\Console\Input\ArrayInput; | ||
| use Symfony\Component\Console\Input\InputArgument; | ||
| use Symfony\Component\Console\Input\InputDefinition; | ||
| use Symfony\Component\Console\Input\InputInterface; | ||
|
|
@@ -196,6 +201,95 @@ protected function getDefaultCommands(): array | |
| ]; | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| * | ||
| * Resolves lazily-loaded commands, so that callers see each command's own | ||
| * hidden state. A LazyCommand reports the state of the AsCommand attribute | ||
| * it was built from, while this CLI decides on the command itself, from the | ||
| * configured hidden_commands and the command's stability. | ||
| * | ||
| * Without this, the parent lists hidden commands when describing a | ||
| * namespace, suggests them when completing a command name, and counts the | ||
| * namespaces they define. Resolving every command costs about 80ms. | ||
| * | ||
| * @see CommandBase::isHidden() | ||
| */ | ||
| public function all(?string $namespace = null): array | ||
| { | ||
| $commands = parent::all($namespace); | ||
| foreach ($commands as $name => $command) { | ||
| if ($command instanceof LazyCommand) { | ||
| $commands[$name] = $command->getCommand(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Warning — Unwrapping LazyCommand in |
||
| } | ||
| } | ||
|
|
||
| return $commands; | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| * | ||
| * When the command name is a namespace rather than a command, the parent | ||
| * lists the namespace's commands using its own DescriptorHelper, which only | ||
| * knows the default descriptors. Those describe lazily-loaded commands | ||
| * without resolving them, so hidden commands (which this CLI decides on the | ||
| * command itself) and commands disabled by configuration are both listed. | ||
| * | ||
| * Run our own list command for the namespace instead, so that the output | ||
| * matches "list <namespace>". As in the parent, it is written to the error | ||
| * output and the exit code reports that no command was run. | ||
| * | ||
| * @see ListCommand | ||
| * @see \Platformsh\Cli\Console\DescriptorUtils::describeNamespaces() | ||
| */ | ||
| public function doRun(InputInterface $input, OutputInterface $output): int | ||
| { | ||
| if (($namespace = $this->getDescribableNamespace($input)) !== null) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Minor — An explicit --help request exits 1 with empty stdout, breaking scripted help. The namespace check runs before |
||
| $listInput = new ArrayInput(['command' => 'list', 'namespace' => $namespace]); | ||
| $listInput->setInteractive(false); | ||
| $this->get('list')->run( | ||
| $listInput, | ||
| $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output, | ||
| ); | ||
|
|
||
| return 1; | ||
| } | ||
|
|
||
| return parent::doRun($input, $output); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the namespace to describe, if the input names one instead of a command. | ||
| */ | ||
| private function getDescribableNamespace(InputInterface $input): ?string | ||
| { | ||
| if ($input->hasParameterOption(['--version', '-V'], true)) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| // As in the parent method: this makes ArgvInput::getFirstArgument() | ||
| // able to tell an option from an argument. Errors are ignored | ||
| // because the command is not known yet. | ||
| $input->bind($this->getDefinition()); | ||
| } catch (ConsoleExceptionInterface) { | ||
| // Ignored. | ||
| } | ||
|
|
||
| $name = $this->getCommandName($input); | ||
| if ($name === null || $name === '' || $this->has($name)) { | ||
| return null; | ||
| } | ||
|
|
||
| try { | ||
| return $this->findNamespace($name); | ||
| } catch (CommandNotFoundException) { | ||
| // Not a namespace either: let the parent report the error. | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * @inheritdoc | ||
| */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 Minor — The behaviour the change promises to keep is not actually asserted.
The exit status of the bare-namespace run is discarded (
_), here and at line 110 for the abbreviation, and the split between stdout and stderr is erased by concatenating both intonamespaceListing. The PR states that streams and exit codes are unchanged (stderr, non-zero), but nothing in the test would fail ifdoRun()returned 0 or wrote the listing to stdout. Assertingerr != niland thatviaNamespace(stdout) is empty would pin the contract the change claims to preserve.