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 stale_uid(uid: &str) -> Self {
424        Self {
425            message: format!(
426                "UID '{uid}' refers to an element that no longer exists. \
427                 Run 'chrome-cli page snapshot' to refresh."
428            ),
429            code: ExitCode::GeneralError,
430            custom_json: None,
431        }
432    }
433
434    #[must_use]
435    pub fn to_json(&self) -> String {
436        let output = ErrorOutput {
437            error: &self.message,
438            code: self.code as u8,
439        };
440        serde_json::to_string(&output).unwrap_or_else(|_| {
441            format!(
442                r#"{{"error":"{}","code":{}}}"#,
443                self.message, self.code as u8
444            )
445        })
446    }
447
448    pub fn print_json_stderr(&self) {
449        if let Some(ref json) = self.custom_json {
450            eprintln!("{json}");
451        } else {
452            eprintln!("{}", self.to_json());
453        }
454    }
455}
456
457#[derive(Serialize)]
458struct ErrorOutput<'a> {
459    error: &'a str,
460    code: u8,
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn not_implemented_produces_json_with_error_and_code() {
469        let err = AppError::not_implemented("tabs");
470        let json = err.to_json();
471        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
472        assert_eq!(parsed["error"], "tabs: not yet implemented");
473        assert_eq!(parsed["code"], 1);
474    }
475
476    #[test]
477    fn exit_code_display() {
478        assert_eq!(ExitCode::Success.to_string(), "success");
479        assert_eq!(ExitCode::GeneralError.to_string(), "general error");
480        assert_eq!(ExitCode::ConnectionError.to_string(), "connection error");
481    }
482
483    #[test]
484    fn app_error_display() {
485        let err = AppError::not_implemented("connect");
486        assert_eq!(
487            err.to_string(),
488            "general error: connect: not yet implemented"
489        );
490    }
491
492    #[test]
493    fn stale_session_error() {
494        let err = AppError::stale_session();
495        assert!(err.message.contains("stale"));
496        assert!(err.message.contains("chrome-cli connect"));
497        assert!(matches!(err.code, ExitCode::ConnectionError));
498    }
499
500    #[test]
501    fn no_session_error() {
502        let err = AppError::no_session();
503        assert!(err.message.contains("No active session"));
504        assert!(matches!(err.code, ExitCode::ConnectionError));
505    }
506
507    #[test]
508    fn target_not_found_error() {
509        let err = AppError::target_not_found("ABCDEF");
510        assert!(err.message.contains("ABCDEF"));
511        assert!(err.message.contains("tabs list"));
512        assert!(matches!(err.code, ExitCode::TargetError));
513    }
514
515    #[test]
516    fn no_page_targets_error() {
517        let err = AppError::no_page_targets();
518        assert!(err.message.contains("No page targets"));
519        assert!(matches!(err.code, ExitCode::TargetError));
520    }
521
522    #[test]
523    fn last_tab_error() {
524        let err = AppError::last_tab();
525        assert!(err.message.contains("Cannot close the last tab"));
526        assert!(err.message.contains("at least one open tab"));
527        assert!(matches!(err.code, ExitCode::TargetError));
528    }
529
530    #[test]
531    fn navigation_failed_error() {
532        let err = AppError::navigation_failed("net::ERR_NAME_NOT_RESOLVED");
533        assert!(err.message.contains("Navigation failed"));
534        assert!(err.message.contains("ERR_NAME_NOT_RESOLVED"));
535        assert!(matches!(err.code, ExitCode::GeneralError));
536    }
537
538    #[test]
539    fn navigation_timeout_error() {
540        let err = AppError::navigation_timeout(30000, "load");
541        assert!(err.message.contains("timed out"));
542        assert!(err.message.contains("30000ms"));
543        assert!(err.message.contains("load"));
544        assert!(matches!(err.code, ExitCode::TimeoutError));
545    }
546
547    #[test]
548    fn element_not_found_error() {
549        let err = AppError::element_not_found("#missing");
550        assert!(err.message.contains("Element not found"));
551        assert!(err.message.contains("#missing"));
552        assert!(matches!(err.code, ExitCode::GeneralError));
553    }
554
555    #[test]
556    fn evaluation_failed_error() {
557        let err = AppError::evaluation_failed("script threw an exception");
558        assert!(err.message.contains("Text extraction failed"));
559        assert!(err.message.contains("script threw an exception"));
560        assert!(matches!(err.code, ExitCode::GeneralError));
561    }
562
563    #[test]
564    fn snapshot_failed_error() {
565        let err = AppError::snapshot_failed("domain not enabled");
566        assert!(err.message.contains("Accessibility tree capture failed"));
567        assert!(err.message.contains("domain not enabled"));
568        assert!(matches!(err.code, ExitCode::GeneralError));
569    }
570
571    #[test]
572    fn file_write_failed_error() {
573        let err = AppError::file_write_failed("/tmp/out.txt", "permission denied");
574        assert!(err.message.contains("Failed to write snapshot to file"));
575        assert!(err.message.contains("/tmp/out.txt"));
576        assert!(err.message.contains("permission denied"));
577        assert!(matches!(err.code, ExitCode::GeneralError));
578    }
579
580    #[test]
581    fn no_chrome_found_error() {
582        let err = AppError::no_chrome_found();
583        assert!(err.message.contains("No Chrome instance found"));
584        assert!(matches!(err.code, ExitCode::ConnectionError));
585    }
586
587    #[test]
588    fn screenshot_failed_error() {
589        let err = AppError::screenshot_failed("timeout waiting for capture");
590        assert!(err.message.contains("Screenshot capture failed"));
591        assert!(err.message.contains("timeout waiting for capture"));
592        assert!(matches!(err.code, ExitCode::GeneralError));
593    }
594
595    #[test]
596    fn uid_not_found_error() {
597        let err = AppError::uid_not_found("s99");
598        assert!(err.message.contains("s99"));
599        assert!(err.message.contains("page snapshot"));
600        assert!(matches!(err.code, ExitCode::GeneralError));
601    }
602
603    #[test]
604    fn invalid_clip_error() {
605        let err = AppError::invalid_clip("abc");
606        assert!(err.message.contains("Invalid clip format"));
607        assert!(err.message.contains("X,Y,WIDTH,HEIGHT"));
608        assert!(err.message.contains("abc"));
609        assert!(matches!(err.code, ExitCode::GeneralError));
610    }
611
612    #[test]
613    fn no_active_trace_error() {
614        let err = AppError::no_active_trace();
615        assert!(err.message.contains("No active trace"));
616        assert!(err.message.contains("perf record"));
617        assert!(matches!(err.code, ExitCode::GeneralError));
618    }
619
620    #[test]
621    fn unknown_insight_error() {
622        let err = AppError::unknown_insight("BadInsight");
623        assert!(err.message.contains("Unknown insight"));
624        assert!(err.message.contains("BadInsight"));
625        assert!(err.message.contains("DocumentLatency"));
626        assert!(err.message.contains("LCPBreakdown"));
627        assert!(err.message.contains("RenderBlocking"));
628        assert!(err.message.contains("LongTasks"));
629        assert!(matches!(err.code, ExitCode::GeneralError));
630    }
631
632    #[test]
633    fn trace_file_not_found_error() {
634        let err = AppError::trace_file_not_found("/tmp/missing.json");
635        assert!(err.message.contains("Trace file not found"));
636        assert!(err.message.contains("/tmp/missing.json"));
637        assert!(matches!(err.code, ExitCode::GeneralError));
638    }
639
640    #[test]
641    fn trace_parse_failed_error() {
642        let err = AppError::trace_parse_failed("unexpected EOF");
643        assert!(err.message.contains("Failed to parse trace file"));
644        assert!(err.message.contains("unexpected EOF"));
645        assert!(matches!(err.code, ExitCode::GeneralError));
646    }
647
648    #[test]
649    fn trace_timeout_error() {
650        let err = AppError::trace_timeout(30000);
651        assert!(err.message.contains("Trace timed out"));
652        assert!(err.message.contains("30000ms"));
653        assert!(matches!(err.code, ExitCode::TimeoutError));
654    }
655
656    #[test]
657    fn js_execution_failed_error() {
658        let err = AppError::js_execution_failed("ReferenceError: foo is not defined");
659        assert!(err.message.contains("JavaScript execution failed"));
660        assert!(err.message.contains("ReferenceError: foo is not defined"));
661        assert!(matches!(err.code, ExitCode::GeneralError));
662    }
663
664    #[test]
665    fn script_file_not_found_error() {
666        let err = AppError::script_file_not_found("/tmp/missing.js");
667        assert!(err.message.contains("Script file not found"));
668        assert!(err.message.contains("/tmp/missing.js"));
669        assert!(matches!(err.code, ExitCode::GeneralError));
670    }
671
672    #[test]
673    fn script_file_read_failed_error() {
674        let err = AppError::script_file_read_failed("/tmp/bad.js", "permission denied");
675        assert!(err.message.contains("Failed to read script file"));
676        assert!(err.message.contains("/tmp/bad.js"));
677        assert!(err.message.contains("permission denied"));
678        assert!(matches!(err.code, ExitCode::GeneralError));
679    }
680
681    #[test]
682    fn no_dialog_open_error() {
683        let err = AppError::no_dialog_open();
684        assert!(err.message.contains("No dialog is currently open"));
685        assert!(err.message.contains("must be open"));
686        assert!(matches!(err.code, ExitCode::GeneralError));
687    }
688
689    #[test]
690    fn invalid_key_error() {
691        let err = AppError::invalid_key("FooBar");
692        assert!(err.message.contains("Invalid key"));
693        assert!(err.message.contains("FooBar"));
694        assert!(matches!(err.code, ExitCode::GeneralError));
695    }
696
697    #[test]
698    fn duplicate_modifier_error() {
699        let err = AppError::duplicate_modifier("Control");
700        assert!(err.message.contains("Duplicate modifier"));
701        assert!(err.message.contains("Control"));
702        assert!(matches!(err.code, ExitCode::GeneralError));
703    }
704
705    #[test]
706    fn dialog_handle_failed_error() {
707        let err = AppError::dialog_handle_failed("could not dismiss");
708        assert!(err.message.contains("Dialog handling failed"));
709        assert!(err.message.contains("could not dismiss"));
710        assert!(matches!(err.code, ExitCode::ProtocolError));
711    }
712
713    #[test]
714    fn emulation_failed_error() {
715        let err = AppError::emulation_failed("CDP returned error");
716        assert!(err.message.contains("Emulation failed"));
717        assert!(err.message.contains("CDP returned error"));
718        assert!(matches!(err.code, ExitCode::GeneralError));
719    }
720
721    #[test]
722    fn invalid_viewport_error() {
723        let err = AppError::invalid_viewport("badformat");
724        assert!(err.message.contains("Invalid viewport format"));
725        assert!(err.message.contains("WIDTHxHEIGHT"));
726        assert!(err.message.contains("badformat"));
727        assert!(matches!(err.code, ExitCode::GeneralError));
728    }
729
730    #[test]
731    fn invalid_geolocation_error() {
732        let err = AppError::invalid_geolocation("not-a-coord");
733        assert!(err.message.contains("Invalid geolocation format"));
734        assert!(err.message.contains("LAT,LONG"));
735        assert!(err.message.contains("not-a-coord"));
736        assert!(matches!(err.code, ExitCode::GeneralError));
737    }
738
739    #[test]
740    fn no_js_code_error() {
741        let err = AppError::no_js_code();
742        assert!(err.message.contains("No JavaScript code provided"));
743        assert!(err.message.contains("--file"));
744        assert!(err.message.contains("stdin"));
745        assert!(matches!(err.code, ExitCode::GeneralError));
746    }
747
748    #[test]
749    fn file_not_found_error() {
750        let err = AppError::file_not_found("/nonexistent/file.txt");
751        assert!(err.message.contains("File not found"));
752        assert!(err.message.contains("/nonexistent/file.txt"));
753        assert!(matches!(err.code, ExitCode::GeneralError));
754    }
755
756    #[test]
757    fn file_not_readable_error() {
758        let err = AppError::file_not_readable("/tmp/secret.txt");
759        assert!(err.message.contains("File not readable"));
760        assert!(err.message.contains("/tmp/secret.txt"));
761        assert!(matches!(err.code, ExitCode::GeneralError));
762    }
763
764    #[test]
765    fn not_file_input_error() {
766        let err = AppError::not_file_input("s2");
767        assert!(err.message.contains("Element is not a file input"));
768        assert!(err.message.contains("s2"));
769        assert!(matches!(err.code, ExitCode::GeneralError));
770    }
771
772    #[test]
773    fn js_execution_failed_with_json_carries_custom_json() {
774        let custom =
775            r#"{"error":"Error: test","stack":"Error: test\n    at <anonymous>:1:7","code":1}"#;
776        let err = AppError::js_execution_failed_with_json("Error: test", custom.to_string());
777        assert!(err.message.contains("JavaScript execution failed"));
778        assert_eq!(err.custom_json.as_deref(), Some(custom));
779        assert!(matches!(err.code, ExitCode::GeneralError));
780    }
781
782    #[test]
783    fn js_execution_failed_without_json_has_no_custom_json() {
784        let err = AppError::js_execution_failed("Error: test");
785        assert!(err.custom_json.is_none());
786    }
787}