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