Files
coder/cli/create_internal_test.go
T
Nick Vigilante 04b2af25ca fix(cli): offer a None option in the coder create preset prompt (#30068)
When a template has presets but no default preset, `coder create`
prompts for a preset, but the prompt listed only the template's presets.
The only way to skip a preset was `--preset none`, while the dashboard
lists **None** first.

This adds a **None** entry at the top of the prompt.
Selecting it behaves like `--preset none`: the CLI sends no preset ID
and prints `No preset applied.`
If a preset's label is also `None`, the no-preset entry is labeled `None
(no preset)` so both stay selectable.
Default preset auto-selection is unchanged.

> [!NOTE]
> "No preset ID sent" doesn't always mean the build records no preset.
> When a create request has no preset ID, coderd matches the parameter
values against the template's presets (`prebuilds.FindMatchingPresetID`)
and records any exact match, as `--preset none` and the dashboard's None
option already do.

Fixes ENG-3407

<details>
<summary>Validation and decision log</summary>

### Validation

Tested at e5f52ffe4b against Postgres.

- `TestCreate`, `TestCreateWithPreset`, and the new
`TestPresetSelectOptions` pass against Postgres 13.
- Ran a dev server from this branch (`./scripts/develop.sh
--port-offset`) and drove `coder create` through a real PTY against a
template with two presets and no default:
  - The menu lists `None`, `eu-preset: Deploys to EU`, `us-preset`.
  - `None` prints `No preset applied.`, then prompts for parameters.
  - Selecting either preset applies its parameters.
- `--preset none` and the prompt's `None` produce identical
`workspace_builds.template_version_preset_id` values.
With `region=us`, both record the matching `us-preset` because of
server-side preset matching.
- Template with a preset literally named `None`:
- The dashboard shows `None` twice. Each entry has its own internal
value, so both work, but they look identical.
- The first commit on this branch showed `None` twice in the CLI too,
and both entries meant "no preset". That made the `None` preset
unreachable from the prompt, which is a regression from `main`.
- The second commit fixes that: the menu shows `None (no preset)`,
`None`, `us-preset`, and selecting `None` applies the preset
(`region=eu`).

### Decisions

- **Label `None` to match the dashboard.** Switch to `None (no preset)`
only when a preset's label collides, so the common case stays identical
to the dashboard.
- **Out of scope: `--preset None` flag.** The flag matches `none`
case-insensitively before any preset name, so a preset named `None` or
`none` can't be selected by flag. That's unchanged from `main`.
- **Out of scope: default presets.** The dashboard preselects the
default preset but still lets you pick another preset or None. The CLI
applies the default without prompting. That behavior needs a separate
decision.

</details>

---

_Generated by Coder Agents on behalf of @nickvigilante._
2026-09-30 14:18:26 -04:00

68 lines
2.0 KiB
Go

package cli
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/codersdk"
)
func TestPresetSelectOptions(t *testing.T) {
t.Parallel()
eu := codersdk.Preset{ID: uuid.New(), Name: "eu", Description: "Deploys to EU"}
us := codersdk.Preset{ID: uuid.New(), Name: "us"}
namedNone := codersdk.Preset{ID: uuid.New(), Name: "None"}
namedNoneWithDescription := codersdk.Preset{ID: uuid.New(), Name: "None", Description: "Minimal"}
tests := []struct {
name string
presets []codersdk.Preset
wantOptions []string
wantNone string
wantPresets map[string]uuid.UUID
}{
{
name: "NoneFirst",
presets: []codersdk.Preset{eu, us},
wantOptions: []string{"None", "eu: Deploys to EU", "us"},
wantNone: "None",
wantPresets: map[string]uuid.UUID{"eu: Deploys to EU": eu.ID, "us": us.ID},
},
{
// A preset whose label is "None" must stay selectable, so the
// option for applying no preset gets a distinct label.
name: "PresetNamedNone",
presets: []codersdk.Preset{namedNone, us},
wantOptions: []string{"None (no preset)", "None", "us"},
wantNone: "None (no preset)",
wantPresets: map[string]uuid.UUID{"None": namedNone.ID, "us": us.ID},
},
{
name: "PresetNamedNoneWithDescription",
presets: []codersdk.Preset{namedNoneWithDescription},
wantOptions: []string{"None", "None: Minimal"},
wantNone: "None",
wantPresets: map[string]uuid.UUID{"None: Minimal": namedNoneWithDescription.ID},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
options, noneOption, presetMap := presetSelectOptions(tt.presets)
require.Equal(t, tt.wantOptions, options)
require.Equal(t, tt.wantNone, noneOption)
require.NotContains(t, presetMap, noneOption)
require.Len(t, presetMap, len(tt.wantPresets))
for option, id := range tt.wantPresets {
require.Contains(t, presetMap, option)
require.Equal(t, id, presetMap[option].ID)
}
})
}
}