Skip to main content

dialog_probe/
dialog_probe.rs

1//! A probe harness for validating the backends on real
2//! hardware. Not part of the crate's API and not shipped
3//! anywhere; it exists so the platform assumptions in the
4//! backends can be checked where they actually run: the
5//! osascript assumptions need a real Mac, and the PowerShell
6//! backend gets the same human check on a real Windows box
7//! (the maintainer's machine dual-boots). The `--case` and
8//! `--interactive` modes drive the crate itself, so they work
9//! on any OS and always exercise whichever backend the crate
10//! probes there.
11//!
12//! Modes:
13//!
14//! - `--scripted` (the default, macOS only): what CI runs unattended. Raw
15//!   osascript probes use `giving up after` so nothing can block:
16//!   they confirm the result shapes the crate's scripts rely on.
17//!   The list, color, and file dialogs are skipped here, because
18//!   `choose from list`, `choose color`, and the `choose file`
19//!   family have no timeout parameter and would hang an
20//!   unattended job.
21//! - `--case text|password|choice|multi|color|open|open-multi|save|folder|error|info|warning|confirm`:
22//!   run exactly one dialog through the crate and print the mapped
23//!   result. CI uses this with a synthetic Escape keypress; humans
24//!   can use it too.
25//! - `--interactive`: the full human checklist through the crate:
26//!   OK, cancel, selection, and hostile text (quotes, commas,
27//!   non-ASCII), printing the expected and actual result per case.
28//!
29//! `--backend kdialog|zenity|osascript|powershell` can precede
30//! any mode to force that backend instead of the probed one, so
31//! a machine with several helpers installed can verify each in
32//! turn.
33//!
34//! Every case prints one structured line: the case name, then the
35//! exit code, stdout, and stderr (raw probes) or the crate's mapped
36//! `Result` (crate cases), so a run log is the validation report.
37
38use std::io::Write;
39use std::path::PathBuf;
40#[cfg(target_os = "macos")]
41use std::process::Command;
42
43use any_dialog::Dialogs;
44
45#[cfg(target_os = "macos")]
46const RAW_TEXT_SCRIPT: &[&str] = &[
47    "on run argv",
48    "set answer to display dialog (item 2 of argv) default answer (item 3 of argv) with title (item 1 of argv) giving up after 3",
49    "return text returned of answer",
50    "end run",
51];
52
53#[cfg(target_os = "macos")]
54const RAW_ALERT_SCRIPT: &[&str] = &[
55    "on run argv",
56    "display alert (item 1 of argv) message (item 2 of argv) giving up after 3",
57    "return \"\"",
58    "end run",
59];
60
61#[cfg(target_os = "macos")]
62const RAW_MESSAGE_SCRIPT: &[&str] = &[
63    "on run argv",
64    "display dialog (item 2 of argv) buttons {\"OK\"} default button \"OK\" with title (item 1 of argv) with icon (item 3 of argv as integer) giving up after 3",
65    "return \"\"",
66    "end run",
67];
68
69// When this dialog gives up, `button returned` comes back empty,
70// so the script prints "no". That is a property of the timeout,
71// not of the crate's script, which has no giving-up clause and
72// can only return through a real click or a cancel.
73#[cfg(target_os = "macos")]
74const RAW_CONFIRM_SCRIPT: &[&str] = &[
75    "on run argv",
76    "set answer to display dialog (item 2 of argv) buttons {\"No\", \"Yes\"} default button \"Yes\" with title (item 1 of argv) giving up after 3",
77    "if button returned of answer is \"Yes\" then return \"yes\"",
78    "return \"no\"",
79    "end run",
80];
81
82fn main() {
83    // --backend <name> forces one backend (through
84    // Dialogs::with_paths with the bare program name, resolved
85    // on PATH), so a machine with several helpers installed can
86    // verify each one in turn: a KDE box can run the checklist
87    // once through kdialog and once through zenity.
88    let mut backend: Option<String> = None;
89    let mut mode: Vec<String> = Vec::new();
90    let mut args = std::env::args().skip(1);
91    while let Some(arg) = args.next() {
92        if arg == "--backend" {
93            backend = args.next();
94        } else {
95            mode.push(arg);
96        }
97    }
98    let dialogs = match backend.as_deref() {
99        None => Dialogs::new(),
100        Some("kdialog") => Dialogs::with_paths(Some(PathBuf::from("kdialog")), None, None, None),
101        Some("zenity") => Dialogs::with_paths(None, Some(PathBuf::from("zenity")), None, None),
102        Some("osascript") => {
103            Dialogs::with_paths(None, None, Some(PathBuf::from("osascript")), None)
104        }
105        Some("powershell") => {
106            Dialogs::with_paths(None, None, None, Some(PathBuf::from("powershell.exe")))
107        }
108        Some(other) => {
109            eprintln!(
110                "unknown backend {other:?}; expected kdialog, zenity, osascript, or powershell"
111            );
112            std::process::exit(2);
113        }
114    };
115    match mode.first().map(String::as_str) {
116        Some("--interactive") => interactive(&dialogs),
117        Some("--case") => {
118            let case = mode.get(1).map(String::as_str).unwrap_or_default();
119            crate_case(&dialogs, case);
120        }
121        _ => scripted(&dialogs),
122    }
123}
124
125fn scripted(dialogs: &Dialogs) {
126    println!("PROBE scripted mode");
127    println!(
128        "CRATE backend available: {} ({:?})",
129        dialogs.available(),
130        dialogs.backend()
131    );
132
133    #[cfg(not(target_os = "macos"))]
134    {
135        println!(
136            "RAW CASES: macOS only (the raw probes drive osascript directly); use --case or --interactive on this OS"
137        );
138    }
139
140    #[cfg(target_os = "macos")]
141    {
142        raw_case(
143            "text (giving up after 3)",
144            RAW_TEXT_SCRIPT,
145            &[
146                "any-dialog probe",
147                "Scripted text probe: this dialog dismisses itself",
148                "prefilled",
149            ],
150        );
151        raw_case(
152            "alert (giving up after 3)",
153            RAW_ALERT_SCRIPT,
154            &[
155                "any-dialog probe",
156                "Scripted alert probe: this alert dismisses itself",
157            ],
158        );
159        // Message and confirm run after text and alert on purpose: the
160        // CI workflow screenshots at fixed times that frame the first
161        // two dialogs, and adding cases must not move them.
162        raw_case(
163            "message (giving up after 3)",
164            RAW_MESSAGE_SCRIPT,
165            &[
166                "any-dialog probe",
167                "Scripted message probe: this dialog dismisses itself",
168                "1",
169            ],
170        );
171        raw_case(
172            "confirm (giving up after 3)",
173            RAW_CONFIRM_SCRIPT,
174            &[
175                "any-dialog probe",
176                "Scripted confirm probe: this dialog dismisses itself",
177            ],
178        );
179        println!(
180            "RAW CASE choice, multi, color, open, save, folder: SKIPPED in scripted mode (the choose commands have no giving up after; covered by --case and --interactive)"
181        );
182    }
183}
184
185/// Run one raw script the same way the crate runs its scripts (one
186/// `-e` per line, values as run-handler arguments) and print what
187/// came back, verbatim.
188#[cfg(target_os = "macos")]
189fn raw_case(name: &str, script: &[&str], data: &[&str]) {
190    let mut args: Vec<String> = Vec::new();
191    for line in script {
192        args.push("-e".to_string());
193        args.push((*line).to_string());
194    }
195    args.extend(data.iter().map(|value| (*value).to_string()));
196
197    match Command::new("osascript").args(&args).output() {
198        Ok(output) => println!(
199            "RAW CASE {name}: exit={:?} stdout={:?} stderr={:?}",
200            output.status.code(),
201            String::from_utf8_lossy(&output.stdout),
202            String::from_utf8_lossy(&output.stderr),
203        ),
204        Err(error) => println!("RAW CASE {name}: could not run osascript: {error}"),
205    }
206    let _ = std::io::stdout().flush();
207}
208
209fn crate_case(dialogs: &Dialogs, case: &str) {
210    println!("CASE backend: {:?}", dialogs.backend());
211    match case {
212        "text" => {
213            let result = dialogs.prompt_text(
214                "any-dialog probe",
215                "Crate text probe: press Escape or click Cancel",
216                Some("prefilled"),
217            );
218            println!("CASE text => {result:?}");
219        }
220        "choice" => {
221            let result = dialogs.prompt_choice(
222                "any-dialog probe",
223                "Crate choice probe: pick one, or cancel",
224                &["alpha", "beta", "gamma"],
225                Some("beta"),
226            );
227            println!("CASE choice => {result:?}");
228        }
229        "password" => {
230            let result = dialogs.prompt_password(
231                "any-dialog probe",
232                "Crate password probe: type something, or cancel",
233            );
234            println!("CASE password => {result:?}");
235        }
236        "multi" => {
237            let result = dialogs.prompt_choices(
238                "any-dialog probe",
239                "Crate multi probe: check any, or cancel",
240                &["alpha", "beta", "gamma"],
241                &["beta"],
242            );
243            println!("CASE multi => {result:?}");
244        }
245        "color" => {
246            let result = dialogs.prompt_color("any-dialog probe", Some("#3366cc"));
247            println!("CASE color => {result:?}");
248        }
249        "open" => {
250            let result = dialogs.prompt_open_file("any-dialog probe", None, &[]);
251            println!("CASE open => {result:?}");
252        }
253        "open-multi" => {
254            let result = dialogs.prompt_open_files("any-dialog probe", None, &[]);
255            println!("CASE open-multi => {result:?}");
256        }
257        "save" => {
258            let result = dialogs.prompt_save_file("any-dialog probe", None, &[]);
259            println!("CASE save => {result:?}");
260        }
261        "folder" => {
262            let result = dialogs.prompt_folder("any-dialog probe", None);
263            println!("CASE folder => {result:?}");
264        }
265        "error" => {
266            let result =
267                dialogs.show_error("any-dialog probe", "Crate error probe: dismiss this alert");
268            println!("CASE error => {result:?}");
269        }
270        "info" => {
271            let result =
272                dialogs.show_info("any-dialog probe", "Crate info probe: dismiss this message");
273            println!("CASE info => {result:?}");
274        }
275        "warning" => {
276            let result = dialogs.show_warning(
277                "any-dialog probe",
278                "Crate warning probe: dismiss this message",
279            );
280            println!("CASE warning => {result:?}");
281        }
282        "confirm" => {
283            let result = dialogs.confirm(
284                "any-dialog probe",
285                "Crate confirm probe: answer, or dismiss",
286            );
287            println!("CASE confirm => {result:?}");
288        }
289        other => println!(
290            "CASE {other}: unknown case (want text, password, choice, multi, color, open, open-multi, save, folder, error, info, warning, or confirm)"
291        ),
292    }
293    let _ = std::io::stdout().flush();
294}
295
296fn interactive(dialogs: &Dialogs) {
297    println!("INTERACTIVE PROBE: work through the dialogs as they appear.");
298    println!(
299        "backend available: {} ({:?})",
300        dialogs.available(),
301        dialogs.backend()
302    );
303
304    step("1/24 text, OK path: type anything, then click OK");
305    let result = dialogs.prompt_text(
306        "any-dialog probe",
307        "Step 1: type anything and click OK",
308        Some("prefilled"),
309    );
310    println!("CASE text-ok => {result:?} (want Ok(Some(your text)))");
311
312    step("2/24 text, cancel path: click Cancel");
313    let result = dialogs.prompt_text("any-dialog probe", "Step 2: click Cancel", None);
314    println!("CASE text-cancel => {result:?} (want Ok(None))");
315
316    step("3/24 hostile text: leave the text as is, click OK");
317    let result = dialogs.prompt_text(
318        "Probe \"quotes\", commas",
319        "Step 3: quotes \"here\", commas, and ünicode in label and text",
320        Some("a\"b,c—d"),
321    );
322    println!("CASE text-hostile => {result:?} (want Ok(Some(\"a\\\"b,c—d\")))");
323
324    step("4/24 choice, pick gamma");
325    let result = dialogs.prompt_choice(
326        "any-dialog probe",
327        "Step 4: pick gamma and click OK",
328        &["alpha", "beta", "gamma"],
329        Some("beta"),
330    );
331    println!("CASE choice-pick => {result:?} (want Ok(Some(\"gamma\")))");
332
333    step("5/24 choice, cancel path: click Cancel");
334    let result = dialogs.prompt_choice(
335        "any-dialog probe",
336        "Step 5: click Cancel",
337        &["alpha", "beta", "gamma"],
338        None,
339    );
340    println!("CASE choice-cancel => {result:?} (want Ok(None))");
341
342    step("6/24 error alert: dismiss it");
343    let result = dialogs.show_error("any-dialog probe", "Step 6: dismiss this alert");
344    println!("CASE error => {result:?} (want Ok(()))");
345
346    step("7/24 info message: dismiss it");
347    let result = dialogs.show_info("any-dialog probe", "Step 7: dismiss this message");
348    println!("CASE info => {result:?} (want Ok(()))");
349
350    step("8/24 warning message: dismiss it");
351    let result = dialogs.show_warning("any-dialog probe", "Step 8: dismiss this message");
352    println!("CASE warning => {result:?} (want Ok(()))");
353
354    step("9/24 confirm, yes path: click Yes");
355    let result = dialogs.confirm("any-dialog probe", "Step 9: click Yes");
356    println!("CASE confirm-yes => {result:?} (want Ok(true))");
357
358    step("10/24 confirm, no path: click No");
359    let result = dialogs.confirm("any-dialog probe", "Step 10: click No");
360    println!("CASE confirm-no => {result:?} (want Ok(false))");
361
362    step("11/24 password, OK path: type a throwaway password, click OK");
363    let result = dialogs.prompt_password(
364        "any-dialog probe",
365        "Step 11: the typed text should be masked; type anything",
366    );
367    println!("CASE password-ok => {result:?} (want Ok(Some(your text)))");
368
369    step("12/24 password, cancel path: click Cancel");
370    let result = dialogs.prompt_password("any-dialog probe", "Step 12: click Cancel");
371    println!("CASE password-cancel => {result:?} (want Ok(None))");
372
373    step("13/24 multi, pick path: check alpha and gamma, uncheck beta, click OK");
374    let result = dialogs.prompt_choices(
375        "any-dialog probe",
376        "Step 13: check alpha and gamma only",
377        &["alpha", "beta", "gamma"],
378        &["beta"],
379    );
380    println!("CASE multi-pick => {result:?} (want Ok(Some([\"alpha\", \"gamma\"])))");
381
382    step("14/24 multi, cancel path: click Cancel");
383    let result = dialogs.prompt_choices(
384        "any-dialog probe",
385        "Step 14: click Cancel",
386        &["alpha", "beta", "gamma"],
387        &[],
388    );
389    println!("CASE multi-cancel => {result:?} (want Ok(None))");
390
391    step("15/24 color, OK path: pick any color, click OK");
392    let result = dialogs.prompt_color("any-dialog probe", Some("#3366cc"));
393    println!("CASE color-ok => {result:?} (want Ok(Some(\"#......\")))");
394
395    step("16/24 color, cancel path: click Cancel");
396    let result = dialogs.prompt_color("any-dialog probe", None);
397    println!("CASE color-cancel => {result:?} (want Ok(None))");
398
399    step("17/24 open file, OK path: pick any file");
400    let result = dialogs.prompt_open_file("any-dialog probe", None, &[]);
401    println!("CASE open-ok => {result:?} (want Ok(Some(path)))");
402
403    step("18/24 open file, cancel path: click Cancel");
404    let result = dialogs.prompt_open_file("any-dialog probe", None, &[]);
405    println!("CASE open-cancel => {result:?} (want Ok(None))");
406
407    step("19/24 open files, OK path: pick two or more files");
408    let result = dialogs.prompt_open_files("any-dialog probe", None, &[]);
409    println!("CASE open-multi-ok => {result:?} (want Ok(Some([paths])))");
410
411    step("20/24 open files, cancel path: click Cancel");
412    let result = dialogs.prompt_open_files("any-dialog probe", None, &[]);
413    println!("CASE open-multi-cancel => {result:?} (want Ok(None))");
414
415    step("21/24 save file, OK path: name the file and save");
416    let result = dialogs.prompt_save_file(
417        "any-dialog probe",
418        Some(std::path::Path::new("probe-output.txt")),
419        &[],
420    );
421    println!("CASE save-ok => {result:?} (want Ok(Some(path ending probe-output.txt)))");
422
423    step("22/24 save file, cancel path: click Cancel");
424    let result = dialogs.prompt_save_file("any-dialog probe", None, &[]);
425    println!("CASE save-cancel => {result:?} (want Ok(None))");
426
427    step("23/24 folder, OK path: pick any folder");
428    let result = dialogs.prompt_folder("any-dialog probe", None);
429    println!("CASE folder-ok => {result:?} (want Ok(Some(path)))");
430
431    step("24/24 folder, cancel path: click Cancel");
432    let result = dialogs.prompt_folder("any-dialog probe", None);
433    println!("CASE folder-cancel => {result:?} (want Ok(None))");
434
435    println!("INTERACTIVE PROBE done. Save this output with your OS and version.");
436}
437
438fn step(text: &str) {
439    println!("STEP {text}");
440    let _ = std::io::stdout().flush();
441}