1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
//! Commands must work, or fail cleanly, with no terminal attached.
//!
//! kasl runs from scripts, cron jobs and the watch daemon, none of which can
//! answer a prompt. Every command here is spawned as a real process with stdin
//! detached, because that is the only way to reproduce the condition that made
//! `task add --name X` panic on a prompt nobody could see.
#[cfg(test)]
mod tests {
use serial_test::serial;
use std::path::Path;
use std::process::{Command, Stdio};
use tempfile::TempDir;
/// Builds a kasl command bound to a private data directory, with no stdin.
fn kasl_cmd(dir: &Path) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_kasl"));
cmd.env("HOME", dir).env("LOCALAPPDATA", dir).stdin(Stdio::null());
cmd
}
#[serial]
#[test]
fn task_add_with_a_name_needs_no_terminal() {
let dir = TempDir::new().unwrap();
// Regression: the comment and completeness prompts ran even when the
// name was supplied, and unwrapped the "not a terminal" error, so the
// whole command panicked instead of creating the task.
let out = kasl_cmd(dir.path())
.args(["task", "add", "--name", "Scripted task", "--completeness", "30"])
.output()
.unwrap();
assert!(out.status.success(), "task add failed: {}", String::from_utf8_lossy(&out.stderr));
let listed = kasl_cmd(dir.path()).args(["task", "list"]).output().unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(listed.contains("Scripted task"), "task missing from list:\n{listed}");
assert!(listed.contains("30%"), "completeness not applied:\n{listed}");
}
#[serial]
#[test]
fn task_add_from_a_template_needs_no_terminal() {
let dir = TempDir::new().unwrap();
// Regression: `--template` reached dialoguer's prompts unconditionally
// and died with a bare "IO error: not a terminal" - on the very path
// `--from-template` tells scripts to use instead of itself.
kasl_cmd(dir.path())
.args([
"template",
"add",
"--name",
"review",
"--task-name",
"Code review",
"--comment",
"daily",
"--completeness",
"80",
])
.output()
.unwrap();
let out = kasl_cmd(dir.path()).args(["task", "add", "--template", "review"]).output().unwrap();
assert!(
out.status.success(),
"task add --template failed: {}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
let listed = kasl_cmd(dir.path()).args(["task", "list"]).output().unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(
listed.contains("Code review"),
"template task missing from list:
{listed}"
);
assert!(
listed.contains("80%"),
"template completeness not applied:
{listed}"
);
}
#[serial]
#[test]
fn flags_win_over_the_template_they_are_passed_with() {
let dir = TempDir::new().unwrap();
// The docs promised `--template X --name Y` would apply Y. The flags
// were silently dropped instead, so the task came out named after the
// template.
kasl_cmd(dir.path())
.args([
"template",
"add",
"--name",
"review",
"--task-name",
"Code review",
"--comment",
"daily",
"--completeness",
"80",
])
.output()
.unwrap();
let out = kasl_cmd(dir.path())
.args(["task", "add", "--template", "review", "--name", "Review PR 318", "--completeness", "40"])
.output()
.unwrap();
assert!(out.status.success(), "task add failed: {}", String::from_utf8_lossy(&out.stderr));
let listed = kasl_cmd(dir.path()).args(["task", "list"]).output().unwrap();
let listed = String::from_utf8_lossy(&listed.stdout);
assert!(
listed.contains("Review PR 318"),
"--name did not win over the template:
{listed}"
);
assert!(
!listed.contains("Code review"),
"the template name was used despite --name:
{listed}"
);
assert!(
listed.contains("40%"),
"--completeness did not win over the template:
{listed}"
);
// The comment was not overridden, so it still comes from the template.
assert!(
listed.contains("daily"),
"template comment was lost:
{listed}"
);
}
#[serial]
#[test]
fn template_add_without_a_name_fails_instead_of_hanging() {
let dir = TempDir::new().unwrap();
// `template add` had no flags for its fields at all, so creating one
// from a script was impossible: it prompted, unwrapped, and died with
// "IO error: not a terminal" instead of naming the flag to pass.
let out = kasl_cmd(dir.path()).args(["template", "add"]).output().unwrap();
assert!(!out.status.success(), "expected a refusal without a name");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("--name"),
"error should name the missing flag:
{stderr}"
);
assert!(
!stderr.contains("not a terminal"),
"the raw dialoguer error leaked through:
{stderr}"
);
}
#[serial]
#[test]
fn task_add_without_a_name_fails_instead_of_hanging() {
let dir = TempDir::new().unwrap();
let out = kasl_cmd(dir.path()).args(["task", "add"]).output().unwrap();
assert!(!out.status.success(), "expected a refusal without a name");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("--name"), "error should name the missing flag:\n{stderr}");
}
#[serial]
#[test]
fn destructive_commands_refuse_without_yes() {
let dir = TempDir::new().unwrap();
kasl_cmd(dir.path()).args(["task", "add", "--name", "Doomed"]).output().unwrap();
// Without --yes there is a confirmation to answer, and nobody to answer it.
let out = kasl_cmd(dir.path()).args(["task", "remove", "1"]).output().unwrap();
assert!(!out.status.success(), "expected a refusal without --yes");
// With --yes the removal goes through unattended.
let out = kasl_cmd(dir.path()).args(["task", "remove", "1", "--yes"]).output().unwrap();
assert!(out.status.success(), "task remove --yes failed: {}", String::from_utf8_lossy(&out.stderr));
}
#[serial]
#[test]
fn server_connect_refuses_before_touching_the_network() {
let dir = TempDir::new().unwrap();
// The token can only be typed at a prompt, so this command cannot
// finish unattended however complete its arguments are. It has to say
// so straight away: found in a live run, where it contacted the server
// first, announced the version, and only then gave up - a request sent
// on behalf of a run that was never going to succeed.
//
// Port 1 is reserved and never has a listener, so if the check ever
// moves back after the network call this fails with a connection error
// instead of the refusal.
let out = kasl_cmd(dir.path())
.args(["server", "connect", "--url", "http://127.0.0.1:1"])
.output()
.unwrap();
assert!(!out.status.success(), "expected a refusal with no terminal");
let combined = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
assert!(combined.contains("terminal"), "the refusal should name the cause:\n{combined}");
assert!(
!combined.contains("cannot reach"),
"the server must not be contacted by a run that cannot finish:\n{combined}"
);
}
#[serial]
#[test]
fn server_status_and_disconnect_work_unattended() {
let dir = TempDir::new().unwrap();
// Neither reads a secret from the user, so both belong in a script:
// `status` is the natural health check, and `disconnect` has to work
// when a machine is being decommissioned by one.
for args in [vec!["server", "status"], vec!["server", "disconnect"]] {
let out = kasl_cmd(dir.path()).args(&args).output().unwrap();
assert!(
out.status.success(),
"`kasl {}` failed unattended: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
}
#[serial]
#[test]
fn the_queue_commands_are_quiet_when_nothing_is_owed() {
let dir = TempDir::new().unwrap();
// Found in a live run, not by the suite: `flush` asked for the token
// before asking whether anything was owed, so an hourly cron on a
// machine that never connected failed every hour over work that does
// not exist. Nothing owed is nothing to do, connection or no
// connection - and a scheduled job that cries wolf is one nobody reads
// by the time it matters.
for args in [vec!["server", "queue"], vec!["server", "flush"]] {
let out = kasl_cmd(dir.path()).args(&args).output().unwrap();
assert!(
out.status.success(),
"`kasl {}` must succeed with an empty queue: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
}
#[serial]
#[test]
fn read_only_commands_work_unattended() {
let dir = TempDir::new().unwrap();
for args in [
vec!["pauses", "list"],
vec!["task", "list"],
vec!["tag", "list"],
vec!["report"],
vec!["sum"],
vec!["completions", "bash"],
] {
let out = kasl_cmd(dir.path()).args(&args).output().unwrap();
assert!(
out.status.success(),
"`kasl {}` failed unattended: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr)
);
}
}
}