Skip to content

Commit 7735d11

Browse files
fix(tests): restore hidden-subcommand completion coverage (#3492)
1 parent 2746263 commit 7735d11

1 file changed

Lines changed: 101 additions & 282 deletions

File tree

Lines changed: 101 additions & 282 deletions
Original file line numberDiff line numberDiff line change
@@ -1,293 +1,112 @@
11
use crate::common::wt_command;
22
use std::collections::HashSet;
3-
use worktrunk::styling::SUCCESS_SYMBOL;
43

5-
/// Issue found during validation
6-
#[derive(Debug)]
7-
struct Issue {
8-
shell: String,
9-
severity: Severity,
10-
category: Category,
11-
message: String,
12-
}
13-
14-
#[derive(Debug, PartialEq)]
15-
enum Severity {
16-
Error,
17-
Warning,
18-
}
19-
20-
#[derive(Debug)]
21-
enum Category {
22-
HiddenFlag,
23-
Consistency,
24-
}
25-
26-
impl Issue {
27-
fn error(shell: impl Into<String>, category: Category, message: impl Into<String>) -> Self {
28-
Self {
29-
shell: shell.into(),
30-
severity: Severity::Error,
31-
category,
32-
message: message.into(),
33-
}
34-
}
35-
36-
fn warning(shell: impl Into<String>, category: Category, message: impl Into<String>) -> Self {
37-
Self {
38-
shell: shell.into(),
39-
severity: Severity::Warning,
40-
category,
41-
message: message.into(),
42-
}
43-
}
44-
}
45-
46-
/// Validate fish shell completions
47-
fn validate_fish(_content: &str) -> Vec<Issue> {
48-
// No hidden flags to check after removing --internal
49-
Vec::new()
50-
}
51-
52-
/// Validate bash shell completions
53-
fn validate_bash(_content: &str) -> Vec<Issue> {
54-
// No hidden flags to check after removing --internal
55-
Vec::new()
56-
}
57-
58-
/// Validate zsh shell completions
59-
fn validate_zsh(_content: &str) -> Vec<Issue> {
60-
// No hidden flags to check after removing --internal
61-
Vec::new()
62-
}
63-
64-
/// Extract flags from shell completion content
65-
fn extract_flags(content: &str, shell: &str) -> HashSet<String> {
66-
let mut flags = HashSet::new();
67-
68-
match shell {
69-
"fish" => {
70-
// Only from 'complete -c wt' lines, not local variables
71-
for line in content.lines() {
72-
if line.contains("complete -c wt")
73-
&& let Some(captures) = line.split("-l ").nth(1)
74-
&& let Some(flag) = captures.split_whitespace().next()
75-
{
76-
flags.insert(flag.to_string());
77-
}
78-
}
79-
}
80-
"bash" => {
81-
// From opts= lines
82-
for line in content.lines() {
83-
if let Some(opts_start) = line.find("opts=\"") {
84-
// Find the closing quote
85-
let search_from = opts_start + 6;
86-
if let Some(rel_end) = line[search_from..].find('"') {
87-
let opts_end = search_from + rel_end;
88-
let opts_str = &line[search_from..opts_end];
89-
for word in opts_str.split_whitespace() {
90-
if let Some(flag) = word.strip_prefix("--") {
91-
flags.insert(flag.to_string());
92-
}
93-
}
94-
}
95-
}
96-
}
97-
// Also from case statements
98-
for line in content.lines() {
99-
if let Some(stripped) = line
100-
.trim()
101-
.strip_prefix("--")
102-
.and_then(|s| s.strip_suffix(')'))
103-
{
104-
flags.insert(stripped.to_string());
105-
}
106-
}
107-
}
108-
"zsh" => {
109-
// From _arguments lines
110-
for line in content.lines() {
111-
if let Some(start) = line.find("'--")
112-
&& let Some(rest) = line[start + 3..].split(&['[', '='][..]).next()
113-
{
114-
flags.insert(rest.to_string());
115-
}
116-
}
117-
}
118-
_ => {}
119-
}
120-
121-
flags
122-
}
123-
124-
/// Validate cross-shell consistency
125-
fn validate_cross_shell(fish_content: &str, bash_content: &str, zsh_content: &str) -> Vec<Issue> {
126-
let mut issues = Vec::new();
127-
128-
let fish_flags = extract_flags(fish_content, "fish");
129-
let bash_flags = extract_flags(bash_content, "bash");
130-
let zsh_flags = extract_flags(zsh_content, "zsh");
131-
132-
// Flags that should be hidden
133-
let hidden_flags: HashSet<String> = ["internal"].iter().map(|s| s.to_string()).collect();
134-
135-
// Check if hidden flags appear anywhere
136-
for flag in &hidden_flags {
137-
let mut appears_in = Vec::new();
138-
if fish_flags.contains(flag) {
139-
appears_in.push("fish");
140-
}
141-
if bash_flags.contains(flag) {
142-
appears_in.push("bash");
143-
}
144-
if zsh_flags.contains(flag) {
145-
appears_in.push("zsh");
146-
}
147-
148-
if !appears_in.is_empty() {
149-
issues.push(Issue::error(
150-
"cross-shell",
151-
Category::HiddenFlag,
152-
format!(
153-
"Hidden flag --{} appears in: {}",
154-
flag,
155-
appears_in.join(", ")
156-
),
157-
));
158-
}
159-
}
160-
161-
// Check for flags missing from some shells
162-
// (only report if missing from multiple shells - single shell might be intentional)
163-
let all_flags: HashSet<_> = fish_flags
164-
.union(&bash_flags)
165-
.chain(zsh_flags.iter())
166-
.filter(|f| !hidden_flags.contains(*f))
167-
.collect();
168-
169-
for flag in all_flags {
170-
let in_fish = fish_flags.contains(flag);
171-
let in_bash = bash_flags.contains(flag);
172-
let in_zsh = zsh_flags.contains(flag);
173-
174-
let present_count = [in_fish, in_bash, in_zsh].iter().filter(|&&x| x).count();
175-
176-
// Only warn if flag is in exactly one shell (likely a bug)
177-
// If it's in 2 shells, might be intentional
178-
if present_count == 1 {
179-
let mut missing = Vec::new();
180-
if !in_fish {
181-
missing.push("fish");
182-
}
183-
if !in_bash {
184-
missing.push("bash");
185-
}
186-
if !in_zsh {
187-
missing.push("zsh");
188-
}
189-
190-
issues.push(Issue::warning(
191-
"cross-shell",
192-
Category::Consistency,
193-
format!("Flag --{} missing from: {}", flag, missing.join(", ")),
194-
));
195-
}
196-
}
197-
198-
issues
4+
/// Drive the dynamic completion engine for a command-line context and return
5+
/// the set of candidate values it offers.
6+
///
7+
/// A shell requests completions by invoking `COMPLETE=<shell> wt -- <words…>`,
8+
/// where the final (possibly empty) word is the token under the cursor. Worktrunk
9+
/// answers on stdout: one candidate per line, `value<TAB>help` for shells that
10+
/// render descriptions (fish/zsh). We key on `fish` and keep the value before the
11+
/// first tab.
12+
fn completion_candidates(words: &[&str]) -> HashSet<String> {
13+
let mut cmd = wt_command();
14+
cmd.env("COMPLETE", "fish");
15+
cmd.arg("--");
16+
cmd.args(words);
17+
18+
let output = cmd.output().unwrap();
19+
assert!(
20+
output.status.success(),
21+
"completion invocation for {words:?} failed: {}",
22+
String::from_utf8_lossy(&output.stderr)
23+
);
24+
25+
String::from_utf8_lossy(&output.stdout)
26+
.lines()
27+
.filter_map(|line| line.split('\t').next())
28+
.filter(|value| !value.is_empty())
29+
.map(str::to_string)
30+
.collect()
19931
}
20032

33+
/// `hide = true` subcommands must never appear in completions.
34+
///
35+
/// Worktrunk generates completions dynamically (clap_complete's completion
36+
/// engine, driven from `COMPLETE=<shell> wt`), assembled in
37+
/// `src/completion.rs`. Deprecated and internal subcommands are marked
38+
/// `#[command(hide = true)]` so they stay out of `--help` *and* out of
39+
/// completions; a regression that resurfaced one (e.g. an injection layer
40+
/// re-adding it, or a new internal subcommand missing `hide = true`) would leak
41+
/// it to every user's shell.
42+
///
43+
/// Each context also asserts a visible sibling is present. That guard is
44+
/// load-bearing: without it, a broken invocation returning *no* candidates would
45+
/// satisfy every "hidden subcommand absent" check vacuously — the exact failure mode
46+
/// that let this test rot after completions moved from static generation
47+
/// (`wt config shell init`, which no longer emits any flag lines) to the dynamic
48+
/// engine.
49+
///
50+
/// Hidden *flags* are deliberately not checked here: unlike subcommands, the
51+
/// completion assembly (`hide_non_positional_options_for_completion`) surfaces
52+
/// every long flag — hidden ones included — when the user explicitly types `--`,
53+
/// so "hidden flags never appear" is not an invariant the current design holds.
20154
#[test]
202-
fn test_completion_validation() {
203-
// Generate completions
204-
let fish_output = wt_command()
205-
.arg("config")
206-
.arg("shell")
207-
.arg("init")
208-
.arg("fish")
209-
.output()
210-
.unwrap();
211-
212-
let bash_output = wt_command()
213-
.arg("config")
214-
.arg("shell")
215-
.arg("init")
216-
.arg("bash")
217-
.output()
218-
.unwrap();
219-
220-
let zsh_output = wt_command()
221-
.arg("config")
222-
.arg("shell")
223-
.arg("init")
224-
.arg("zsh")
225-
.output()
226-
.unwrap();
227-
228-
assert!(fish_output.status.success());
229-
assert!(bash_output.status.success());
230-
assert!(zsh_output.status.success());
231-
232-
let fish_content = String::from_utf8_lossy(&fish_output.stdout);
233-
let bash_content = String::from_utf8_lossy(&bash_output.stdout);
234-
let zsh_content = String::from_utf8_lossy(&zsh_output.stdout);
235-
236-
// Run all validators
237-
let mut all_issues = Vec::new();
238-
all_issues.extend(validate_fish(&fish_content));
239-
all_issues.extend(validate_bash(&bash_content));
240-
all_issues.extend(validate_zsh(&zsh_content));
241-
all_issues.extend(validate_cross_shell(
242-
&fish_content,
243-
&bash_content,
244-
&zsh_content,
245-
));
246-
247-
// Separate errors and warnings
248-
let errors: Vec<_> = all_issues
249-
.iter()
250-
.filter(|i| i.severity == Severity::Error)
251-
.collect();
252-
let warnings: Vec<_> = all_issues
253-
.iter()
254-
.filter(|i| i.severity == Severity::Warning)
255-
.collect();
256-
257-
// Report issues
258-
if !errors.is_empty() {
259-
eprintln!("\n{}", "=".repeat(80));
260-
eprintln!("COMPLETION VALIDATION ERRORS ({})", errors.len());
261-
eprintln!("{}", "=".repeat(80));
262-
for issue in &errors {
263-
eprintln!(
264-
"❌ [{}] {:?}: {}",
265-
issue.shell, issue.category, issue.message
266-
);
267-
}
268-
}
269-
270-
if !warnings.is_empty() {
271-
eprintln!("\n{}", "=".repeat(80));
272-
eprintln!("COMPLETION VALIDATION WARNINGS ({})", warnings.len());
273-
eprintln!("{}", "=".repeat(80));
274-
for issue in &warnings {
275-
eprintln!(
276-
"⚠️ [{}] {:?}: {}",
277-
issue.shell, issue.category, issue.message
278-
);
279-
}
280-
}
281-
282-
// Fail on errors only
283-
if !errors.is_empty() {
284-
panic!(
285-
"\n{} completion validation error(s) found - see output above",
286-
errors.len()
55+
fn test_hidden_subcommands_excluded_from_completions() {
56+
// `wt <Tab>` — the legacy `select` subcommand is hidden (superseded by the
57+
// picker integrated into `wt switch`).
58+
let top_level = completion_candidates(&["wt", ""]);
59+
assert!(
60+
top_level.contains("switch"),
61+
"expected visible `switch` in top-level completions: {top_level:?}"
62+
);
63+
assert!(
64+
!top_level.contains("select"),
65+
"hidden `select` leaked into `wt` completions: {top_level:?}"
66+
);
67+
68+
// `wt hook <Tab>` — the internal `run-pipeline` runner and the deprecated
69+
// `approvals` alias are hidden; the hook types are offered.
70+
let hook = completion_candidates(&["wt", "hook", ""]);
71+
assert!(
72+
hook.contains("pre-merge"),
73+
"expected visible `pre-merge` in hook completions: {hook:?}"
74+
);
75+
for hidden in ["run-pipeline", "approvals"] {
76+
assert!(
77+
!hook.contains(hidden),
78+
"hidden `hook {hidden}` leaked into completions: {hook:?}"
28779
);
28880
}
28981

290-
if errors.is_empty() && warnings.is_empty() {
291-
println!("{SUCCESS_SYMBOL} All shell completions validated successfully!");
82+
// `wt config shell <Tab>` — the internal `completions` generator (emits the
83+
// package-manager completion registration) is hidden; the interactive setup
84+
// subcommands are offered. `completions` lives here, not directly under
85+
// `wt config`, so it must be probed at this level to actually exercise the
86+
// hide filtering rather than the tree structure.
87+
let config_shell = completion_candidates(&["wt", "config", "shell", ""]);
88+
assert!(
89+
config_shell.contains("install"),
90+
"expected visible `install` in config shell completions: {config_shell:?}"
91+
);
92+
assert!(
93+
!config_shell.contains("completions"),
94+
"hidden `config shell completions` leaked into completions: {config_shell:?}"
95+
);
96+
97+
// `wt config state <Tab>` — the deprecated per-category subcommands (now
98+
// folded into `wt config state cache`) are hidden; `cache` is offered. Like
99+
// `completions` above, these live under `state`, not directly under
100+
// `wt config`, so they must be probed at this level.
101+
let config_state = completion_candidates(&["wt", "config", "state", ""]);
102+
assert!(
103+
config_state.contains("cache"),
104+
"expected visible `cache` in config state completions: {config_state:?}"
105+
);
106+
for hidden in ["previous-branch", "hints", "ci-status"] {
107+
assert!(
108+
!config_state.contains(hidden),
109+
"hidden `config state {hidden}` leaked into completions: {config_state:?}"
110+
);
292111
}
293112
}

0 commit comments

Comments
 (0)