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 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}