Skip to main content

chrome_cli/
error.rs

1use std::fmt;
2
3use serde::Serialize;
4
5#[repr(u8)]
6#[derive(Debug, Clone, Copy)]
7pub enum ExitCode {
8    Success = 0,
9    GeneralError = 1,
10    ConnectionError = 2,
11    TargetError = 3,
12    TimeoutError = 4,
13    ProtocolError = 5,
14}
15
16impl fmt::Display for ExitCode {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        match self {
19            Self::Success => write!(f, "success"),
20            Self::GeneralError => write!(f, "general error"),
21            Self::ConnectionError => write!(f, "connection error"),
22            Self::TargetError => write!(f, "target error"),
23            Self::TimeoutError => write!(f, "timeout error"),
24            Self::ProtocolError => write!(f, "protocol error"),
25        }
26    }
27}
28
29#[derive(Debug)]
30pub struct AppError {
31    pub message: String,
32    pub code: ExitCode,
33    /// Pre-serialized JSON to emit instead of the default `ErrorOutput`.
34    /// Used by the JS execution path to preserve its richer error schema.
35    pub custom_json: Option<String>,
36}
37
38impl fmt::Display for AppError {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        write!(f, "{}: {}", self.code, self.message)
41    }
42}
43
44impl std::error::Error for AppError {}
45
46impl AppError {
47    #[must_use]
48    pub fn not_implemented(command: &str) -> Self {
49        Self {
50            message: format!("{command}: not yet implemented"),
51            code: ExitCode::GeneralError,
52            custom_json: None,
53        }
54    }
55
56    #[must_use]
57    pub fn stale_session() -> Self {
58        Self {
59            message: "Session is stale: Chrome is not reachable at the stored address. \
60                      Run 'chrome-cli connect' to establish a new connection."
61                .into(),
62            code: ExitCode::ConnectionError,
63            custom_json: None,
64        }
65    }
66
67    #[must_use]
68    pub fn no_session() -> Self {
69        Self {
70            message: "No active session. Run 'chrome-cli connect' or \
71                      'chrome-cli connect --launch' to establish a connection."
72                .into(),
73            code: ExitCode::ConnectionError,
74            custom_json: None,
75        }
76    }
77
78    #[must_use]
79    pub fn target_not_found(tab: &str) -> Self {
80        Self {
81            message: format!(
82                "Tab '{tab}' not found. Run 'chrome-cli tabs list' to see available tabs."
83            ),
84            code: ExitCode::TargetError,
85            custom_json: None,
86        }
87    }
88
89    #[must_use]
90    pub fn no_page_targets() -> Self {
91        Self {
92            message: "No page targets found in Chrome. Open a tab first.".into(),
93            code: ExitCode::TargetError,
94            custom_json: None,
95        }
96    }
97
98    #[must_use]
99    pub fn last_tab() -> Self {
100        Self {
101            message: "Cannot close the last tab. Chrome requires at least one open tab.".into(),
102            code: ExitCode::TargetError,
103            custom_json: None,
104        }
105    }
106
107    #[must_use]
108    pub fn navigation_failed(error_text: &str) -> Self {
109        Self {
110            message: format!("Navigation failed: {error_text}"),
111            code: ExitCode::GeneralError,
112            custom_json: None,
113        }
114    }
115
116    #[must_use]
117    pub fn navigation_timeout(timeout_ms: u64, strategy: &str) -> Self {
118        Self {
119            message: format!("Navigation timed out after {timeout_ms}ms waiting for {strategy}"),
120            code: ExitCode::TimeoutError,
121            custom_json: None,
122        }
123    }
124
125    #[must_use]
126    pub fn element_not_found(selector: &str) -> Self {
127        Self {
128            message: format!("Element not found for selector: {selector}"),
129            code: ExitCode::GeneralError,
130            custom_json: None,
131        }
132    }
133
134    #[must_use]
135    pub fn evaluation_failed(description: &str) -> Self {
136        Self {
137            message: format!("Text extraction failed: {description}"),
138            code: ExitCode::GeneralError,
139            custom_json: None,
140        }
141    }
142
143    #[must_use]
144    pub fn snapshot_failed(description: &str) -> Self {
145        Self {
146            message: format!("Accessibility tree capture failed: {description}"),
147            code: ExitCode::GeneralError,
148            custom_json: None,
149        }
150    }
151
152    #[must_use]
153    pub fn file_write_failed(path: &str, error: &str) -> Self {
154        Self {
155            message: format!("Failed to write snapshot to file: {path}: {error}"),
156            code: ExitCode::GeneralError,
157            custom_json: None,
158        }
159    }
160
161    #[must_use]
162    pub fn screenshot_failed(description: &str) -> Self {
163        Self {
164            message: format!("Screenshot capture failed: {description}"),
165            code: ExitCode::GeneralError,
166            custom_json: None,
167        }
168    }
169
170    #[must_use]
171    pub fn uid_not_found(uid: &str) -> Self {
172        Self {
173            message: format!("UID '{uid}' not found. Run 'chrome-cli page snapshot' first."),
174            code: ExitCode::GeneralError,
175            custom_json: None,
176        }
177    }
178
179    #[must_use]
180    pub fn invalid_clip(input: &str) -> Self {
181        Self {
182            message: format!(
183                "Invalid clip format: expected X,Y,WIDTH,HEIGHT (e.g. 10,20,200,100): {input}"
184            ),
185            code: ExitCode::GeneralError,
186            custom_json: None,
187        }
188    }
189
190    #[must_use]
191    pub fn no_active_trace() -> Self {
192        Self {
193            message: "No active trace. Use 'chrome-cli perf record' to record a trace.".into(),
194            code: ExitCode::GeneralError,
195            custom_json: None,
196        }
197    }
198
199    #[must_use]
200    pub fn unknown_insight(name: &str) -> Self {
201        Self {
202            message: format!(
203                "Unknown insight: '{name}'. Available: DocumentLatency, LCPBreakdown, \
204                 RenderBlocking, LongTasks"
205            ),
206            code: ExitCode::GeneralError,
207            custom_json: None,
208        }
209    }
210
211    #[must_use]
212    pub fn trace_file_not_found(path: &str) -> Self {
213        Self {
214            message: format!("Trace file not found: {path}"),
215            code: ExitCode::GeneralError,
216            custom_json: None,
217        }
218    }
219
220    #[must_use]
221    pub fn trace_parse_failed(error: &str) -> Self {
222        Self {
223            message: format!("Failed to parse trace file: {error}"),
224            code: ExitCode::GeneralError,
225            custom_json: None,
226        }
227    }
228
229    #[must_use]
230    pub fn trace_timeout(timeout_ms: u64) -> Self {
231        Self {
232            message: format!("Trace timed out after {timeout_ms}ms"),
233            code: ExitCode::TimeoutError,
234            custom_json: None,
235        }
236    }
237
238    #[must_use]
239    pub fn js_execution_failed(description: &str) -> Self {
240        Self {
241            message: format!("JavaScript execution failed: {description}"),
242            code: ExitCode::GeneralError,
243            custom_json: None,
244        }
245    }
246
247    #[must_use]
248    pub fn js_execution_failed_with_json(description: &str, json: String) -> Self {
249        Self {
250            message: format!("JavaScript execution failed: {description}"),
251            code: ExitCode::GeneralError,
252            custom_json: Some(json),
253        }
254    }
255
256    #[must_use]
257    pub fn script_file_not_found(path: &str) -> Self {
258        Self {
259            message: format!("Script file not found: {path}"),
260            code: ExitCode::GeneralError,
261            custom_json: None,
262        }
263    }
264
265    #[must_use]
266    pub fn script_file_read_failed(path: &str, error: &str) -> Self {
267        Self {
268            message: format!("Failed to read script file: {path}: {error}"),
269            code: ExitCode::GeneralError,
270            custom_json: None,
271        }
272    }
273
274    #[must_use]
275    pub fn no_js_code() -> Self {
276        Self {
277            message:
278                "No JavaScript code provided. Specify code as argument, --file, or pipe via stdin."
279                    .into(),
280            code: ExitCode::GeneralError,
281            custom_json: None,
282        }
283    }
284
285    #[must_use]
286    pub fn no_dialog_open() -> Self {
287        Self {
288            message: "No dialog is currently open. A dialog must be open before it can be handled."
289                .into(),
290            code: ExitCode::GeneralError,
291            custom_json: None,
292        }
293    }
294
295    #[must_use]
296    pub fn dialog_handle_failed(reason: &str) -> Self {
297        Self {
298            message: format!("Dialog handling failed: {reason}"),
299            code: ExitCode::ProtocolError,
300            custom_json: None,
301        }
302    }
303
304    #[must_use]
305    pub fn no_chrome_found() -> Self {
306        Self {
307            message: "No Chrome instance found. Run 'chrome-cli connect' or \
308                      'chrome-cli connect --launch' to establish a connection."
309                .into(),
310            code: ExitCode::ConnectionError,
311            custom_json: None,
312        }
313    }
314
315    #[must_use]
316    pub fn no_snapshot_state() -> Self {
317        Self {
318            message: "No snapshot state found. Run 'chrome-cli page snapshot' first to assign \
319                      UIDs to interactive elements."
320                .into(),
321            code: ExitCode::GeneralError,
322            custom_json: None,
323        }
324    }
325
326    #[must_use]
327    pub fn element_zero_size(target: &str) -> Self {
328        Self {
329            message: format!(
330                "Element '{target}' has zero-size bounding box and cannot be clicked."
331            ),
332            code: ExitCode::GeneralError,
333            custom_json: None,
334        }
335    }
336
337    #[must_use]
338    pub fn invalid_key(key: &str) -> Self {
339        Self {
340            message: format!("Invalid key: '{key}'"),
341            code: ExitCode::GeneralError,
342            custom_json: None,
343        }
344    }
345
346    #[must_use]
347    pub fn duplicate_modifier(modifier: &str) -> Self {
348        Self {
349            message: format!("Duplicate modifier: '{modifier}'"),
350            code: ExitCode::GeneralError,
351            custom_json: None,
352        }
353    }
354
355    #[must_use]
356    pub fn interaction_failed(action: &str, reason: &str) -> Self {
357        Self {
358            message: format!("Interaction failed ({action}): {reason}"),
359            code: ExitCode::ProtocolError,
360            custom_json: None,
361        }
362    }
363
364    #[must_use]
365    pub fn emulation_failed(description: &str) -> Self {
366        Self {
367            message: format!("Emulation failed: {description}"),
368            code: ExitCode::GeneralError,
369            custom_json: None,
370        }
371    }
372
373    #[must_use]
374    pub fn invalid_viewport(input: &str) -> Self {
375        Self {
376            message: format!(
377                "Invalid viewport format: expected WIDTHxHEIGHT (e.g. 1280x720): {input}"
378            ),
379            code: ExitCode::GeneralError,
380            custom_json: None,
381        }
382    }
383
384    #[must_use]
385    pub fn invalid_geolocation(input: &str) -> Self {
386        Self {
387            message: format!(
388                "Invalid geolocation format: expected LAT,LONG (e.g. 37.7749,-122.4194): {input}"
389            ),
390            code: ExitCode::GeneralError,
391            custom_json: None,
392        }
393    }
394
395    #[must_use]
396    pub fn file_not_found(path: &str) -> Self {
397        Self {
398            message: format!("File not found: {path}"),
399            code: ExitCode::GeneralError,
400            custom_json: None,
401        }
402    }
403
404    #[must_use]
405    pub fn file_not_readable(path: &str) -> Self {
406        Self {
407            message: format!("File not readable: {path}"),
408            code: ExitCode::GeneralError,
409            custom_json: None,
410        }
411    }
412
413    #[must_use]
414    pub fn not_file_input(target: &str) -> Self {
415        Self {
416            message: format!("Element is not a file input: {target}"),
417            code: ExitCode::GeneralError,
418            custom_json: None,
419        }
420    }
421
422    #[must_use]
423    pub fn node_not_found(id: &str) -> Self {
424        Self {
425            message: format!("Node not found: {id}"),
426            code: ExitCode::TargetError,
427            custom_json: None,
428        }
429    }
430
431    #[must_use]
432    pub fn attribute_not_found(name: &str, node_id: &str) -> Self {
433        Self {
434            message: format!("Attribute '{name}' not found on node {node_id}"),
435            code: ExitCode::GeneralError,
436            custom_json: None,
437        }
438    }
439
440    #[must_use]
441    pub fn no_parent() -> Self {
442        Self {
443            message: "Element has no parent (document root)".into(),
444            code: ExitCode::TargetError,
445            custom_json: None,
446        }
447    }
448
449    #[must_use]
450    pub fn stale_uid(uid: &str) -> Self {
451        Self {
452            message: format!(
453                "UID '{uid}' refers to an element that no longer exists. \
454                 Run 'chrome-cli page snapshot' to refresh."
455            ),
456            code: ExitCode::GeneralError,
457            custom_json: None,
458        }
459    }
460
461    #[must_use]
462    pub fn to_json(&self) -> String {
463        let output = ErrorOutput {
464            error: &self.message,
465            code: self.code as u8,
466        };
467        serde_json::to_string(&output).unwrap_or_else(|_| {
468            format!(
469                r#"{{"error":"{}","code":{}}}"#,
470                self.message, self.code as u8
471            )
472        })
473    }
474
475    pub fn print_json_stderr(&self) {
476        if let Some(ref json) = self.custom_json {
477            eprintln!("{json}");
478        } else {
479            eprintln!("{}", self.to_json());
480        }
481    }
482}
483
484#[derive(Serialize)]
485struct ErrorOutput<'a> {
486    error: &'a str,
487    code: u8,
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn not_implemented_produces_json_with_error_and_code() {
496        let err = AppError::not_implemented("tabs");
497        let json = err.to_json();
498        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
499        assert_eq!(parsed["error"], "tabs: not yet implemented");
500        assert_eq!(parsed["code"], 1);
501    }
502
503    #[test]
504    fn exit_code_display() {
505        assert_eq!(ExitCode::Success.to_string(), "success");
506        assert_eq!(ExitCode::GeneralError.to_string(), "general error");
507        assert_eq!(ExitCode::ConnectionError.to_string(), "connection error");
508    }
509
510    #[test]
511    fn app_error_display() {
512        let err = AppError::not_implemented("connect");
513        assert_eq!(
514            err.to_string(),
515            "general error: connect: not yet implemented"
516        );
517    }
518
519    #[test]
520    fn stale_session_error() {
521        let err = AppError::stale_session();
522        assert!(err.message.contains("stale"));
523        assert!(err.message.contains("chrome-cli connect"));
524        assert!(matches!(err.code, ExitCode::ConnectionError));
525    }
526
527    #[test]
528    fn no_session_error() {
529        let err = AppError::no_session();
530        assert!(err.message.contains("No active session"));
531        assert!(matches!(err.code, ExitCode::ConnectionError));
532    }
533
534    #[test]
535    fn target_not_found_error() {
536        let err = AppError::target_not_found("ABCDEF");
537        assert!(err.message.contains("ABCDEF"));
538        assert!(err.message.contains("tabs list"));
539        assert!(matches!(err.code, ExitCode::TargetError));
540    }
541
542    #[test]
543    fn no_page_targets_error() {
544        let err = AppError::no_page_targets();
545        assert!(err.message.contains("No page targets"));
546        assert!(matches!(err.code, ExitCode::TargetError));
547    }
548
549    #[test]
550    fn last_tab_error() {
551        let err = AppError::last_tab();
552        assert!(err.message.contains("Cannot close the last tab"));
553        assert!(err.message.contains("at least one open tab"));
554        assert!(matches!(err.code, ExitCode::TargetError));
555    }
556
557    #[test]
558    fn navigation_failed_error() {
559        let err = AppError::navigation_failed("net::ERR_NAME_NOT_RESOLVED");
560        assert!(err.message.contains("Navigation failed"));
561        assert!(err.message.contains("ERR_NAME_NOT_RESOLVED"));
562        assert!(matches!(err.code, ExitCode::GeneralError));
563    }
564
565    #[test]
566    fn navigation_timeout_error() {
567        let err = AppError::navigation_timeout(30000, "load");
568        assert!(err.message.contains("timed out"));
569        assert!(err.message.contains("30000ms"));
570        assert!(err.message.contains("load"));
571        assert!(matches!(err.code, ExitCode::TimeoutError));
572    }
573
574    #[test]
575    fn element_not_found_error() {
576        let err = AppError::element_not_found("#missing");
577        assert!(err.message.contains("Element not found"));
578        assert!(err.message.contains("#missing"));
579        assert!(matches!(err.code, ExitCode::GeneralError));
580    }
581
582    #[test]
583    fn evaluation_failed_error() {
584        let err = AppError::evaluation_failed("script threw an exception");
585        assert!(err.message.contains("Text extraction failed"));
586        assert!(err.message.contains("script threw an exception"));
587        assert!(matches!(err.code, ExitCode::GeneralError));
588    }
589
590    #[test]
591    fn snapshot_failed_error() {
592        let err = AppError::snapshot_failed("domain not enabled");
593        assert!(err.message.contains("Accessibility tree capture failed"));
594        assert!(err.message.contains("domain not enabled"));
595        assert!(matches!(err.code, ExitCode::GeneralError));
596    }
597
598    #[test]
599    fn file_write_failed_error() {
600        let err = AppError::file_write_failed("/tmp/out.txt", "permission denied");
601        assert!(err.message.contains("Failed to write snapshot to file"));
602        assert!(err.message.contains("/tmp/out.txt"));
603        assert!(err.message.contains("permission denied"));
604        assert!(matches!(err.code, ExitCode::GeneralError));
605    }
606
607    #[test]
608    fn no_chrome_found_error() {
609        let err = AppError::no_chrome_found();
610        assert!(err.message.contains("No Chrome instance found"));
611        assert!(matches!(err.code, ExitCode::ConnectionError));
612    }
613
614    #[test]
615    fn screenshot_failed_error() {
616        let err = AppError::screenshot_failed("timeout waiting for capture");
617        assert!(err.message.contains("Screenshot capture failed"));
618        assert!(err.message.contains("timeout waiting for capture"));
619        assert!(matches!(err.code, ExitCode::GeneralError));
620    }
621
622    #[test]
623    fn uid_not_found_error() {
624        let err = AppError::uid_not_found("s99");
625        assert!(err.message.contains("s99"));
626        assert!(err.message.contains("page snapshot"));
627        assert!(matches!(err.code, ExitCode::GeneralError));
628    }
629
630    #[test]
631    fn invalid_clip_error() {
632        let err = AppError::invalid_clip("abc");
633        assert!(err.message.contains("Invalid clip format"));
634        assert!(err.message.contains("X,Y,WIDTH,HEIGHT"));
635        assert!(err.message.contains("abc"));
636        assert!(matches!(err.code, ExitCode::GeneralError));
637    }
638
639    #[test]
640    fn no_active_trace_error() {
641        let err = AppError::no_active_trace();
642        assert!(err.message.contains("No active trace"));
643        assert!(err.message.contains("perf record"));
644        assert!(matches!(err.code, ExitCode::GeneralError));
645    }
646
647    #[test]
648    fn unknown_insight_error() {
649        let err = AppError::unknown_insight("BadInsight");
650        assert!(err.message.contains("Unknown insight"));
651        assert!(err.message.contains("BadInsight"));
652        assert!(err.message.contains("DocumentLatency"));
653        assert!(err.message.contains("LCPBreakdown"));
654        assert!(err.message.contains("RenderBlocking"));
655        assert!(err.message.contains("LongTasks"));
656        assert!(matches!(err.code, ExitCode::GeneralError));
657    }
658
659    #[test]
660    fn trace_file_not_found_error() {
661        let err = AppError::trace_file_not_found("/tmp/missing.json");
662        assert!(err.message.contains("Trace file not found"));
663        assert!(err.message.contains("/tmp/missing.json"));
664        assert!(matches!(err.code, ExitCode::GeneralError));
665    }
666
667    #[test]
668    fn trace_parse_failed_error() {
669        let err = AppError::trace_parse_failed("unexpected EOF");
670        assert!(err.message.contains("Failed to parse trace file"));
671        assert!(err.message.contains("unexpected EOF"));
672        assert!(matches!(err.code, ExitCode::GeneralError));
673    }
674
675    #[test]
676    fn trace_timeout_error() {
677        let err = AppError::trace_timeout(30000);
678        assert!(err.message.contains("Trace timed out"));
679        assert!(err.message.contains("30000ms"));
680        assert!(matches!(err.code, ExitCode::TimeoutError));
681    }
682
683    #[test]
684    fn js_execution_failed_error() {
685        let err = AppError::js_execution_failed("ReferenceError: foo is not defined");
686        assert!(err.message.contains("JavaScript execution failed"));
687        assert!(err.message.contains("ReferenceError: foo is not defined"));
688        assert!(matches!(err.code, ExitCode::GeneralError));
689    }
690
691    #[test]
692    fn script_file_not_found_error() {
693        let err = AppError::script_file_not_found("/tmp/missing.js");
694        assert!(err.message.contains("Script file not found"));
695        assert!(err.message.contains("/tmp/missing.js"));
696        assert!(matches!(err.code, ExitCode::GeneralError));
697    }
698
699    #[test]
700    fn script_file_read_failed_error() {
701        let err = AppError::script_file_read_failed("/tmp/bad.js", "permission denied");
702        assert!(err.message.contains("Failed to read script file"));
703        assert!(err.message.contains("/tmp/bad.js"));
704        assert!(err.message.contains("permission denied"));
705        assert!(matches!(err.code, ExitCode::GeneralError));
706    }
707
708    #[test]
709    fn no_dialog_open_error() {
710        let err = AppError::no_dialog_open();
711        assert!(err.message.contains("No dialog is currently open"));
712        assert!(err.message.contains("must be open"));
713        assert!(matches!(err.code, ExitCode::GeneralError));
714    }
715
716    #[test]
717    fn invalid_key_error() {
718        let err = AppError::invalid_key("FooBar");
719        assert!(err.message.contains("Invalid key"));
720        assert!(err.message.contains("FooBar"));
721        assert!(matches!(err.code, ExitCode::GeneralError));
722    }
723
724    #[test]
725    fn duplicate_modifier_error() {
726        let err = AppError::duplicate_modifier("Control");
727        assert!(err.message.contains("Duplicate modifier"));
728        assert!(err.message.contains("Control"));
729        assert!(matches!(err.code, ExitCode::GeneralError));
730    }
731
732    #[test]
733    fn dialog_handle_failed_error() {
734        let err = AppError::dialog_handle_failed("could not dismiss");
735        assert!(err.message.contains("Dialog handling failed"));
736        assert!(err.message.contains("could not dismiss"));
737        assert!(matches!(err.code, ExitCode::ProtocolError));
738    }
739
740    #[test]
741    fn emulation_failed_error() {
742        let err = AppError::emulation_failed("CDP returned error");
743        assert!(err.message.contains("Emulation failed"));
744        assert!(err.message.contains("CDP returned error"));
745        assert!(matches!(err.code, ExitCode::GeneralError));
746    }
747
748    #[test]
749    fn invalid_viewport_error() {
750        let err = AppError::invalid_viewport("badformat");
751        assert!(err.message.contains("Invalid viewport format"));
752        assert!(err.message.contains("WIDTHxHEIGHT"));
753        assert!(err.message.contains("badformat"));
754        assert!(matches!(err.code, ExitCode::GeneralError));
755    }
756
757    #[test]
758    fn invalid_geolocation_error() {
759        let err = AppError::invalid_geolocation("not-a-coord");
760        assert!(err.message.contains("Invalid geolocation format"));
761        assert!(err.message.contains("LAT,LONG"));
762        assert!(err.message.contains("not-a-coord"));
763        assert!(matches!(err.code, ExitCode::GeneralError));
764    }
765
766    #[test]
767    fn no_js_code_error() {
768        let err = AppError::no_js_code();
769        assert!(err.message.contains("No JavaScript code provided"));
770        assert!(err.message.contains("--file"));
771        assert!(err.message.contains("stdin"));
772        assert!(matches!(err.code, ExitCode::GeneralError));
773    }
774
775    #[test]
776    fn file_not_found_error() {
777        let err = AppError::file_not_found("/nonexistent/file.txt");
778        assert!(err.message.contains("File not found"));
779        assert!(err.message.contains("/nonexistent/file.txt"));
780        assert!(matches!(err.code, ExitCode::GeneralError));
781    }
782
783    #[test]
784    fn file_not_readable_error() {
785        let err = AppError::file_not_readable("/tmp/secret.txt");
786        assert!(err.message.contains("File not readable"));
787        assert!(err.message.contains("/tmp/secret.txt"));
788        assert!(matches!(err.code, ExitCode::GeneralError));
789    }
790
791    #[test]
792    fn not_file_input_error() {
793        let err = AppError::not_file_input("s2");
794        assert!(err.message.contains("Element is not a file input"));
795        assert!(err.message.contains("s2"));
796        assert!(matches!(err.code, ExitCode::GeneralError));
797    }
798
799    #[test]
800    fn js_execution_failed_with_json_carries_custom_json() {
801        let custom =
802            r#"{"error":"Error: test","stack":"Error: test\n    at <anonymous>:1:7","code":1}"#;
803        let err = AppError::js_execution_failed_with_json("Error: test", custom.to_string());
804        assert!(err.message.contains("JavaScript execution failed"));
805        assert_eq!(err.custom_json.as_deref(), Some(custom));
806        assert!(matches!(err.code, ExitCode::GeneralError));
807    }
808
809    #[test]
810    fn js_execution_failed_without_json_has_no_custom_json() {
811        let err = AppError::js_execution_failed("Error: test");
812        assert!(err.custom_json.is_none());
813    }
814}