1use crate::types::{BeholderStatus, ChunkRef, Event, EventSource, Level, OutputChunk};
48
49#[derive(Debug, Clone)]
59pub struct ToolVersionRange {
60 pub min: Option<&'static str>,
61 pub max: Option<&'static str>,
62}
63
64#[derive(Clone)]
69pub enum BeholderMode {
70 Rewriter {
72 adjust_argv: fn(&mut Vec<String>),
74 },
75 DynamicRewriter {
78 add_args: Vec<String>,
80 },
81 Parser,
83}
84
85#[derive(Debug, Clone, Default)]
89pub enum BeholderSelect {
90 #[default]
92 Auto,
93 Force(String),
99 None,
101}
102
103pub struct AttachResult {
107 pub beholder: Option<Box<dyn Beholder>>,
109 pub status: BeholderStatus,
111 pub argv: Vec<String>,
116}
117
118pub trait BeholderFactory: Send + Sync {
126 fn name(&self) -> &'static str;
127 fn version(&self) -> &'static str;
128 fn matches(&self, resolved_argv: &[String]) -> bool;
134 fn mode(&self) -> BeholderMode;
135 fn create(&self) -> Box<dyn Beholder>;
137 fn tool_version_range(&self) -> Option<ToolVersionRange> { None }
143}
144
145pub trait Beholder: Send {
158 fn name(&self) -> &'static str;
159 fn version(&self) -> &'static str;
160 fn mode(&self) -> BeholderMode;
161 fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event>;
163 fn on_done(&mut self, _run_id: &crate::types::TaskRunId, _offset_ms: u32) -> Vec<Event> {
171 Vec::new()
172 }
173 fn unknown_format_reason(&self) -> Option<&str> { None }
179}
180
181pub struct BeholderRegistry {
188 entries: Vec<Box<dyn BeholderFactory>>,
189}
190
191impl BeholderRegistry {
192 pub fn new() -> Self {
193 Self { entries: Vec::new() }
194 }
195
196 pub fn register(&mut self, factory: Box<dyn BeholderFactory>) {
198 self.entries.push(factory);
199 }
200
201 pub fn attach(
227 &self,
228 raw_cmd: &str,
229 select: &BeholderSelect,
230 verbatim_output: bool,
231 ) -> AttachResult {
232 let mut argv = resolve_argv(raw_cmd);
233
234 if !is_single_simple_command(raw_cmd) && !matches!(select, BeholderSelect::None) {
250 return AttachResult {
251 beholder: None,
252 status: BeholderStatus::declined("auto", "compound-command"),
253 argv,
254 };
255 }
256
257 match select {
258 BeholderSelect::None => AttachResult {
259 beholder: None,
260 status: BeholderStatus::none_explicit(),
261 argv,
262 },
263
264 BeholderSelect::Force(name) => {
265 match self.entries.iter().find(|e| e.name() == name.as_str()) {
266 None => AttachResult {
267 beholder: None,
268 status: BeholderStatus::none_auto(),
269 argv,
270 },
271 Some(factory) => {
272 let would_decline = !factory.matches(&argv);
273 let is_rewriter = matches!(factory.mode(), BeholderMode::Rewriter { .. } | BeholderMode::DynamicRewriter { .. });
274 let base_status = if would_decline {
275 BeholderStatus::forced_against_flags(factory.name(), factory.version())
276 } else if verbatim_output && is_rewriter {
277 BeholderStatus::forced_against_verbatim(
278 factory.name(),
279 factory.version(),
280 )
281 } else {
282 BeholderStatus::forced(factory.name(), factory.version())
283 };
284 let added = rewrite_if_rewriter(factory.mode(), &mut argv);
285 let status = base_status.with_rewrite(added);
286 AttachResult { beholder: Some(factory.create()), status, argv }
287 }
288 }
289 }
290
291 BeholderSelect::Auto => {
292 for factory in &self.entries {
293 if factory.matches(&argv) {
294 let is_rewriter = matches!(factory.mode(), BeholderMode::Rewriter { .. } | BeholderMode::DynamicRewriter { .. });
295 if verbatim_output && is_rewriter {
298 return AttachResult {
299 beholder: None,
300 status: BeholderStatus::declined(factory.name(), "verbatim-output"),
301 argv,
302 };
303 }
304 let added = rewrite_if_rewriter(factory.mode(), &mut argv);
305 let status = BeholderStatus::attached(factory.name(), factory.version())
306 .with_rewrite(added);
307 return AttachResult {
308 beholder: Some(factory.create()),
309 status,
310 argv,
311 };
312 }
313 }
314 AttachResult {
315 beholder: None,
316 status: BeholderStatus::none_auto(),
317 argv,
318 }
319 }
320 }
321 }
322
323 pub fn len(&self) -> usize {
324 self.entries.len()
325 }
326
327 pub fn is_empty(&self) -> bool {
328 self.entries.is_empty()
329 }
330}
331
332impl Default for BeholderRegistry {
333 fn default() -> Self {
334 Self::new()
335 }
336}
337
338fn rewrite_if_rewriter(mode: BeholderMode, argv: &mut Vec<String>) -> Vec<String> {
344 match mode {
345 BeholderMode::Rewriter { adjust_argv } => {
346 let before = argv.clone();
347 adjust_argv(argv);
348 argv.iter().filter(|a| !before.contains(a)).cloned().collect()
349 }
350 BeholderMode::DynamicRewriter { add_args } => {
351 let mut added = Vec::new();
352 for arg in add_args {
353 if !argv.contains(&arg) {
354 argv.push(arg.clone());
355 added.push(arg);
356 }
357 }
358 added
359 }
360 BeholderMode::Parser => Vec::new(),
361 }
362}
363
364pub fn resolve_argv(raw_cmd: &str) -> Vec<String> {
373 let mut argv: Vec<String> = tokenize(raw_cmd);
374 loop {
375 match argv.first().map(String::as_str) {
376 Some("bunx") | Some("npx") => {
377 argv.remove(0);
378 }
379 Some("pnpm") | Some("npm") => {
380 argv.remove(0);
381 if argv.first().map(String::as_str) == Some("exec") {
382 argv.remove(0);
383 }
384 break;
385 }
386 _ => break,
387 }
388 }
389 argv
390}
391
392fn tokenize(s: &str) -> Vec<String> {
395 s.split_ascii_whitespace().map(str::to_owned).collect()
396}
397
398fn is_single_simple_command(raw_cmd: &str) -> bool {
410 !raw_cmd
411 .chars()
412 .any(|c| matches!(c, '|' | '&' | ';' | '<' | '>' | '(' | ')' | '`' | '$' | '\n'))
413}
414
415const CARGO_DIAG_SUBCOMMANDS: &[&str] = &[
419 "check", "build", "test", "clippy", "run", "fix", "doc", "bench", "publish",
420];
421
422pub struct CargoBeholderFactory;
433
434impl BeholderFactory for CargoBeholderFactory {
435 fn name(&self) -> &'static str { "cargo" }
436 fn version(&self) -> &'static str { "1.38" }
437
438 fn matches(&self, argv: &[String]) -> bool {
439 if argv.first().map(String::as_str) != Some("cargo") {
440 return false;
441 }
442 let has_diag_sub = argv.iter().skip(1)
443 .any(|a| CARGO_DIAG_SUBCOMMANDS.contains(&a.as_str()));
444 if !has_diag_sub {
445 return false;
446 }
447 !argv.iter().any(|a| a.starts_with("--message-format"))
449 }
450
451 fn mode(&self) -> BeholderMode {
452 BeholderMode::Rewriter {
453 adjust_argv: |argv| {
454 argv.push("--message-format=json-render-diagnostics".to_owned());
455 },
456 }
457 }
458
459 fn create(&self) -> Box<dyn Beholder> {
460 Box::new(CargoBeholder::default())
461 }
462
463 fn tool_version_range(&self) -> Option<ToolVersionRange> {
464 Some(ToolVersionRange { min: Some("1.38.0"), max: None })
467 }
468}
469
470const FORMAT_PROBE_LIMIT: u8 = 5;
474
475pub struct CargoBeholder {
485 buf: Vec<u8>,
486 json_lines_unrecognized: u8,
489 recognized_line_seen: bool,
492 unknown_format: Option<String>,
495}
496
497impl Default for CargoBeholder {
498 fn default() -> Self {
499 Self {
500 buf: Vec::new(),
501 json_lines_unrecognized: 0,
502 recognized_line_seen: false,
503 unknown_format: None,
504 }
505 }
506}
507
508impl Beholder for CargoBeholder {
509 fn name(&self) -> &'static str { "cargo" }
510 fn version(&self) -> &'static str { "1.38" }
511 fn mode(&self) -> BeholderMode {
512 BeholderMode::Rewriter {
513 adjust_argv: |argv| {
514 argv.push("--message-format=json-render-diagnostics".to_owned());
515 },
516 }
517 }
518
519 fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
520 if self.unknown_format.is_some() {
521 return Vec::new();
522 }
523 self.buf.extend_from_slice(&chunk.bytes);
524 let mut events = Vec::new();
525 while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
526 let line: Vec<u8> = self.buf.drain(..=nl).collect();
527 let line = line.trim_ascii_end();
529 if line.is_empty() || line[0] != b'{' {
530 continue;
531 }
532 let Ok(s) = std::str::from_utf8(line) else { continue };
533
534 match serde_json::from_str::<CargoLine>(s) {
536 Ok(cargo_line) => {
537 self.recognized_line_seen = true;
538 if let Some(ev) = parse_cargo_line_inner(&cargo_line, chunk) {
539 events.push(ev);
540 }
541 }
542 Err(_) => {
543 if !self.recognized_line_seen {
547 self.json_lines_unrecognized =
548 self.json_lines_unrecognized.saturating_add(1);
549 if self.json_lines_unrecognized >= FORMAT_PROBE_LIMIT {
550 self.unknown_format = Some(format!(
551 "no recognizable cargo JSON lines in first {} JSON-object lines",
552 FORMAT_PROBE_LIMIT
553 ));
554 return Vec::new();
555 }
556 }
557 }
558 }
559 }
560 events
561 }
562
563 fn unknown_format_reason(&self) -> Option<&str> {
564 self.unknown_format.as_deref()
565 }
566}
567
568pub fn default_registry() -> BeholderRegistry {
570 let mut r = BeholderRegistry::new();
571 r.register(Box::new(CargoBeholderFactory));
572 r.register(Box::new(TscBeholderFactory));
573 r.register(Box::new(EslintBeholderFactory));
574 r.register(Box::new(BiomeBeholderFactory));
575 r.register(Box::new(VitestBeholderFactory));
576 r.register(Box::new(JestBeholderFactory));
577 r.register(Box::new(BunTestBeholderFactory));
578 r.register(Box::new(PytestBeholderFactory));
579 r.register(Box::new(ViteBuildBeholderFactory));
580 r
581}
582
583pub fn registry_with_user_beholders(user_dir: Option<&std::path::Path>) -> BeholderRegistry {
590 let mut r = default_registry();
591 if let Some(dir) = user_dir {
592 for factory in crate::user_beholders::load_user_beholders(dir) {
593 r.register(factory);
594 }
595 }
596 r
597}
598
599pub struct TscBeholderFactory;
619
620impl BeholderFactory for TscBeholderFactory {
621 fn name(&self) -> &'static str { "tsc" }
622 fn version(&self) -> &'static str { "3.0" }
623
624 fn matches(&self, argv: &[String]) -> bool {
625 if argv.first().map(String::as_str) != Some("tsc") {
626 return false;
627 }
628 if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v" | "--init")) {
630 return false;
631 }
632 !argv.iter().any(|a| a == "--pretty" || a == "--pretty=true")
637 }
638
639 fn mode(&self) -> BeholderMode {
640 BeholderMode::Rewriter {
641 adjust_argv: |argv| {
642 if !argv.iter().any(|a| a == "--pretty=false") {
644 argv.push("--pretty=false".to_owned());
645 }
646 },
647 }
648 }
649
650 fn create(&self) -> Box<dyn Beholder> {
651 Box::new(TscBeholder::default())
652 }
653
654 fn tool_version_range(&self) -> Option<ToolVersionRange> {
655 Some(ToolVersionRange { min: Some("3.0.0"), max: None })
658 }
659}
660
661pub struct TscBeholder {
669 buf: Vec<u8>,
670}
671
672impl Default for TscBeholder {
673 fn default() -> Self {
674 Self { buf: Vec::new() }
675 }
676}
677
678impl Beholder for TscBeholder {
679 fn name(&self) -> &'static str { "tsc" }
680 fn version(&self) -> &'static str { "3.0" }
681 fn mode(&self) -> BeholderMode {
682 BeholderMode::Rewriter {
683 adjust_argv: |argv| {
684 if !argv.iter().any(|a| a == "--pretty=false") {
685 argv.push("--pretty=false".to_owned());
686 }
687 },
688 }
689 }
690
691 fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
692 self.buf.extend_from_slice(&chunk.bytes);
693 let mut events = Vec::new();
694 while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
695 let raw: Vec<u8> = self.buf.drain(..=nl).collect();
696 let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
697 if line.is_empty() { continue }
698 if line.starts_with('[') { continue }
700
701 let source = EventSource::Beholder {
702 name: "tsc".to_owned(),
703 version: "3.0".to_owned(),
704 };
705
706 if let Some(diag) = parse_tsc_diagnostic(line) {
707 let mut fields = serde_json::json!({});
708 fields["error"] = serde_json::json!({ "code": diag.code });
709 fields["file"] = serde_json::json!({
710 "path": diag.file,
711 "line": diag.line,
712 "col": diag.col,
713 });
714 events.push(Event {
715 run_id: chunk.run_id.clone(),
716 seq: 0,
717 offset_ms: chunk.offset_ms,
718 level: diag.level,
719 target: "tsc".to_owned(),
720 msg: diag.msg,
721 fields,
722 anchor: Some(ChunkRef { seq: chunk.seq }),
723 source,
724 });
725 } else if let Some(summary) = parse_tsc_summary(line) {
726 events.push(Event {
727 run_id: chunk.run_id.clone(),
728 seq: 0,
729 offset_ms: chunk.offset_ms,
730 level: if summary.errors == 0 { Level::Info } else { Level::Error },
731 target: "tsc".to_owned(),
732 msg: line.to_owned(),
733 fields: serde_json::json!({ "build": { "errors": summary.errors } }),
734 anchor: Some(ChunkRef { seq: chunk.seq }),
735 source,
736 });
737 }
738 }
739 events
740 }
741}
742
743struct TscDiagnostic {
746 file: String,
747 line: u32,
748 col: u32,
749 level: Level,
750 code: String,
751 msg: String,
752}
753
754struct TscSummary {
755 errors: u32,
756}
757
758fn parse_tsc_diagnostic(line: &str) -> Option<TscDiagnostic> {
761 const MARKERS: &[(&str, Level)] = &[
762 ("): error TS", Level::Error),
763 ("): warning TS", Level::Warn),
764 ("): message TS", Level::Info),
765 ];
766
767 for (marker, level) in MARKERS {
768 let Some(marker_pos) = line.find(marker) else { continue };
769
770 let file_pos_str = &line[..marker_pos];
772 let Some((file, ln, col)) = parse_file_pos(file_pos_str) else { continue };
773
774 let after = &line[marker_pos + marker.len()..];
776 let code_len = after.chars().take_while(|c| c.is_ascii_digit()).count();
777 if code_len == 0 { continue; }
778 let code = format!("TS{}", &after[..code_len]);
779
780 let rest = &after[code_len..];
781 if !rest.starts_with(": ") { continue; }
782 let msg = rest[2..].to_owned();
783
784 return Some(TscDiagnostic { file, line: ln, col, level: *level, code, msg });
785 }
786 None
787}
788
789fn parse_file_pos(s: &str) -> Option<(String, u32, u32)> {
793 let comma = s.rfind(',')?;
794 let col: u32 = s[comma + 1..].parse().ok()?;
795
796 let before_comma = &s[..comma];
797 let open_paren = before_comma.rfind('(')?;
798 let ln: u32 = before_comma[open_paren + 1..].parse().ok()?;
799
800 let file = s[..open_paren].to_owned();
801 if file.is_empty() { return None; }
802
803 Some((file, ln, col))
804}
805
806fn parse_tsc_summary(line: &str) -> Option<TscSummary> {
808 let rest = line.strip_prefix("Found ")?;
809 let count_len = rest.chars().take_while(|c| c.is_ascii_digit()).count();
811 if count_len == 0 { return None; }
812 let errors: u32 = rest[..count_len].parse().ok()?;
813 if !rest[count_len..].starts_with(" error") { return None; }
815 Some(TscSummary { errors })
816}
817
818#[derive(serde::Deserialize)]
821struct CargoLine {
822 reason: String,
823 message: Option<CargoMessage>,
824 success: Option<bool>,
825}
826
827#[derive(serde::Deserialize)]
828struct CargoMessage {
829 message: String,
830 level: String,
831 code: Option<CargoCode>,
832 spans: Vec<CargoSpan>,
833}
834
835#[derive(serde::Deserialize)]
836struct CargoCode {
837 code: String,
838}
839
840#[derive(serde::Deserialize)]
841struct CargoSpan {
842 file_name: String,
843 is_primary: bool,
844 line_start: u32,
845 column_start: u32,
846}
847
848fn parse_cargo_line_inner(line: &CargoLine, chunk: &OutputChunk) -> Option<Event> {
851 let source = EventSource::Beholder {
852 name: "cargo".to_owned(),
853 version: "1.38".to_owned(),
854 };
855
856 match line.reason.as_str() {
857 "compiler-message" => {
858 let msg = line.message.as_ref()?;
859 let level = cargo_level(&msg.level);
860 let primary = msg.spans.iter().find(|s| s.is_primary);
861
862 let mut fields = serde_json::json!({});
863 if let Some(code) = &msg.code {
864 fields["error"] = serde_json::json!({ "code": code.code });
865 }
866 if let Some(span) = primary {
867 fields["file"] = serde_json::json!({
868 "path": span.file_name,
869 "line": span.line_start,
870 "col": span.column_start,
871 });
872 }
873
874 Some(Event {
875 run_id: chunk.run_id.clone(),
876 seq: 0, offset_ms: chunk.offset_ms,
878 level,
879 target: "cargo::rustc".to_owned(),
880 msg: msg.message.clone(),
881 fields,
882 anchor: Some(ChunkRef { seq: chunk.seq }),
883 source,
884 })
885 }
886 "build-finished" => {
887 let ok = line.success.unwrap_or(false);
888 Some(Event {
889 run_id: chunk.run_id.clone(),
890 seq: 0,
891 offset_ms: chunk.offset_ms,
892 level: if ok { Level::Info } else { Level::Error },
893 target: "cargo".to_owned(),
894 msg: if ok { "build finished".to_owned() } else { "build failed".to_owned() },
895 fields: serde_json::json!({ "build": { "success": ok } }),
896 anchor: Some(ChunkRef { seq: chunk.seq }),
897 source,
898 })
899 }
900 _ => None,
901 }
902}
903
904fn cargo_level(s: &str) -> Level {
905 match s {
906 "error" | "failure-note" => Level::Error,
907 "warning" => Level::Warn,
908 "note" | "help" => Level::Info,
909 _ => Level::Debug,
910 }
911}
912
913pub struct EslintBeholderFactory;
928
929impl BeholderFactory for EslintBeholderFactory {
930 fn name(&self) -> &'static str { "eslint" }
931 fn version(&self) -> &'static str { "8.0" }
932
933 fn matches(&self, argv: &[String]) -> bool {
934 if argv.first().map(String::as_str) != Some("eslint") {
935 return false;
936 }
937 if argv.iter().any(|a| matches!(a.as_str(), "--version" | "--env-info" | "--print-config")) {
939 return false;
940 }
941 let format_arg = argv.windows(2)
945 .find(|w| w[0] == "-f" || w[0] == "--format")
946 .map(|w| w[1].as_str());
947 let format_eq = argv.iter()
948 .find(|a| a.starts_with("--format="))
949 .map(|a| a.trim_start_matches("--format="));
950 let explicit_format = format_arg.or(format_eq);
951 matches!(explicit_format, None | Some("json"))
952 }
953
954 fn mode(&self) -> BeholderMode {
955 BeholderMode::Rewriter {
956 adjust_argv: |argv| {
957 if !argv.iter().any(|a| a == "--format=json") {
958 argv.push("--format=json".to_owned());
959 }
960 },
961 }
962 }
963
964 fn create(&self) -> Box<dyn Beholder> {
965 Box::new(EslintBeholder::default())
966 }
967
968 fn tool_version_range(&self) -> Option<ToolVersionRange> {
969 Some(ToolVersionRange { min: Some("8.0.0"), max: None })
972 }
973}
974
975pub struct EslintBeholder {
982 buf: Vec<u8>,
983 unknown_format: Option<String>,
984}
985
986impl Default for EslintBeholder {
987 fn default() -> Self {
988 Self { buf: Vec::new(), unknown_format: None }
989 }
990}
991
992impl Beholder for EslintBeholder {
993 fn name(&self) -> &'static str { "eslint" }
994 fn version(&self) -> &'static str { "8.0" }
995 fn mode(&self) -> BeholderMode {
996 BeholderMode::Rewriter {
997 adjust_argv: |argv| {
998 if !argv.iter().any(|a| a == "--format=json") {
999 argv.push("--format=json".to_owned());
1000 }
1001 },
1002 }
1003 }
1004
1005 fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1006 self.buf.extend_from_slice(&chunk.bytes);
1007 Vec::new()
1008 }
1009
1010 fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1011 if self.buf.is_empty() {
1012 return Vec::new();
1013 }
1014 let Ok(s) = std::str::from_utf8(&self.buf) else {
1015 self.unknown_format = Some("non-UTF-8 output".to_owned());
1016 return Vec::new();
1017 };
1018 let s = s.trim();
1021 match serde_json::from_str::<Vec<EslintFile>>(s) {
1022 Ok(files) => eslint_to_events(files, run_id, offset_ms),
1023 Err(e) => {
1024 self.unknown_format = Some(format!("failed to parse ESLint JSON: {e}"));
1025 Vec::new()
1026 }
1027 }
1028 }
1029
1030 fn unknown_format_reason(&self) -> Option<&str> {
1031 self.unknown_format.as_deref()
1032 }
1033}
1034
1035#[derive(serde::Deserialize)]
1038struct EslintFile {
1039 #[serde(rename = "filePath")]
1040 file_path: String,
1041 messages: Vec<EslintMessage>,
1042}
1043
1044#[derive(serde::Deserialize)]
1045struct EslintMessage {
1046 #[serde(rename = "ruleId")]
1047 rule_id: Option<String>,
1048 severity: u8,
1049 message: String,
1050 line: Option<u32>,
1051 column: Option<u32>,
1052}
1053
1054fn eslint_to_events(
1055 files: Vec<EslintFile>,
1056 run_id: &crate::types::TaskRunId,
1057 offset_ms: u32,
1058) -> Vec<Event> {
1059 let mut events = Vec::new();
1060 for file in files {
1061 for msg in file.messages {
1062 let level = match msg.severity {
1063 2 => Level::Error,
1064 1 => Level::Warn,
1065 _ => Level::Info,
1066 };
1067 let mut fields = serde_json::json!({});
1068 if let Some(ref rule) = msg.rule_id {
1069 fields["error"] = serde_json::json!({ "code": rule });
1070 }
1071 let mut file_fields = serde_json::json!({ "path": file.file_path });
1072 if let Some(l) = msg.line { file_fields["line"] = serde_json::json!(l); }
1073 if let Some(c) = msg.column { file_fields["col"] = serde_json::json!(c); }
1074 fields["file"] = file_fields;
1075
1076 events.push(Event {
1077 run_id: run_id.clone(),
1078 seq: 0,
1079 offset_ms,
1080 level,
1081 target: "eslint".to_owned(),
1082 msg: msg.message,
1083 fields,
1084 anchor: None,
1085 source: EventSource::Beholder {
1086 name: "eslint".to_owned(),
1087 version: "8.0".to_owned(),
1088 },
1089 });
1090 }
1091 }
1092 events
1093}
1094
1095const BIOME_LINT_SUBCOMMANDS: &[&str] = &["check", "lint", "ci"];
1099
1100pub struct BiomeBeholderFactory;
1111
1112impl BeholderFactory for BiomeBeholderFactory {
1113 fn name(&self) -> &'static str { "biome" }
1114 fn version(&self) -> &'static str { "1.0" }
1115
1116 fn matches(&self, argv: &[String]) -> bool {
1117 if argv.first().map(String::as_str) != Some("biome") {
1118 return false;
1119 }
1120 if argv.iter().any(|a| a == "--version") {
1121 return false;
1122 }
1123 let has_lint_sub = argv.iter().skip(1)
1125 .any(|a| BIOME_LINT_SUBCOMMANDS.contains(&a.as_str()));
1126 if !has_lint_sub {
1127 return false;
1128 }
1129 let reporter = argv.iter()
1131 .find(|a| a.starts_with("--reporter="))
1132 .map(|a| a.trim_start_matches("--reporter="));
1133 matches!(reporter, None | Some("json"))
1134 }
1135
1136 fn mode(&self) -> BeholderMode {
1137 BeholderMode::Rewriter {
1138 adjust_argv: |argv| {
1139 if !argv.iter().any(|a| a == "--reporter=json") {
1140 argv.push("--reporter=json".to_owned());
1141 }
1142 },
1143 }
1144 }
1145
1146 fn create(&self) -> Box<dyn Beholder> {
1147 Box::new(BiomeBeholder::default())
1148 }
1149
1150 fn tool_version_range(&self) -> Option<ToolVersionRange> {
1151 Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1153 }
1154}
1155
1156pub struct BiomeBeholder {
1161 buf: Vec<u8>,
1162 unknown_format: Option<String>,
1163}
1164
1165impl Default for BiomeBeholder {
1166 fn default() -> Self {
1167 Self { buf: Vec::new(), unknown_format: None }
1168 }
1169}
1170
1171impl Beholder for BiomeBeholder {
1172 fn name(&self) -> &'static str { "biome" }
1173 fn version(&self) -> &'static str { "1.0" }
1174 fn mode(&self) -> BeholderMode {
1175 BeholderMode::Rewriter {
1176 adjust_argv: |argv| {
1177 if !argv.iter().any(|a| a == "--reporter=json") {
1178 argv.push("--reporter=json".to_owned());
1179 }
1180 },
1181 }
1182 }
1183
1184 fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1185 self.buf.extend_from_slice(&chunk.bytes);
1186 Vec::new()
1187 }
1188
1189 fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1190 if self.buf.is_empty() {
1191 return Vec::new();
1192 }
1193 let Ok(s) = std::str::from_utf8(&self.buf) else {
1194 self.unknown_format = Some("non-UTF-8 output".to_owned());
1195 return Vec::new();
1196 };
1197 let s = s.trim();
1198 match serde_json::from_str::<BiomeOutput>(s) {
1199 Ok(output) => biome_to_events(output.diagnostics, run_id, offset_ms),
1200 Err(e) => {
1201 self.unknown_format = Some(format!("failed to parse Biome JSON: {e}"));
1202 Vec::new()
1203 }
1204 }
1205 }
1206
1207 fn unknown_format_reason(&self) -> Option<&str> {
1208 self.unknown_format.as_deref()
1209 }
1210}
1211
1212#[derive(serde::Deserialize)]
1215struct BiomeOutput {
1216 #[serde(default)]
1217 diagnostics: Vec<BiomeDiagnostic>,
1218}
1219
1220#[derive(serde::Deserialize)]
1221struct BiomeDiagnostic {
1222 category: Option<String>,
1223 severity: String,
1224 description: String,
1225 location: Option<BiomeLocation>,
1226}
1227
1228#[derive(serde::Deserialize)]
1229struct BiomeLocation {
1230 path: Option<BiomePath>,
1231}
1232
1233#[derive(serde::Deserialize)]
1234struct BiomePath {
1235 file: Option<String>,
1236}
1237
1238fn biome_to_events(
1239 diagnostics: Vec<BiomeDiagnostic>,
1240 run_id: &crate::types::TaskRunId,
1241 offset_ms: u32,
1242) -> Vec<Event> {
1243 diagnostics.into_iter().map(|d| {
1244 let level = match d.severity.as_str() {
1245 "fatal" => Level::Fatal,
1246 "error" => Level::Error,
1247 "warning" => Level::Warn,
1248 "information" => Level::Info,
1249 "hint" => Level::Debug,
1250 _ => Level::Warn,
1251 };
1252 let mut fields = serde_json::json!({});
1253 if let Some(ref cat) = d.category {
1254 fields["error"] = serde_json::json!({ "code": cat });
1255 }
1256 if let Some(loc) = d.location {
1257 if let Some(path) = loc.path {
1258 if let Some(file) = path.file {
1259 fields["file"] = serde_json::json!({ "path": file });
1260 }
1261 }
1262 }
1263 Event {
1264 run_id: run_id.clone(),
1265 seq: 0,
1266 offset_ms,
1267 level,
1268 target: "biome".to_owned(),
1269 msg: d.description,
1270 fields,
1271 anchor: None,
1272 source: EventSource::Beholder {
1273 name: "biome".to_owned(),
1274 version: "1.0".to_owned(),
1275 },
1276 }
1277 }).collect()
1278}
1279
1280fn vitest_adjust_argv(argv: &mut Vec<String>) {
1284 if !argv.iter().any(|a| a == "--reporter=json") {
1285 argv.push("--reporter=json".to_owned());
1286 }
1287}
1288
1289fn jest_adjust_argv(argv: &mut Vec<String>) {
1291 if !argv.iter().any(|a| a == "--json") {
1292 argv.push("--json".to_owned());
1293 }
1294}
1295
1296fn bun_test_adjust_argv(argv: &mut Vec<String>) {
1298 if !argv.iter().any(|a| a == "--reporter=json") {
1299 argv.push("--reporter=json".to_owned());
1300 }
1301}
1302
1303pub struct VitestBeholderFactory;
1314
1315impl BeholderFactory for VitestBeholderFactory {
1316 fn name(&self) -> &'static str { "vitest" }
1317 fn version(&self) -> &'static str { "1.0" }
1318
1319 fn matches(&self, argv: &[String]) -> bool {
1320 if argv.first().map(String::as_str) != Some("vitest") {
1321 return false;
1322 }
1323 if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v")) {
1324 return false;
1325 }
1326 let reporter_eq = argv.iter()
1328 .find(|a| a.starts_with("--reporter="))
1329 .map(|a| a.trim_start_matches("--reporter="));
1330 let reporter_space = argv.windows(2)
1331 .find(|w| w[0] == "--reporter")
1332 .map(|w| w[1].as_str());
1333 let explicit = reporter_eq.or(reporter_space);
1334 matches!(explicit, None | Some("json"))
1335 }
1336
1337 fn mode(&self) -> BeholderMode {
1338 BeholderMode::Rewriter { adjust_argv: vitest_adjust_argv }
1339 }
1340
1341 fn create(&self) -> Box<dyn Beholder> {
1342 Box::new(JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv))
1343 }
1344
1345 fn tool_version_range(&self) -> Option<ToolVersionRange> {
1346 Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1347 }
1348}
1349
1350pub struct JestBeholderFactory;
1359
1360impl BeholderFactory for JestBeholderFactory {
1361 fn name(&self) -> &'static str { "jest" }
1362 fn version(&self) -> &'static str { "27.0" }
1363
1364 fn matches(&self, argv: &[String]) -> bool {
1365 if argv.first().map(String::as_str) != Some("jest") {
1366 return false;
1367 }
1368 if argv.iter().any(|a| matches!(a.as_str(), "--version" | "-v")) {
1369 return false;
1370 }
1371 if argv.iter().any(|a| a.starts_with("--outputFile")) {
1373 return false;
1374 }
1375 true
1376 }
1377
1378 fn mode(&self) -> BeholderMode {
1379 BeholderMode::Rewriter { adjust_argv: jest_adjust_argv }
1380 }
1381
1382 fn create(&self) -> Box<dyn Beholder> {
1383 Box::new(JsTestBeholder::new("jest", "27.0", jest_adjust_argv))
1384 }
1385
1386 fn tool_version_range(&self) -> Option<ToolVersionRange> {
1387 Some(ToolVersionRange { min: Some("27.0.0"), max: None })
1388 }
1389}
1390
1391pub struct BunTestBeholderFactory;
1400
1401impl BeholderFactory for BunTestBeholderFactory {
1402 fn name(&self) -> &'static str { "bun-test" }
1403 fn version(&self) -> &'static str { "1.0" }
1404
1405 fn matches(&self, argv: &[String]) -> bool {
1406 if argv.first().map(String::as_str) != Some("bun") {
1407 return false;
1408 }
1409 if argv.get(1).map(String::as_str) != Some("test") {
1410 return false;
1411 }
1412 if argv.iter().any(|a| a == "--version") {
1413 return false;
1414 }
1415 let reporter = argv.iter()
1416 .find(|a| a.starts_with("--reporter="))
1417 .map(|a| a.trim_start_matches("--reporter="));
1418 matches!(reporter, None | Some("json"))
1419 }
1420
1421 fn mode(&self) -> BeholderMode {
1422 BeholderMode::Rewriter { adjust_argv: bun_test_adjust_argv }
1423 }
1424
1425 fn create(&self) -> Box<dyn Beholder> {
1426 Box::new(JsTestBeholder::new("bun-test", "1.0", bun_test_adjust_argv))
1427 }
1428
1429 fn tool_version_range(&self) -> Option<ToolVersionRange> {
1430 Some(ToolVersionRange { min: Some("1.0.0"), max: None })
1431 }
1432}
1433
1434pub struct JsTestBeholder {
1445 tool: &'static str,
1446 version: &'static str,
1447 adjust_argv_fn: fn(&mut Vec<String>),
1448 buf: Vec<u8>,
1449 unknown_format: Option<String>,
1450}
1451
1452impl JsTestBeholder {
1453 fn new(tool: &'static str, version: &'static str, adjust_argv_fn: fn(&mut Vec<String>)) -> Self {
1454 Self { tool, version, adjust_argv_fn, buf: Vec::new(), unknown_format: None }
1455 }
1456}
1457
1458impl Beholder for JsTestBeholder {
1459 fn name(&self) -> &'static str { self.tool }
1460 fn version(&self) -> &'static str { self.version }
1461 fn mode(&self) -> BeholderMode {
1462 BeholderMode::Rewriter { adjust_argv: self.adjust_argv_fn }
1463 }
1464
1465 fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1466 self.buf.extend_from_slice(&chunk.bytes);
1467 Vec::new()
1468 }
1469
1470 fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1471 if self.buf.is_empty() {
1472 return Vec::new();
1473 }
1474 let Ok(s) = std::str::from_utf8(&self.buf) else {
1475 self.unknown_format = Some("non-UTF-8 output".to_owned());
1476 return Vec::new();
1477 };
1478 let s = s.trim();
1479 match serde_json::from_str::<JsTestReport>(s) {
1480 Ok(report) => js_test_to_events(report, run_id, offset_ms, self.tool, self.version),
1481 Err(e) => {
1482 self.unknown_format = Some(format!("failed to parse {} JSON: {e}", self.tool));
1483 Vec::new()
1484 }
1485 }
1486 }
1487
1488 fn unknown_format_reason(&self) -> Option<&str> {
1489 self.unknown_format.as_deref()
1490 }
1491}
1492
1493#[derive(serde::Deserialize)]
1496struct JsTestReport {
1497 #[serde(default)]
1498 success: bool,
1499 #[serde(rename = "numTotalTests", default)]
1500 num_total: u32,
1501 #[serde(rename = "numPassedTests", default)]
1502 num_passed: u32,
1503 #[serde(rename = "numFailedTests", default)]
1504 num_failed: u32,
1505 #[serde(rename = "numPendingTests", default)]
1506 num_pending: u32,
1507 #[serde(rename = "testResults", default)]
1508 test_results: Vec<JsTestSuiteResult>,
1509}
1510
1511#[derive(serde::Deserialize)]
1512struct JsTestSuiteResult {
1513 #[serde(rename = "testFilePath")]
1514 file_path: String,
1515 #[serde(rename = "assertionResults", default)]
1516 assertions: Vec<JsTestAssertionResult>,
1517}
1518
1519#[derive(serde::Deserialize)]
1520struct JsTestAssertionResult {
1521 #[serde(rename = "fullName", default)]
1522 full_name: String,
1523 status: String,
1524 #[serde(rename = "failureMessages", default)]
1525 failure_messages: Vec<String>,
1526}
1527
1528fn js_test_to_events(
1529 report: JsTestReport,
1530 run_id: &crate::types::TaskRunId,
1531 offset_ms: u32,
1532 tool: &'static str,
1533 version: &'static str,
1534) -> Vec<Event> {
1535 let mut events = Vec::new();
1536
1537 let summary_msg = if report.success {
1539 format!("{} passed", report.num_passed)
1540 } else {
1541 format!("{} failed, {} passed", report.num_failed, report.num_passed)
1542 };
1543 events.push(Event {
1544 run_id: run_id.clone(),
1545 seq: 0,
1546 offset_ms,
1547 level: if report.success { Level::Info } else { Level::Error },
1548 target: tool.to_owned(),
1549 msg: summary_msg,
1550 fields: serde_json::json!({
1551 "test": {
1552 "total": report.num_total,
1553 "passed": report.num_passed,
1554 "failed": report.num_failed,
1555 "pending": report.num_pending,
1556 },
1557 "build": { "success": report.success }
1558 }),
1559 anchor: None,
1560 source: EventSource::Beholder {
1561 name: tool.to_owned(),
1562 version: version.to_owned(),
1563 },
1564 });
1565
1566 for suite in &report.test_results {
1568 for assertion in &suite.assertions {
1569 if assertion.status != "failed" {
1570 continue;
1571 }
1572 let msg = assertion.failure_messages
1574 .first()
1575 .and_then(|s| s.lines().next())
1576 .map(str::to_owned)
1577 .unwrap_or_else(|| assertion.full_name.clone());
1578 events.push(Event {
1579 run_id: run_id.clone(),
1580 seq: 0,
1581 offset_ms,
1582 level: Level::Error,
1583 target: format!("{}::test", tool),
1584 msg,
1585 fields: serde_json::json!({
1586 "test": { "name": assertion.full_name },
1587 "file": { "path": suite.file_path },
1588 }),
1589 anchor: None,
1590 source: EventSource::Beholder {
1591 name: tool.to_owned(),
1592 version: version.to_owned(),
1593 },
1594 });
1595 }
1596 }
1597
1598 events
1599}
1600
1601pub struct PytestBeholderFactory;
1619
1620impl BeholderFactory for PytestBeholderFactory {
1621 fn name(&self) -> &'static str { "pytest" }
1622 fn version(&self) -> &'static str { "7.0" }
1623
1624 fn matches(&self, argv: &[String]) -> bool {
1625 let first = argv.first().map(String::as_str);
1626 let is_direct = matches!(first, Some("pytest") | Some("py.test"));
1627 let is_python_m = matches!(first, Some("python") | Some("python3"))
1628 && argv.windows(2).any(|w| w[0] == "-m" && w[1] == "pytest");
1629 if !is_direct && !is_python_m {
1630 return false;
1631 }
1632 for a in argv {
1633 match a.as_str() {
1634 "--version" | "-V" | "--help" | "-h" => return false,
1635 "--collect-only" | "--co" => return false,
1636 _ => {}
1637 }
1638 }
1639 true
1640 }
1641
1642 fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1643
1644 fn create(&self) -> Box<dyn Beholder> { Box::new(PytestBeholder::default()) }
1645
1646 fn tool_version_range(&self) -> Option<ToolVersionRange> {
1647 Some(ToolVersionRange { min: Some("7.0.0"), max: None })
1648 }
1649}
1650
1651pub struct PytestBeholder {
1657 buf: Vec<u8>,
1658 summary_line: Option<String>,
1660 unknown_format: Option<String>,
1661}
1662
1663impl Default for PytestBeholder {
1664 fn default() -> Self {
1665 Self { buf: Vec::new(), summary_line: None, unknown_format: None }
1666 }
1667}
1668
1669impl Beholder for PytestBeholder {
1670 fn name(&self) -> &'static str { "pytest" }
1671 fn version(&self) -> &'static str { "7.0" }
1672 fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1673
1674 fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1675 self.buf.extend_from_slice(&chunk.bytes);
1676 let mut events = Vec::new();
1677
1678 let source = EventSource::Beholder {
1679 name: "pytest".to_owned(),
1680 version: "7.0".to_owned(),
1681 };
1682
1683 while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
1684 let raw: Vec<u8> = self.buf.drain(..=nl).collect();
1685 let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
1686 if line.is_empty() { continue }
1687
1688 if let Some(ev) = parse_pytest_failure_line(line, chunk, &source) {
1689 events.push(ev);
1690 continue;
1691 }
1692
1693 if line.starts_with("==") && line.ends_with("==") {
1696 let inner = line.trim_matches('=').trim();
1697 if inner.contains(" passed") || inner.contains(" failed")
1698 || inner.contains(" error")
1699 {
1700 self.summary_line = Some(inner.to_owned());
1701 }
1702 }
1703 }
1704
1705 events
1706 }
1707
1708 fn on_done(&mut self, run_id: &crate::types::TaskRunId, offset_ms: u32) -> Vec<Event> {
1709 let Some(ref summary) = self.summary_line else {
1710 return Vec::new();
1711 };
1712 let Ok(parsed) = parse_pytest_summary(summary) else {
1713 self.unknown_format = Some(format!("could not parse pytest summary: {summary:?}"));
1714 return Vec::new();
1715 };
1716
1717 let level = if parsed.failed > 0 || parsed.errors > 0 { Level::Error } else { Level::Info };
1718 let msg = format_pytest_summary_msg(&parsed);
1719
1720 vec![Event {
1721 run_id: run_id.clone(),
1722 seq: 0,
1723 offset_ms,
1724 level,
1725 target: "pytest".to_owned(),
1726 msg,
1727 fields: serde_json::json!({
1728 "test": {
1729 "total": parsed.passed + parsed.failed + parsed.skipped + parsed.errors,
1730 "passed": parsed.passed,
1731 "failed": parsed.failed,
1732 "skipped": parsed.skipped,
1733 "errors": parsed.errors,
1734 },
1735 "build": { "success": parsed.failed == 0 && parsed.errors == 0 }
1736 }),
1737 anchor: None,
1738 source: EventSource::Beholder {
1739 name: "pytest".to_owned(),
1740 version: "7.0".to_owned(),
1741 },
1742 }]
1743 }
1744
1745 fn unknown_format_reason(&self) -> Option<&str> { self.unknown_format.as_deref() }
1746}
1747
1748fn parse_pytest_failure_line(
1756 line: &str,
1757 chunk: &OutputChunk,
1758 source: &EventSource,
1759) -> Option<Event> {
1760 let (label, rest) = if let Some(r) = line.strip_prefix("FAILED ") {
1761 ("FAILED", r)
1762 } else if let Some(r) = line.strip_prefix("ERROR ") {
1763 ("ERROR", r)
1764 } else {
1765 return None;
1766 };
1767
1768 let (nodeid, reason) = if let Some(idx) = rest.find(" - ") {
1771 let nodeid = &rest[..idx];
1772 if !nodeid.contains(".py") { return None; }
1773 (nodeid, rest[idx + 3..].trim())
1774 } else {
1775 if !rest.contains(".py") { return None; }
1776 (rest, "")
1777 };
1778
1779 let (file_path, test_name) = if let Some(idx) = nodeid.find("::") {
1780 (&nodeid[..idx], &nodeid[idx + 2..])
1781 } else {
1782 (nodeid, "")
1783 };
1784
1785 let msg = if reason.is_empty() {
1786 format!("{label} {nodeid}")
1787 } else {
1788 reason.to_owned()
1789 };
1790
1791 Some(Event {
1792 run_id: chunk.run_id.clone(),
1793 seq: 0,
1794 offset_ms: chunk.offset_ms,
1795 level: Level::Error,
1796 target: "pytest::test".to_owned(),
1797 msg,
1798 fields: serde_json::json!({
1799 "test": { "name": test_name },
1800 "file": { "path": file_path },
1801 }),
1802 anchor: Some(ChunkRef { seq: chunk.seq }),
1803 source: source.clone(),
1804 })
1805}
1806
1807struct PytestSummary {
1808 passed: u32,
1809 failed: u32,
1810 skipped: u32,
1811 errors: u32,
1812}
1813
1814fn parse_pytest_summary(s: &str) -> Result<PytestSummary, ()> {
1816 let s = if let Some(idx) = s.rfind(" in ") { &s[..idx] } else { s };
1818
1819 let mut passed = 0u32;
1820 let mut failed = 0u32;
1821 let mut skipped = 0u32;
1822 let mut errors = 0u32;
1823 let mut any = false;
1824
1825 for part in s.split(", ") {
1826 let part = part.trim();
1827 let mut it = part.splitn(2, ' ');
1828 let count: u32 = it.next().and_then(|n| n.parse().ok()).ok_or(())?;
1829 let kind = it.next().ok_or(())?;
1830 any = true;
1831 if kind.starts_with("passed") { passed = count; }
1832 else if kind.starts_with("failed") { failed = count; }
1833 else if kind.starts_with("skipped") { skipped = count; }
1834 else if kind.starts_with("error") { errors = count; }
1835 }
1837
1838 if !any { return Err(()); }
1839 Ok(PytestSummary { passed, failed, skipped, errors })
1840}
1841
1842fn format_pytest_summary_msg(s: &PytestSummary) -> String {
1843 let mut parts: Vec<String> = Vec::new();
1844 if s.failed > 0 { parts.push(format!("{} failed", s.failed)); }
1845 if s.errors > 0 { parts.push(format!("{} error{}", s.errors, if s.errors == 1 { "" } else { "s" })); }
1846 if s.passed > 0 { parts.push(format!("{} passed", s.passed)); }
1847 if s.skipped > 0 { parts.push(format!("{} skipped", s.skipped)); }
1848 if parts.is_empty() { return "no tests ran".to_owned(); }
1849 parts.join(", ")
1850}
1851
1852pub struct ViteBuildBeholderFactory;
1865
1866impl BeholderFactory for ViteBuildBeholderFactory {
1867 fn name(&self) -> &'static str { "vite-build" }
1868 fn version(&self) -> &'static str { "4.0" }
1869
1870 fn matches(&self, argv: &[String]) -> bool {
1871 if argv.first().map(String::as_str) != Some("vite") { return false; }
1872 if !argv.iter().skip(1).any(|a| a == "build") { return false; }
1873 for a in argv {
1874 match a.as_str() {
1875 "--help" | "-h" | "--version" => return false,
1876 _ => {}
1877 }
1878 }
1879 true
1880 }
1881
1882 fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1883
1884 fn create(&self) -> Box<dyn Beholder> { Box::new(ViteBuildBeholder::default()) }
1885
1886 fn tool_version_range(&self) -> Option<ToolVersionRange> {
1887 Some(ToolVersionRange { min: Some("4.0.0"), max: None })
1888 }
1889}
1890
1891pub struct ViteBuildBeholder {
1898 buf: Vec<u8>,
1899 unknown_format: Option<String>,
1900}
1901
1902impl Default for ViteBuildBeholder {
1903 fn default() -> Self {
1904 Self { buf: Vec::new(), unknown_format: None }
1905 }
1906}
1907
1908impl Beholder for ViteBuildBeholder {
1909 fn name(&self) -> &'static str { "vite-build" }
1910 fn version(&self) -> &'static str { "4.0" }
1911 fn mode(&self) -> BeholderMode { BeholderMode::Parser }
1912
1913 fn parse_chunk(&mut self, chunk: &OutputChunk) -> Vec<Event> {
1914 self.buf.extend_from_slice(&chunk.bytes);
1915 let mut events = Vec::new();
1916
1917 let source = EventSource::Beholder {
1918 name: "vite-build".to_owned(),
1919 version: "4.0".to_owned(),
1920 };
1921
1922 while let Some(nl) = self.buf.iter().position(|&b| b == b'\n') {
1923 let raw: Vec<u8> = self.buf.drain(..=nl).collect();
1924 let Ok(line) = std::str::from_utf8(raw.trim_ascii_end()) else { continue };
1925 if line.is_empty() { continue }
1926
1927 if let Some(warn_text) = line.strip_prefix("(!) ") {
1929 events.push(Event {
1930 run_id: chunk.run_id.clone(),
1931 seq: 0,
1932 offset_ms: chunk.offset_ms,
1933 level: Level::Warn,
1934 target: "vite-build::warning".to_owned(),
1935 msg: warn_text.trim().to_owned(),
1936 fields: serde_json::json!({ "build": { "warning": true } }),
1937 anchor: Some(ChunkRef { seq: chunk.seq }),
1938 source: source.clone(),
1939 });
1940 continue;
1941 }
1942
1943 if let Some(ev) = parse_vite_bundle_line(line, chunk, &source) {
1945 events.push(ev);
1946 continue;
1947 }
1948
1949 if let Some(ms) = parse_vite_built_ms(line) {
1951 events.push(Event {
1952 run_id: chunk.run_id.clone(),
1953 seq: 0,
1954 offset_ms: chunk.offset_ms,
1955 level: Level::Info,
1956 target: "vite-build".to_owned(),
1957 msg: format!("built in {:.2}s", ms as f64 / 1000.0),
1958 fields: serde_json::json!({
1959 "build": { "duration_ms": ms, "success": true }
1960 }),
1961 anchor: Some(ChunkRef { seq: chunk.seq }),
1962 source: source.clone(),
1963 });
1964 }
1965 }
1966
1967 events
1968 }
1969
1970 fn unknown_format_reason(&self) -> Option<&str> { self.unknown_format.as_deref() }
1971}
1972
1973fn parse_vite_bundle_line(line: &str, chunk: &OutputChunk, source: &EventSource) -> Option<Event> {
1979 let trimmed = line.trim();
1980 if !trimmed.starts_with("dist/") { return None; }
1981 if !trimmed.contains("kB") { return None; }
1982
1983 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
1984 let file = tokens.first()?;
1985
1986 let mut values: Vec<f64> = Vec::new();
1988 let mut i = 1;
1989 while i < tokens.len() {
1990 if let Ok(v) = tokens[i].parse::<f64>() {
1991 if tokens.get(i + 1).copied() == Some("kB") {
1992 values.push(v);
1993 i += 2;
1994 continue;
1995 }
1996 }
1997 i += 1;
1998 }
1999 let size_kb = *values.first()?;
2000 let gzip_kb = values.get(1).copied();
2001
2002 let msg = if let Some(g) = gzip_kb {
2003 format!("{file}: {size_kb:.2} kB (gzip: {g:.2} kB)")
2004 } else {
2005 format!("{file}: {size_kb:.2} kB")
2006 };
2007
2008 let mut fields = serde_json::json!({
2009 "file": { "path": file },
2010 "build": { "size_kb": size_kb },
2011 });
2012 if let Some(g) = gzip_kb {
2013 fields["build"]["gzip_kb"] = serde_json::json!(g);
2014 }
2015
2016 Some(Event {
2017 run_id: chunk.run_id.clone(),
2018 seq: 0,
2019 offset_ms: chunk.offset_ms,
2020 level: Level::Info,
2021 target: "vite-build::bundle".to_owned(),
2022 msg,
2023 fields,
2024 anchor: Some(ChunkRef { seq: chunk.seq }),
2025 source: source.clone(),
2026 })
2027}
2028
2029fn parse_vite_built_ms(line: &str) -> Option<u64> {
2031 let idx = line.find("built in ")?;
2032 let rest = line[idx + "built in ".len()..].trim();
2033 let secs_str = rest.trim_end_matches(|c: char| c.is_alphabetic());
2034 let secs: f64 = secs_str.parse().ok()?;
2035 Some((secs * 1000.0).round() as u64)
2036}
2037
2038
2039#[cfg(test)]
2042mod tests {
2043 use super::*;
2044 struct PrefixFactory {
2048 prefix: &'static str,
2049 name: &'static str,
2050 }
2051
2052 struct NoopBeholder {
2053 name: &'static str,
2054 }
2055
2056 impl BeholderFactory for PrefixFactory {
2057 fn name(&self) -> &'static str { self.name }
2058 fn version(&self) -> &'static str { "1.0" }
2059 fn matches(&self, argv: &[String]) -> bool {
2060 argv.first().map(|s| s.starts_with(self.prefix)).unwrap_or(false)
2061 }
2062 fn mode(&self) -> BeholderMode { BeholderMode::Parser }
2063 fn create(&self) -> Box<dyn Beholder> { Box::new(NoopBeholder { name: self.name }) }
2064 }
2065
2066 impl Beholder for NoopBeholder {
2067 fn name(&self) -> &'static str { self.name }
2068 fn version(&self) -> &'static str { "1.0" }
2069 fn mode(&self) -> BeholderMode { BeholderMode::Parser }
2070 fn parse_chunk(&mut self, _chunk: &OutputChunk) -> Vec<Event> { vec![] }
2071 }
2072
2073 struct RewriterFactory;
2075 struct RewriterBeholder;
2076
2077 impl BeholderFactory for RewriterFactory {
2078 fn name(&self) -> &'static str { "cargo" }
2079 fn version(&self) -> &'static str { "1.78" }
2080 fn matches(&self, argv: &[String]) -> bool {
2081 argv.first().map(|s| s == "cargo").unwrap_or(false)
2082 && !argv.iter().any(|a| a == "--no-json")
2083 }
2084 fn mode(&self) -> BeholderMode {
2085 BeholderMode::Rewriter {
2086 adjust_argv: |argv| {
2087 argv.push("--message-format=json-render-diagnostics".to_string());
2088 },
2089 }
2090 }
2091 fn create(&self) -> Box<dyn Beholder> { Box::new(RewriterBeholder) }
2092 }
2093
2094 impl Beholder for RewriterBeholder {
2095 fn name(&self) -> &'static str { "cargo" }
2096 fn version(&self) -> &'static str { "1.78" }
2097 fn mode(&self) -> BeholderMode {
2098 BeholderMode::Rewriter {
2099 adjust_argv: |argv| {
2100 argv.push("--message-format=json-render-diagnostics".to_string());
2101 },
2102 }
2103 }
2104 fn parse_chunk(&mut self, _chunk: &OutputChunk) -> Vec<Event> { vec![] }
2105 }
2106
2107 #[test]
2110 fn empty_registry_returns_none_auto() {
2111 let registry = BeholderRegistry::new();
2112 let result = registry.attach("cargo check", &BeholderSelect::Auto, false);
2113 assert!(result.beholder.is_none());
2114 assert_eq!(result.status.text, "none:auto");
2115 assert_eq!(result.argv, vec!["cargo", "check"]);
2116 }
2117
2118 #[test]
2119 fn auto_attaches_first_matching() {
2120 let mut registry = BeholderRegistry::new();
2121 registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2122 let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2123 assert!(result.beholder.is_some());
2124 assert_eq!(result.status.text, "attached:cargo@1.0");
2125 }
2126
2127 #[test]
2128 fn rewriter_adjusts_argv_in_result() {
2129 let mut registry = BeholderRegistry::new();
2130 registry.register(Box::new(RewriterFactory));
2131 let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2132 assert!(result.beholder.is_some());
2133 assert!(
2135 result.status.text.starts_with("attached:cargo@1.78"),
2136 "got: {}",
2137 result.status.text
2138 );
2139 assert!(
2140 result.status.text.contains("rewrite="),
2141 "rewrite must be surfaced in status; got: {}",
2142 result.status.text
2143 );
2144 assert!(
2145 result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2146 "rewriter must append the JSON flag"
2147 );
2148 assert_eq!(
2149 result.status.rewrite_added.as_deref(),
2150 Some(vec!["--message-format=json-render-diagnostics".to_string()].as_slice()),
2151 "rewrite_added must list the injected arg"
2152 );
2153 }
2154
2155 #[test]
2156 fn auto_respects_decline_from_matches() {
2157 let mut registry = BeholderRegistry::new();
2158 registry.register(Box::new(RewriterFactory));
2159 let result = registry.attach("cargo check --no-json", &BeholderSelect::Auto, false);
2160 assert!(result.beholder.is_none());
2161 assert_eq!(result.status.text, "none:auto");
2162 assert!(!result.argv.contains(&"--message-format=json-render-diagnostics".to_string()));
2164 }
2165
2166 #[test]
2172 fn a_piped_line_gets_no_rewriter_because_the_flag_would_land_on_the_last_stage() {
2173 let mut registry = BeholderRegistry::new();
2174 registry.register(Box::new(RewriterFactory));
2175 let result = registry.attach(
2176 "cargo check -p yah-agent-tools --lib 2>&1 | tail -12",
2177 &BeholderSelect::Auto,
2178 false,
2179 );
2180 assert!(result.beholder.is_none(), "no beholder may attach to a shell line");
2181 assert_eq!(result.status.text, "declined:auto reason=\"compound-command\"");
2182 assert!(
2183 !result
2184 .argv
2185 .contains(&"--message-format=json-render-diagnostics".to_string()),
2186 "the flag must not be spliced into `tail`'s arguments"
2187 );
2188 }
2189
2190 #[test]
2193 fn force_does_not_override_the_compound_command_guard() {
2194 let mut registry = BeholderRegistry::new();
2195 registry.register(Box::new(RewriterFactory));
2196 let result = registry.attach(
2197 "cargo check && cargo test",
2198 &BeholderSelect::Force("cargo".to_string()),
2199 false,
2200 );
2201 assert!(result.beholder.is_none());
2202 assert_eq!(result.status.text, "declined:auto reason=\"compound-command\"");
2203 }
2204
2205 #[test]
2206 fn a_bare_command_still_attaches() {
2207 let mut registry = BeholderRegistry::new();
2208 registry.register(Box::new(RewriterFactory));
2209 let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2210 assert!(result.beholder.is_some(), "the guard must not cost the simple case");
2211 }
2212
2213 #[test]
2217 fn the_guard_is_a_character_scan_and_over_declines_on_purpose() {
2218 assert!(is_single_simple_command("cargo check --workspace"));
2219 assert!(is_single_simple_command("cargo test -p yah --lib mcp::tools"));
2220 assert!(!is_single_simple_command("cargo check 2>&1 | tail -5"));
2222 assert!(!is_single_simple_command("cargo check && cargo test"));
2223 assert!(!is_single_simple_command("cargo check; echo done"));
2224 assert!(!is_single_simple_command("cargo check $(cat args)"));
2225 assert!(!is_single_simple_command("cargo check\ncargo test"));
2226 assert!(!is_single_simple_command("cargo check > out.txt"));
2228 assert!(!is_single_simple_command("cargo test -p yah -- --nocapture $FILTER"));
2229 }
2230
2231 #[test]
2232 fn explicit_none_bypasses_registry() {
2233 let mut registry = BeholderRegistry::new();
2234 registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2235 let result = registry.attach("cargo check", &BeholderSelect::None, false);
2236 assert!(result.beholder.is_none());
2237 assert_eq!(result.status.text, "none:explicit");
2238 }
2239
2240 #[test]
2241 fn force_pins_by_name() {
2242 let mut registry = BeholderRegistry::new();
2243 registry.register(Box::new(PrefixFactory { prefix: "tsc", name: "tsc" }));
2244 registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2245 let result = registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), false);
2247 assert!(result.beholder.is_some());
2248 assert!(result.status.text.starts_with("forced:cargo"), "got: {}", result.status.text);
2249 }
2250
2251 #[test]
2252 fn force_against_flags_when_would_decline() {
2253 let mut registry = BeholderRegistry::new();
2254 registry.register(Box::new(RewriterFactory));
2255 let result =
2257 registry.attach("cargo check --no-json", &BeholderSelect::Force("cargo".to_string()), false);
2258 assert!(result.beholder.is_some());
2259 assert!(
2260 result.status.text.contains("forced-against-flags"),
2261 "got: {}",
2262 result.status.text
2263 );
2264 }
2265
2266 #[test]
2267 fn force_unknown_name_returns_none() {
2268 let registry = BeholderRegistry::new();
2269 let result =
2270 registry.attach("cargo check", &BeholderSelect::Force("unknown".to_string()), false);
2271 assert!(result.beholder.is_none());
2272 }
2273
2274 #[test]
2275 fn priority_order_first_match_wins() {
2276 let mut registry = BeholderRegistry::new();
2277 registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "first" }));
2278 registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "second" }));
2279 let result = registry.attach("cargo check", &BeholderSelect::Auto, false);
2280 assert_eq!(result.status.text, "attached:first@1.0");
2281 }
2282
2283 #[test]
2286 fn verbatim_output_causes_rewriter_to_decline_in_auto() {
2287 let mut registry = BeholderRegistry::new();
2288 registry.register(Box::new(RewriterFactory));
2289 let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, true);
2290 assert!(result.beholder.is_none(), "rewriter must not attach under verbatim_output");
2292 assert!(
2293 result.status.text.contains("declined:cargo"),
2294 "got: {}",
2295 result.status.text
2296 );
2297 assert!(
2298 result.status.text.contains("verbatim-output"),
2299 "got: {}",
2300 result.status.text
2301 );
2302 assert!(!result.argv.contains(&"--message-format=json-render-diagnostics".to_string()));
2304 }
2305
2306 #[test]
2314 fn the_real_cargo_rewriter_declines_when_the_output_is_read_verbatim() {
2315 let mut registry = BeholderRegistry::new();
2316 registry.register(Box::new(CargoBeholderFactory));
2317 let result = registry.attach("cargo check -p yah --lib", &BeholderSelect::Auto, true);
2318 assert!(result.beholder.is_none(), "got: {}", result.status.text);
2319 assert!(
2320 !result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2321 "the command line must reach the shell exactly as the caller wrote it: {:?}",
2322 result.argv
2323 );
2324 let observed = registry.attach("cargo check -p yah --lib", &BeholderSelect::Auto, false);
2327 assert!(observed.beholder.is_some(), "got: {}", observed.status.text);
2328 }
2329
2330 #[test]
2331 fn verbatim_output_does_not_affect_parser_beholder() {
2332 let mut registry = BeholderRegistry::new();
2333 registry.register(Box::new(PrefixFactory { prefix: "cargo", name: "cargo" }));
2334 let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, true);
2337 assert!(
2338 result.beholder.is_some(),
2339 "parser beholder must attach even under verbatim_output"
2340 );
2341 assert_eq!(result.status.text, "attached:cargo@1.0");
2342 }
2343
2344 #[test]
2345 fn force_overrides_verbatim_decline_and_records_forced_against_verbatim() {
2346 let mut registry = BeholderRegistry::new();
2347 registry.register(Box::new(RewriterFactory));
2348 let result =
2351 registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), true);
2352 assert!(result.beholder.is_some(), "Force must attach under verbatim_output");
2353 assert!(
2354 result.status.text.contains("forced-against-verbatim"),
2355 "got: {}",
2356 result.status.text
2357 );
2358 assert!(
2360 result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2361 "rewrite must still be applied when forced"
2362 );
2363 }
2364
2365 #[test]
2366 fn rewrite_surfaced_on_force_normal() {
2367 let mut registry = BeholderRegistry::new();
2368 registry.register(Box::new(RewriterFactory));
2369 let result =
2370 registry.attach("cargo check", &BeholderSelect::Force("cargo".to_string()), false);
2371 assert!(result.beholder.is_some());
2372 assert!(
2373 result.status.text.contains("forced:cargo"),
2374 "got: {}",
2375 result.status.text
2376 );
2377 assert!(
2378 result.status.text.contains("rewrite="),
2379 "rewrite must be surfaced on forced attach; got: {}",
2380 result.status.text
2381 );
2382 assert!(result.status.rewrite_added.is_some());
2383 }
2384
2385 #[test]
2388 fn resolve_bare_command() {
2389 assert_eq!(resolve_argv("cargo check --workspace"), ["cargo", "check", "--workspace"]);
2390 }
2391
2392 #[test]
2393 fn resolve_strips_bunx() {
2394 let argv = resolve_argv("bunx vitest --run");
2395 assert_eq!(argv[0], "vitest");
2396 assert_eq!(argv[1], "--run");
2397 }
2398
2399 #[test]
2400 fn resolve_strips_npx() {
2401 let argv = resolve_argv("npx tsc --noEmit");
2402 assert_eq!(argv[0], "tsc");
2403 assert_eq!(argv[1], "--noEmit");
2404 }
2405
2406 #[test]
2407 fn resolve_strips_pnpm() {
2408 let argv = resolve_argv("pnpm tsc");
2409 assert_eq!(argv[0], "tsc");
2410 }
2411
2412 #[test]
2413 fn resolve_strips_npm_exec() {
2414 let argv = resolve_argv("npm exec tsc --noEmit");
2415 assert_eq!(argv[0], "tsc");
2416 assert_eq!(argv[1], "--noEmit");
2417 }
2418
2419 #[test]
2420 fn resolve_pnpm_without_exec_subcommand() {
2421 let argv = resolve_argv("pnpm vitest --reporter=verbose");
2423 assert_eq!(argv[0], "vitest");
2424 }
2425
2426 #[test]
2427 fn resolve_empty_command() {
2428 let argv = resolve_argv("");
2429 assert!(argv.is_empty());
2430 }
2431
2432 fn dummy_chunk(bytes: &[u8]) -> OutputChunk {
2435 use crate::types::{Stream, TaskRunId};
2436 OutputChunk {
2437 run_id: TaskRunId::new(),
2438 seq: 0,
2439 offset_ms: 0,
2440 stream: Stream::Stdout,
2441 bytes: bytes.to_vec(),
2442 }
2443 }
2444
2445 #[test]
2446 fn cargo_factory_matches_check() {
2447 let f = CargoBeholderFactory;
2448 let argv: Vec<String> = vec!["cargo".into(), "check".into(), "--workspace".into()];
2449 assert!(f.matches(&argv));
2450 }
2451
2452 #[test]
2453 fn cargo_factory_matches_clippy() {
2454 let f = CargoBeholderFactory;
2455 let argv: Vec<String> = vec!["cargo".into(), "clippy".into()];
2456 assert!(f.matches(&argv));
2457 }
2458
2459 #[test]
2460 fn cargo_factory_declines_explicit_message_format() {
2461 let f = CargoBeholderFactory;
2462 let argv: Vec<String> =
2463 vec!["cargo".into(), "check".into(), "--message-format=human".into()];
2464 assert!(!f.matches(&argv));
2465 }
2466
2467 #[test]
2468 fn cargo_factory_declines_non_diag_subcommand() {
2469 let f = CargoBeholderFactory;
2470 let argv: Vec<String> = vec!["cargo".into(), "fmt".into()];
2471 assert!(!f.matches(&argv));
2472 }
2473
2474 #[test]
2475 fn cargo_factory_declines_non_cargo() {
2476 let f = CargoBeholderFactory;
2477 let argv: Vec<String> = vec!["rustc".into(), "--edition=2021".into()];
2478 assert!(!f.matches(&argv));
2479 }
2480
2481 #[test]
2482 fn cargo_factory_rewriter_adds_flag() {
2483 let registry = default_registry();
2484 let result = registry.attach("cargo check --workspace", &BeholderSelect::Auto, false);
2485 assert!(result.beholder.is_some(), "cargo beholder must attach");
2486 assert!(
2487 result.argv.contains(&"--message-format=json-render-diagnostics".to_string()),
2488 "rewriter must inject the JSON flag; got: {:?}",
2489 result.argv
2490 );
2491 assert!(
2493 result.status.text.contains("rewrite="),
2494 "rewrite must be surfaced in status; got: {}",
2495 result.status.text
2496 );
2497 }
2498
2499 #[test]
2500 fn cargo_beholder_parses_compiler_error() {
2501 let json = r#"{"reason":"compiler-message","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"message":{"$message_type":"diagnostic","message":"mismatched types","code":{"code":"E0308","explanation":""},"level":"error","spans":[{"file_name":"src/lib.rs","byte_start":0,"byte_end":1,"line_start":5,"line_end":5,"column_start":1,"column_end":14,"is_primary":true,"text":[],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"error[E0308]: mismatched types\n"}}"#;
2502 let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2503 let mut b = CargoBeholder::default();
2504 let events = b.parse_chunk(&chunk);
2505 assert_eq!(events.len(), 1);
2506 let ev = &events[0];
2507 assert_eq!(ev.target, "cargo::rustc");
2508 assert_eq!(ev.level, Level::Error);
2509 assert_eq!(ev.msg, "mismatched types");
2510 assert_eq!(ev.fields["error"]["code"], "E0308");
2511 assert_eq!(ev.fields["file"]["path"], "src/lib.rs");
2512 assert_eq!(ev.fields["file"]["line"], 5u32);
2513 }
2514
2515 #[test]
2516 fn cargo_beholder_parses_warning() {
2517 let json = r#"{"reason":"compiler-message","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"message":{"$message_type":"diagnostic","message":"unused variable: `x`","code":{"code":"unused_variables","explanation":""},"level":"warning","spans":[{"file_name":"src/lib.rs","byte_start":0,"byte_end":1,"line_start":10,"line_end":10,"column_start":9,"column_end":10,"is_primary":true,"text":[],"label":null,"suggested_replacement":null,"suggestion_applicability":null,"expansion":null}],"children":[],"rendered":"warning: unused variable"}}"#;
2518 let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2519 let mut b = CargoBeholder::default();
2520 let events = b.parse_chunk(&chunk);
2521 assert_eq!(events.len(), 1);
2522 assert_eq!(events[0].level, Level::Warn);
2523 assert_eq!(events[0].fields["file"]["line"], 10u32);
2524 }
2525
2526 #[test]
2527 fn cargo_beholder_parses_build_finished_success() {
2528 let json = r#"{"reason":"build-finished","success":true}"#;
2529 let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2530 let mut b = CargoBeholder::default();
2531 let events = b.parse_chunk(&chunk);
2532 assert_eq!(events.len(), 1);
2533 assert_eq!(events[0].level, Level::Info);
2534 assert_eq!(events[0].msg, "build finished");
2535 }
2536
2537 #[test]
2538 fn cargo_beholder_parses_build_finished_failure() {
2539 let json = r#"{"reason":"build-finished","success":false}"#;
2540 let chunk = dummy_chunk(format!("{json}\n").as_bytes());
2541 let mut b = CargoBeholder::default();
2542 let events = b.parse_chunk(&chunk);
2543 assert_eq!(events.len(), 1);
2544 assert_eq!(events[0].level, Level::Error);
2545 assert_eq!(events[0].msg, "build failed");
2546 }
2547
2548 #[test]
2549 fn cargo_beholder_skips_artifacts_and_unknown() {
2550 let artifact = r#"{"reason":"compiler-artifact","package_id":"foo","manifest_path":"foo","target":{"kind":["lib"],"name":"foo","src_path":"src/lib.rs","edition":"2021","doctest":true,"test":true,"doc":true},"profile":{"opt_level":"0","debuginfo":2,"debug_assertions":true,"overflow_checks":true,"test":false},"features":[],"filenames":[],"executable":null,"fresh":false}"#;
2552 let script = r#"{"reason":"build-script-executed","package_id":"foo 0.1.0","linked_libs":[],"linked_paths":[],"cfgs":[],"env":[],"out_dir":"/tmp"}"#;
2553 let mut b = CargoBeholder::default();
2554 let chunk = dummy_chunk(format!("{artifact}\n{script}\n").as_bytes());
2555 let events = b.parse_chunk(&chunk);
2556 assert!(events.is_empty(), "got: {events:?}");
2557 }
2558
2559 #[test]
2560 fn cargo_beholder_buffers_partial_lines() {
2561 let json = r#"{"reason":"build-finished","success":true}"#;
2562 let half = json.len() / 2;
2563 let mut b = CargoBeholder::default();
2564 let chunk1 = dummy_chunk(json[..half].as_bytes());
2566 let ev1 = b.parse_chunk(&chunk1);
2567 assert!(ev1.is_empty(), "should buffer partial line");
2568 let chunk2 = dummy_chunk(format!("{}\n", &json[half..]).as_bytes());
2570 let ev2 = b.parse_chunk(&chunk2);
2571 assert_eq!(ev2.len(), 1);
2572 assert_eq!(ev2[0].msg, "build finished");
2573 }
2574
2575 #[test]
2578 fn cargo_beholder_unknown_format_reason_none_initially() {
2579 let b = CargoBeholder::default();
2580 assert!(b.unknown_format_reason().is_none(), "fresh beholder must not flag unknown format");
2581 }
2582
2583 #[test]
2584 fn cargo_factory_declares_tool_version_range() {
2585 let f = CargoBeholderFactory;
2586 let range = f.tool_version_range().expect("cargo factory must declare a version range");
2587 assert_eq!(range.min, Some("1.38.0"), "min version must be 1.38.0");
2588 assert!(range.max.is_none(), "no upper bound declared");
2589 }
2590
2591 #[test]
2592 fn cargo_beholder_format_probe_fires_after_limit() {
2593 let bad_line = r#"{"not_reason":"something","value":42}"#;
2595 let input: String = (0..super::FORMAT_PROBE_LIMIT)
2596 .map(|_| format!("{bad_line}\n"))
2597 .collect();
2598 let chunk = dummy_chunk(input.as_bytes());
2599 let mut b = CargoBeholder::default();
2600 let events = b.parse_chunk(&chunk);
2601 assert!(events.is_empty(), "no events from unrecognized lines");
2602 let reason = b.unknown_format_reason();
2603 assert!(
2604 reason.is_some(),
2605 "unknown_format_reason must be Some after {FORMAT_PROBE_LIMIT} unrecognized JSON lines"
2606 );
2607 assert!(
2608 reason.unwrap().contains("no recognizable cargo JSON"),
2609 "reason text must describe the problem; got: {:?}",
2610 reason
2611 );
2612 }
2613
2614 #[test]
2615 fn cargo_beholder_format_probe_not_triggered_below_limit() {
2616 let bad_line = r#"{"not_reason":"something"}"#;
2618 let input: String = (0..super::FORMAT_PROBE_LIMIT - 1)
2619 .map(|_| format!("{bad_line}\n"))
2620 .collect();
2621 let chunk = dummy_chunk(input.as_bytes());
2622 let mut b = CargoBeholder::default();
2623 b.parse_chunk(&chunk);
2624 assert!(
2625 b.unknown_format_reason().is_none(),
2626 "must not flag unknown format before probe limit"
2627 );
2628 }
2629
2630 #[test]
2631 fn cargo_beholder_format_probe_suppressed_after_recognized_line() {
2632 let good = r#"{"reason":"build-finished","success":true}"#;
2634 let bad_line = r#"{"not_reason":"something"}"#;
2635 let mut input = format!("{good}\n");
2637 for _ in 0..super::FORMAT_PROBE_LIMIT + 2 {
2638 input.push_str(bad_line);
2639 input.push('\n');
2640 }
2641 let chunk = dummy_chunk(input.as_bytes());
2642 let mut b = CargoBeholder::default();
2643 let events = b.parse_chunk(&chunk);
2644 assert_eq!(events.len(), 1, "expected one event from the recognized line");
2646 assert_eq!(events[0].msg, "build finished");
2647 assert!(
2648 b.unknown_format_reason().is_none(),
2649 "format probe must not fire after a recognized line has been seen"
2650 );
2651 }
2652
2653 #[test]
2654 fn cargo_beholder_stops_emitting_after_unknown_format() {
2655 let bad_line = r#"{"not_reason":"x"}"#;
2657 let trigger: String = (0..super::FORMAT_PROBE_LIMIT)
2658 .map(|_| format!("{bad_line}\n"))
2659 .collect();
2660 let mut b = CargoBeholder::default();
2661 b.parse_chunk(&dummy_chunk(trigger.as_bytes()));
2662 assert!(b.unknown_format_reason().is_some(), "format must be flagged");
2663
2664 let valid = r#"{"reason":"build-finished","success":true}"#.to_string() + "\n";
2666 let events = b.parse_chunk(&dummy_chunk(valid.as_bytes()));
2667 assert!(events.is_empty(), "beholder must not emit events after unknown_format is set");
2668 }
2669
2670 #[test]
2671 fn cargo_beholder_non_json_lines_dont_count_toward_probe() {
2672 let mut input = String::new();
2674 for _ in 0..super::FORMAT_PROBE_LIMIT + 3 {
2676 input.push_str(" warning: some human text\n");
2677 }
2678 input.push_str(r#"{"reason":"build-finished","success":true}"#);
2680 input.push('\n');
2681 let mut b = CargoBeholder::default();
2682 let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2683 assert_eq!(events.len(), 1, "valid cargo line must still produce an event");
2684 assert!(b.unknown_format_reason().is_none(), "non-JSON lines must not count toward probe");
2685 }
2686
2687 #[test]
2690 fn tsc_factory_matches_bare_tsc() {
2691 let f = TscBeholderFactory;
2692 let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into()];
2693 assert!(f.matches(&argv));
2694 }
2695
2696 #[test]
2697 fn tsc_factory_matches_tsc_with_project() {
2698 let f = TscBeholderFactory;
2699 let argv: Vec<String> = vec!["tsc".into(), "-p".into(), "tsconfig.json".into()];
2700 assert!(f.matches(&argv));
2701 }
2702
2703 #[test]
2704 fn tsc_factory_matches_when_pretty_false_already_set() {
2705 let f = TscBeholderFactory;
2707 let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into(), "--pretty=false".into()];
2708 assert!(f.matches(&argv));
2709 }
2710
2711 #[test]
2712 fn tsc_factory_declines_pretty_true() {
2713 let f = TscBeholderFactory;
2714 let argv: Vec<String> = vec!["tsc".into(), "--pretty=true".into()];
2715 assert!(!f.matches(&argv));
2716 }
2717
2718 #[test]
2719 fn tsc_factory_declines_bare_pretty() {
2720 let f = TscBeholderFactory;
2721 let argv: Vec<String> = vec!["tsc".into(), "--pretty".into()];
2722 assert!(!f.matches(&argv));
2723 }
2724
2725 #[test]
2726 fn tsc_factory_declines_version() {
2727 let f = TscBeholderFactory;
2728 let argv: Vec<String> = vec!["tsc".into(), "--version".into()];
2729 assert!(!f.matches(&argv));
2730 }
2731
2732 #[test]
2733 fn tsc_factory_declines_init() {
2734 let f = TscBeholderFactory;
2735 let argv: Vec<String> = vec!["tsc".into(), "--init".into()];
2736 assert!(!f.matches(&argv));
2737 }
2738
2739 #[test]
2740 fn tsc_factory_declines_non_tsc_command() {
2741 let f = TscBeholderFactory;
2742 let argv: Vec<String> = vec!["node".into(), "build.js".into()];
2743 assert!(!f.matches(&argv));
2744 }
2745
2746 #[test]
2747 fn tsc_factory_rewriter_adds_pretty_false() {
2748 let registry = default_registry();
2749 let result = registry.attach("tsc --noEmit", &BeholderSelect::Auto, false);
2750 assert!(result.beholder.is_some(), "tsc beholder must attach");
2751 assert!(
2752 result.argv.contains(&"--pretty=false".to_string()),
2753 "rewriter must inject --pretty=false; got: {:?}",
2754 result.argv
2755 );
2756 assert!(
2757 result.status.text.contains("rewrite="),
2758 "rewrite must be surfaced in status; got: {}",
2759 result.status.text
2760 );
2761 }
2762
2763 #[test]
2764 fn tsc_factory_no_rewrite_when_pretty_false_present() {
2765 let registry = default_registry();
2766 let result = registry.attach("tsc --noEmit --pretty=false", &BeholderSelect::Auto, false);
2767 assert!(result.beholder.is_some(), "tsc beholder must attach");
2768 let count = result.argv.iter().filter(|a| a.as_str() == "--pretty=false").count();
2770 assert_eq!(count, 1, "--pretty=false must appear exactly once; got: {:?}", result.argv);
2771 assert!(
2773 result.status.rewrite_added.as_ref().map(|v| v.is_empty()).unwrap_or(true),
2774 "rewrite_added must be empty when flag already present"
2775 );
2776 }
2777
2778 #[test]
2779 fn tsc_beholder_parses_error_line() {
2780 let line = "src/foo.ts(10,5): error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.\n";
2781 let mut b = TscBeholder::default();
2782 let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2783 assert_eq!(events.len(), 1);
2784 let ev = &events[0];
2785 assert_eq!(ev.target, "tsc");
2786 assert_eq!(ev.level, Level::Error);
2787 assert_eq!(ev.msg, "Argument of type 'string' is not assignable to parameter of type 'number'.");
2788 assert_eq!(ev.fields["error"]["code"], "TS2345");
2789 assert_eq!(ev.fields["file"]["path"], "src/foo.ts");
2790 assert_eq!(ev.fields["file"]["line"], 10u32);
2791 assert_eq!(ev.fields["file"]["col"], 5u32);
2792 }
2793
2794 #[test]
2795 fn tsc_beholder_parses_warning_line() {
2796 let line = "src/bar.tsx(42,3): warning TS6133: 'x' is declared but its value is never read.\n";
2797 let mut b = TscBeholder::default();
2798 let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2799 assert_eq!(events.len(), 1);
2800 assert_eq!(events[0].level, Level::Warn);
2801 assert_eq!(events[0].fields["error"]["code"], "TS6133");
2802 assert_eq!(events[0].fields["file"]["path"], "src/bar.tsx");
2803 assert_eq!(events[0].fields["file"]["line"], 42u32);
2804 assert_eq!(events[0].fields["file"]["col"], 3u32);
2805 }
2806
2807 #[test]
2808 fn tsc_beholder_parses_summary_with_errors() {
2809 let line = "Found 2 errors.\n";
2810 let mut b = TscBeholder::default();
2811 let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2812 assert_eq!(events.len(), 1);
2813 assert_eq!(events[0].level, Level::Error);
2814 assert_eq!(events[0].fields["build"]["errors"], 2u32);
2815 }
2816
2817 #[test]
2818 fn tsc_beholder_parses_summary_zero_errors() {
2819 let line = "Found 0 errors.\n";
2820 let mut b = TscBeholder::default();
2821 let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2822 assert_eq!(events.len(), 1);
2823 assert_eq!(events[0].level, Level::Info);
2824 assert_eq!(events[0].fields["build"]["errors"], 0u32);
2825 }
2826
2827 #[test]
2828 fn tsc_beholder_parses_summary_in_n_files() {
2829 let line = "Found 3 errors in 2 files.\n";
2830 let mut b = TscBeholder::default();
2831 let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2832 assert_eq!(events.len(), 1);
2833 assert_eq!(events[0].fields["build"]["errors"], 3u32);
2834 }
2835
2836 #[test]
2837 fn tsc_beholder_skips_watch_mode_header() {
2838 let line = "[12:00:00 AM] Starting compilation in watch mode...\n";
2839 let mut b = TscBeholder::default();
2840 let events = b.parse_chunk(&dummy_chunk(line.as_bytes()));
2841 assert!(events.is_empty(), "watch-mode header must be skipped");
2842 }
2843
2844 #[test]
2845 fn tsc_beholder_skips_blank_lines() {
2846 let input = "\n\n";
2847 let mut b = TscBeholder::default();
2848 let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2849 assert!(events.is_empty());
2850 }
2851
2852 #[test]
2853 fn tsc_beholder_buffers_partial_lines() {
2854 let line = "src/foo.ts(1,1): error TS2304: Cannot find name 'foo'.";
2855 let half = line.len() / 2;
2856 let mut b = TscBeholder::default();
2857 let ev1 = b.parse_chunk(&dummy_chunk(line[..half].as_bytes()));
2858 assert!(ev1.is_empty(), "partial line must not produce events");
2859 let ev2 = b.parse_chunk(&dummy_chunk(format!("{}\n", &line[half..]).as_bytes()));
2860 assert_eq!(ev2.len(), 1, "complete line must produce one event");
2861 assert_eq!(ev2[0].fields["error"]["code"], "TS2304");
2862 }
2863
2864 #[test]
2865 fn tsc_beholder_multiple_errors_in_one_chunk() {
2866 let input = concat!(
2867 "src/a.ts(1,1): error TS2304: Cannot find name 'a'.\n",
2868 "src/b.ts(2,3): error TS2304: Cannot find name 'b'.\n",
2869 "Found 2 errors.\n",
2870 );
2871 let mut b = TscBeholder::default();
2872 let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2873 assert_eq!(events.len(), 3, "two diagnostics + one summary");
2874 assert_eq!(events[0].fields["file"]["path"], "src/a.ts");
2875 assert_eq!(events[1].fields["file"]["path"], "src/b.ts");
2876 assert_eq!(events[2].fields["build"]["errors"], 2u32);
2877 }
2878
2879 #[test]
2880 fn tsc_beholder_full_watch_cycle() {
2881 let input = concat!(
2882 "[12:00:00 AM] Starting compilation in watch mode...\n",
2883 "\n",
2884 "src/foo.ts(5,9): error TS2345: wrong type.\n",
2885 "\n",
2886 "[12:00:01 AM] Found 1 error. Watching for file changes.\n",
2887 );
2888 let mut b = TscBeholder::default();
2889 let events = b.parse_chunk(&dummy_chunk(input.as_bytes()));
2890 assert_eq!(events.len(), 1, "only diagnostic line should produce event; got: {events:?}");
2892 assert_eq!(events[0].level, Level::Error);
2893 assert_eq!(events[0].fields["file"]["path"], "src/foo.ts");
2894 }
2895
2896 #[test]
2897 fn tsc_factory_declares_tool_version_range() {
2898 let f = TscBeholderFactory;
2899 let range = f.tool_version_range().expect("tsc factory must declare a version range");
2900 assert_eq!(range.min, Some("3.0.0"));
2901 assert!(range.max.is_none());
2902 }
2903
2904 #[test]
2907 fn eslint_factory_matches_bare_eslint() {
2908 let f = EslintBeholderFactory;
2909 let argv: Vec<String> = vec!["eslint".into(), "src/".into()];
2910 assert!(f.matches(&argv));
2911 }
2912
2913 #[test]
2914 fn eslint_factory_matches_when_format_json_already_set() {
2915 let f = EslintBeholderFactory;
2916 let argv: Vec<String> = vec!["eslint".into(), "--format=json".into(), "src/".into()];
2917 assert!(f.matches(&argv), "must match when --format=json already present");
2918 }
2919
2920 #[test]
2921 fn eslint_factory_declines_non_json_format() {
2922 let f = EslintBeholderFactory;
2923 let argv: Vec<String> = vec!["eslint".into(), "--format=compact".into(), "src/".into()];
2924 assert!(!f.matches(&argv));
2925 }
2926
2927 #[test]
2928 fn eslint_factory_declines_version() {
2929 let f = EslintBeholderFactory;
2930 let argv: Vec<String> = vec!["eslint".into(), "--version".into()];
2931 assert!(!f.matches(&argv));
2932 }
2933
2934 #[test]
2935 fn eslint_factory_declines_env_info() {
2936 let f = EslintBeholderFactory;
2937 let argv: Vec<String> = vec!["eslint".into(), "--env-info".into()];
2938 assert!(!f.matches(&argv));
2939 }
2940
2941 #[test]
2942 fn eslint_factory_declines_non_eslint_command() {
2943 let f = EslintBeholderFactory;
2944 let argv: Vec<String> = vec!["tsc".into(), "--noEmit".into()];
2945 assert!(!f.matches(&argv));
2946 }
2947
2948 #[test]
2949 fn eslint_factory_rewriter_adds_format_json() {
2950 let registry = default_registry();
2951 let result = registry.attach("eslint src/", &BeholderSelect::Auto, false);
2952 assert!(result.beholder.is_some(), "eslint beholder must attach");
2953 assert!(
2954 result.argv.contains(&"--format=json".to_string()),
2955 "rewriter must inject --format=json; got: {:?}",
2956 result.argv
2957 );
2958 assert!(
2959 result.status.text.contains("rewrite="),
2960 "rewrite must be surfaced in status; got: {}",
2961 result.status.text
2962 );
2963 }
2964
2965 #[test]
2966 fn eslint_factory_no_rewrite_when_format_json_present() {
2967 let registry = default_registry();
2968 let result = registry.attach("eslint --format=json src/", &BeholderSelect::Auto, false);
2969 assert!(result.beholder.is_some(), "eslint beholder must attach");
2970 let count = result.argv.iter().filter(|a| a.as_str() == "--format=json").count();
2971 assert_eq!(count, 1, "--format=json must appear exactly once; got: {:?}", result.argv);
2972 }
2973
2974 #[test]
2975 fn eslint_beholder_parse_chunk_buffers_only() {
2976 let json = r#"[{"filePath":"/src/a.js","messages":[]}]"#;
2977 let mut b = EslintBeholder::default();
2978 let events = b.parse_chunk(&dummy_chunk(json.as_bytes()));
2979 assert!(events.is_empty(), "parse_chunk must not emit events; ESLint parses at EOF");
2980 }
2981
2982 #[test]
2983 fn eslint_beholder_on_done_parses_errors_and_warnings() {
2984 use crate::types::TaskRunId;
2985 let json = r#"[
2986 {
2987 "filePath": "/src/foo.ts",
2988 "messages": [
2989 {
2990 "ruleId": "no-unused-vars",
2991 "severity": 2,
2992 "message": "'x' is defined but never used.",
2993 "line": 10,
2994 "column": 5
2995 },
2996 {
2997 "ruleId": "no-console",
2998 "severity": 1,
2999 "message": "Unexpected console statement.",
3000 "line": 20,
3001 "column": 1
3002 }
3003 ],
3004 "errorCount": 1,
3005 "warningCount": 1
3006 }
3007 ]"#;
3008 let mut b = EslintBeholder::default();
3009 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3010 let run_id = TaskRunId::new();
3011 let events = b.on_done(&run_id, 0);
3012 assert_eq!(events.len(), 2);
3013 let err = &events[0];
3014 assert_eq!(err.level, Level::Error);
3015 assert_eq!(err.target, "eslint");
3016 assert_eq!(err.msg, "'x' is defined but never used.");
3017 assert_eq!(err.fields["error"]["code"], "no-unused-vars");
3018 assert_eq!(err.fields["file"]["path"], "/src/foo.ts");
3019 assert_eq!(err.fields["file"]["line"], 10u32);
3020 assert_eq!(err.fields["file"]["col"], 5u32);
3021 let warn = &events[1];
3022 assert_eq!(warn.level, Level::Warn);
3023 assert_eq!(warn.fields["error"]["code"], "no-console");
3024 }
3025
3026 #[test]
3027 fn eslint_beholder_on_done_empty_output() {
3028 use crate::types::TaskRunId;
3029 let mut b = EslintBeholder::default();
3030 let events = b.on_done(&TaskRunId::new(), 0);
3031 assert!(events.is_empty(), "empty buffer must produce no events");
3032 }
3033
3034 #[test]
3035 fn eslint_beholder_on_done_invalid_json_flags_unknown_format() {
3036 use crate::types::TaskRunId;
3037 let bad = b"not json at all";
3038 let mut b = EslintBeholder::default();
3039 b.parse_chunk(&dummy_chunk(bad));
3040 let events = b.on_done(&TaskRunId::new(), 0);
3041 assert!(events.is_empty(), "bad JSON must not produce events");
3042 assert!(
3043 b.unknown_format_reason().is_some(),
3044 "bad JSON must set unknown_format_reason"
3045 );
3046 }
3047
3048 #[test]
3049 fn eslint_beholder_on_done_chunks_split_across_multiple_calls() {
3050 use crate::types::TaskRunId;
3051 let json = r#"[{"filePath":"/a.ts","messages":[{"ruleId":"eqeqeq","severity":2,"message":"Use ===.","line":3,"column":7}],"errorCount":1,"warningCount":0}]"#;
3052 let mid = json.len() / 2;
3053 let mut b = EslintBeholder::default();
3054 b.parse_chunk(&dummy_chunk(json[..mid].as_bytes()));
3055 b.parse_chunk(&dummy_chunk(json[mid..].as_bytes()));
3056 let events = b.on_done(&TaskRunId::new(), 0);
3057 assert_eq!(events.len(), 1, "split-chunk delivery must still parse correctly");
3058 assert_eq!(events[0].fields["error"]["code"], "eqeqeq");
3059 }
3060
3061 #[test]
3062 fn eslint_factory_declares_tool_version_range() {
3063 let f = EslintBeholderFactory;
3064 let range = f.tool_version_range().expect("eslint factory must declare a version range");
3065 assert_eq!(range.min, Some("8.0.0"));
3066 assert!(range.max.is_none());
3067 }
3068
3069 #[test]
3072 fn biome_factory_matches_check() {
3073 let f = BiomeBeholderFactory;
3074 let argv: Vec<String> = vec!["biome".into(), "check".into(), "src/".into()];
3075 assert!(f.matches(&argv));
3076 }
3077
3078 #[test]
3079 fn biome_factory_matches_lint() {
3080 let f = BiomeBeholderFactory;
3081 let argv: Vec<String> = vec!["biome".into(), "lint".into(), "src/".into()];
3082 assert!(f.matches(&argv));
3083 }
3084
3085 #[test]
3086 fn biome_factory_matches_ci() {
3087 let f = BiomeBeholderFactory;
3088 let argv: Vec<String> = vec!["biome".into(), "ci".into(), "src/".into()];
3089 assert!(f.matches(&argv));
3090 }
3091
3092 #[test]
3093 fn biome_factory_declines_format_subcommand() {
3094 let f = BiomeBeholderFactory;
3095 let argv: Vec<String> = vec!["biome".into(), "format".into(), "src/".into()];
3096 assert!(!f.matches(&argv));
3097 }
3098
3099 #[test]
3100 fn biome_factory_declines_version() {
3101 let f = BiomeBeholderFactory;
3102 let argv: Vec<String> = vec!["biome".into(), "--version".into()];
3103 assert!(!f.matches(&argv));
3104 }
3105
3106 #[test]
3107 fn biome_factory_declines_non_json_reporter() {
3108 let f = BiomeBeholderFactory;
3109 let argv: Vec<String> = vec!["biome".into(), "check".into(), "--reporter=github".into()];
3110 assert!(!f.matches(&argv));
3111 }
3112
3113 #[test]
3114 fn biome_factory_matches_when_reporter_json_present() {
3115 let f = BiomeBeholderFactory;
3116 let argv: Vec<String> = vec!["biome".into(), "check".into(), "--reporter=json".into()];
3117 assert!(f.matches(&argv), "must match when --reporter=json already set");
3118 }
3119
3120 #[test]
3121 fn biome_factory_declines_non_biome_command() {
3122 let f = BiomeBeholderFactory;
3123 let argv: Vec<String> = vec!["eslint".into(), "src/".into()];
3124 assert!(!f.matches(&argv));
3125 }
3126
3127 #[test]
3128 fn biome_factory_rewriter_adds_reporter_json() {
3129 let registry = default_registry();
3130 let result = registry.attach("biome check src/", &BeholderSelect::Auto, false);
3131 assert!(result.beholder.is_some(), "biome beholder must attach");
3132 assert!(
3133 result.argv.contains(&"--reporter=json".to_string()),
3134 "rewriter must inject --reporter=json; got: {:?}",
3135 result.argv
3136 );
3137 assert!(
3138 result.status.text.contains("rewrite="),
3139 "rewrite must be surfaced in status; got: {}",
3140 result.status.text
3141 );
3142 }
3143
3144 #[test]
3145 fn biome_factory_no_rewrite_when_reporter_json_present() {
3146 let registry = default_registry();
3147 let result = registry.attach("biome lint --reporter=json src/", &BeholderSelect::Auto, false);
3148 assert!(result.beholder.is_some(), "biome beholder must attach");
3149 let count = result.argv.iter().filter(|a| a.as_str() == "--reporter=json").count();
3150 assert_eq!(count, 1, "--reporter=json must appear exactly once; got: {:?}", result.argv);
3151 }
3152
3153 #[test]
3154 fn biome_beholder_parse_chunk_buffers_only() {
3155 let json = r#"{"diagnostics":[],"summary":{}}"#;
3156 let mut b = BiomeBeholder::default();
3157 let events = b.parse_chunk(&dummy_chunk(json.as_bytes()));
3158 assert!(events.is_empty(), "parse_chunk must not emit events; Biome parses at EOF");
3159 }
3160
3161 #[test]
3162 fn biome_beholder_on_done_parses_error() {
3163 use crate::types::TaskRunId;
3164 let json = r#"{
3165 "diagnostics": [
3166 {
3167 "category": "lint/suspicious/noDoubleEquals",
3168 "severity": "error",
3169 "description": "Use === instead of ==",
3170 "location": {
3171 "path": {"file": "src/foo.ts"}
3172 }
3173 }
3174 ],
3175 "summary": {"changed": 0, "unchanged": 1, "errors": 1}
3176 }"#;
3177 let mut b = BiomeBeholder::default();
3178 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3179 let events = b.on_done(&TaskRunId::new(), 0);
3180 assert_eq!(events.len(), 1);
3181 let ev = &events[0];
3182 assert_eq!(ev.level, Level::Error);
3183 assert_eq!(ev.target, "biome");
3184 assert_eq!(ev.msg, "Use === instead of ==");
3185 assert_eq!(ev.fields["error"]["code"], "lint/suspicious/noDoubleEquals");
3186 assert_eq!(ev.fields["file"]["path"], "src/foo.ts");
3187 }
3188
3189 #[test]
3190 fn biome_beholder_on_done_parses_warning() {
3191 use crate::types::TaskRunId;
3192 let json = r#"{
3193 "diagnostics": [
3194 {
3195 "category": "lint/style/useConst",
3196 "severity": "warning",
3197 "description": "Prefer const over let.",
3198 "location": {"path": {"file": "src/bar.ts"}}
3199 }
3200 ],
3201 "summary": {}
3202 }"#;
3203 let mut b = BiomeBeholder::default();
3204 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3205 let events = b.on_done(&TaskRunId::new(), 0);
3206 assert_eq!(events.len(), 1);
3207 assert_eq!(events[0].level, Level::Warn);
3208 assert_eq!(events[0].fields["file"]["path"], "src/bar.ts");
3209 }
3210
3211 #[test]
3212 fn biome_beholder_on_done_empty_diagnostics() {
3213 use crate::types::TaskRunId;
3214 let json = r#"{"diagnostics":[],"summary":{"changed":0,"unchanged":5,"errors":0}}"#;
3215 let mut b = BiomeBeholder::default();
3216 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3217 let events = b.on_done(&TaskRunId::new(), 0);
3218 assert!(events.is_empty(), "zero diagnostics must produce no events");
3219 }
3220
3221 #[test]
3222 fn biome_beholder_on_done_invalid_json_flags_unknown_format() {
3223 use crate::types::TaskRunId;
3224 let mut b = BiomeBeholder::default();
3225 b.parse_chunk(&dummy_chunk(b"not json"));
3226 let events = b.on_done(&TaskRunId::new(), 0);
3227 assert!(events.is_empty());
3228 assert!(b.unknown_format_reason().is_some());
3229 }
3230
3231 #[test]
3232 fn biome_factory_declares_tool_version_range() {
3233 let f = BiomeBeholderFactory;
3234 let range = f.tool_version_range().expect("biome factory must declare a version range");
3235 assert_eq!(range.min, Some("1.0.0"));
3236 assert!(range.max.is_none());
3237 }
3238
3239 #[test]
3242 fn vitest_factory_matches_bare_vitest() {
3243 let f = VitestBeholderFactory;
3244 let argv: Vec<String> = vec!["vitest".into(), "--run".into()];
3245 assert!(f.matches(&argv));
3246 }
3247
3248 #[test]
3249 fn vitest_factory_matches_with_reporter_json_already_set() {
3250 let f = VitestBeholderFactory;
3251 let argv: Vec<String> = vec!["vitest".into(), "--reporter=json".into()];
3252 assert!(f.matches(&argv), "must match when --reporter=json already present");
3253 }
3254
3255 #[test]
3256 fn vitest_factory_declines_non_json_reporter_eq() {
3257 let f = VitestBeholderFactory;
3258 let argv: Vec<String> = vec!["vitest".into(), "--reporter=verbose".into()];
3259 assert!(!f.matches(&argv));
3260 }
3261
3262 #[test]
3263 fn vitest_factory_declines_non_json_reporter_space() {
3264 let f = VitestBeholderFactory;
3265 let argv: Vec<String> = vec!["vitest".into(), "--reporter".into(), "verbose".into()];
3266 assert!(!f.matches(&argv));
3267 }
3268
3269 #[test]
3270 fn vitest_factory_declines_version() {
3271 let f = VitestBeholderFactory;
3272 let argv: Vec<String> = vec!["vitest".into(), "--version".into()];
3273 assert!(!f.matches(&argv));
3274 }
3275
3276 #[test]
3277 fn vitest_factory_declines_non_vitest_command() {
3278 let f = VitestBeholderFactory;
3279 let argv: Vec<String> = vec!["jest".into(), "--run".into()];
3280 assert!(!f.matches(&argv));
3281 }
3282
3283 #[test]
3284 fn vitest_factory_rewriter_adds_reporter_json() {
3285 let registry = default_registry();
3286 let result = registry.attach("vitest --run", &BeholderSelect::Auto, false);
3287 assert!(result.beholder.is_some(), "vitest beholder must attach");
3288 assert!(
3289 result.argv.contains(&"--reporter=json".to_string()),
3290 "rewriter must inject --reporter=json; got: {:?}",
3291 result.argv
3292 );
3293 assert!(
3294 result.status.text.contains("rewrite="),
3295 "rewrite must be surfaced in status; got: {}",
3296 result.status.text
3297 );
3298 }
3299
3300 #[test]
3301 fn vitest_factory_no_rewrite_when_reporter_json_present() {
3302 let registry = default_registry();
3303 let result = registry.attach("vitest --reporter=json --run", &BeholderSelect::Auto, false);
3304 assert!(result.beholder.is_some());
3305 let count = result.argv.iter().filter(|a| a.as_str() == "--reporter=json").count();
3306 assert_eq!(count, 1, "--reporter=json must appear exactly once; got: {:?}", result.argv);
3307 }
3308
3309 #[test]
3310 fn vitest_factory_declares_tool_version_range() {
3311 let f = VitestBeholderFactory;
3312 let range = f.tool_version_range().expect("vitest factory must declare a version range");
3313 assert_eq!(range.min, Some("1.0.0"));
3314 assert!(range.max.is_none());
3315 }
3316
3317 #[test]
3320 fn jest_factory_matches_bare_jest() {
3321 let f = JestBeholderFactory;
3322 let argv: Vec<String> = vec!["jest".into()];
3323 assert!(f.matches(&argv));
3324 }
3325
3326 #[test]
3327 fn jest_factory_matches_jest_with_path() {
3328 let f = JestBeholderFactory;
3329 let argv: Vec<String> = vec!["jest".into(), "src/foo.test.ts".into()];
3330 assert!(f.matches(&argv));
3331 }
3332
3333 #[test]
3334 fn jest_factory_matches_when_json_already_set() {
3335 let f = JestBeholderFactory;
3336 let argv: Vec<String> = vec!["jest".into(), "--json".into()];
3337 assert!(f.matches(&argv), "must match when --json already present");
3338 }
3339
3340 #[test]
3341 fn jest_factory_declines_version() {
3342 let f = JestBeholderFactory;
3343 let argv: Vec<String> = vec!["jest".into(), "--version".into()];
3344 assert!(!f.matches(&argv));
3345 }
3346
3347 #[test]
3348 fn jest_factory_declines_output_file() {
3349 let f = JestBeholderFactory;
3350 let argv: Vec<String> = vec!["jest".into(), "--outputFile=results.json".into()];
3351 assert!(!f.matches(&argv), "must decline when --outputFile is set (JSON goes to file)");
3352 }
3353
3354 #[test]
3355 fn jest_factory_declines_non_jest_command() {
3356 let f = JestBeholderFactory;
3357 let argv: Vec<String> = vec!["vitest".into()];
3358 assert!(!f.matches(&argv));
3359 }
3360
3361 #[test]
3362 fn jest_factory_rewriter_adds_json_flag() {
3363 let registry = default_registry();
3364 let result = registry.attach("jest src/", &BeholderSelect::Auto, false);
3365 assert!(result.beholder.is_some(), "jest beholder must attach");
3366 assert!(
3367 result.argv.contains(&"--json".to_string()),
3368 "rewriter must inject --json; got: {:?}",
3369 result.argv
3370 );
3371 assert!(
3372 result.status.text.contains("rewrite="),
3373 "rewrite must be surfaced in status; got: {}",
3374 result.status.text
3375 );
3376 }
3377
3378 #[test]
3379 fn jest_factory_no_rewrite_when_json_present() {
3380 let registry = default_registry();
3381 let result = registry.attach("jest --json src/", &BeholderSelect::Auto, false);
3382 assert!(result.beholder.is_some());
3383 let count = result.argv.iter().filter(|a| a.as_str() == "--json").count();
3384 assert_eq!(count, 1, "--json must appear exactly once; got: {:?}", result.argv);
3385 }
3386
3387 #[test]
3388 fn jest_factory_declares_tool_version_range() {
3389 let f = JestBeholderFactory;
3390 let range = f.tool_version_range().expect("jest factory must declare a version range");
3391 assert_eq!(range.min, Some("27.0.0"));
3392 assert!(range.max.is_none());
3393 }
3394
3395 #[test]
3398 fn bun_test_factory_matches_bun_test() {
3399 let f = BunTestBeholderFactory;
3400 let argv: Vec<String> = vec!["bun".into(), "test".into()];
3401 assert!(f.matches(&argv));
3402 }
3403
3404 #[test]
3405 fn bun_test_factory_matches_bun_test_with_path() {
3406 let f = BunTestBeholderFactory;
3407 let argv: Vec<String> = vec!["bun".into(), "test".into(), "src/".into()];
3408 assert!(f.matches(&argv));
3409 }
3410
3411 #[test]
3412 fn bun_test_factory_matches_when_reporter_json_already_set() {
3413 let f = BunTestBeholderFactory;
3414 let argv: Vec<String> = vec!["bun".into(), "test".into(), "--reporter=json".into()];
3415 assert!(f.matches(&argv));
3416 }
3417
3418 #[test]
3419 fn bun_test_factory_declines_bare_bun() {
3420 let f = BunTestBeholderFactory;
3421 let argv: Vec<String> = vec!["bun".into(), "run".into(), "build.ts".into()];
3422 assert!(!f.matches(&argv));
3423 }
3424
3425 #[test]
3426 fn bun_test_factory_declines_non_json_reporter() {
3427 let f = BunTestBeholderFactory;
3428 let argv: Vec<String> = vec!["bun".into(), "test".into(), "--reporter=junit".into()];
3429 assert!(!f.matches(&argv));
3430 }
3431
3432 #[test]
3433 fn bun_test_factory_declines_version() {
3434 let f = BunTestBeholderFactory;
3435 let argv: Vec<String> = vec!["bun".into(), "test".into(), "--version".into()];
3436 assert!(!f.matches(&argv));
3437 }
3438
3439 #[test]
3440 fn bun_test_factory_rewriter_adds_reporter_json() {
3441 let registry = default_registry();
3442 let result = registry.attach("bun test", &BeholderSelect::Auto, false);
3443 assert!(result.beholder.is_some(), "bun-test beholder must attach");
3444 assert!(
3445 result.argv.contains(&"--reporter=json".to_string()),
3446 "rewriter must inject --reporter=json; got: {:?}",
3447 result.argv
3448 );
3449 }
3450
3451 #[test]
3452 fn bun_test_factory_declares_tool_version_range() {
3453 let f = BunTestBeholderFactory;
3454 let range = f.tool_version_range().expect("bun-test factory must declare a version range");
3455 assert_eq!(range.min, Some("1.0.0"));
3456 assert!(range.max.is_none());
3457 }
3458
3459 fn js_test_report_json(success: bool, passed: u32, failed: u32) -> String {
3462 format!(
3463 r#"{{
3464 "success": {success},
3465 "numTotalTests": {total},
3466 "numPassedTests": {passed},
3467 "numFailedTests": {failed},
3468 "numPendingTests": 0,
3469 "testResults": []
3470 }}"#,
3471 total = passed + failed,
3472 )
3473 }
3474
3475 #[test]
3476 fn js_test_beholder_on_done_success_summary() {
3477 use crate::types::TaskRunId;
3478 let json = js_test_report_json(true, 5, 0);
3479 let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3480 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3481 let events = b.on_done(&TaskRunId::new(), 0);
3482 assert_eq!(events.len(), 1, "success run must produce one summary event");
3483 let ev = &events[0];
3484 assert_eq!(ev.target, "vitest");
3485 assert_eq!(ev.level, Level::Info);
3486 assert_eq!(ev.msg, "5 passed");
3487 assert_eq!(ev.fields["build"]["success"], true);
3488 assert_eq!(ev.fields["test"]["passed"], 5u32);
3489 assert_eq!(ev.fields["test"]["failed"], 0u32);
3490 }
3491
3492 #[test]
3493 fn js_test_beholder_on_done_failure_summary() {
3494 use crate::types::TaskRunId;
3495 let json = js_test_report_json(false, 3, 2);
3496 let mut b = JsTestBeholder::new("jest", "27.0", jest_adjust_argv);
3497 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3498 let events = b.on_done(&TaskRunId::new(), 0);
3499 assert_eq!(events.len(), 1, "no assertion results → only summary event");
3500 let ev = &events[0];
3501 assert_eq!(ev.target, "jest");
3502 assert_eq!(ev.level, Level::Error);
3503 assert_eq!(ev.msg, "2 failed, 3 passed");
3504 }
3505
3506 #[test]
3507 fn js_test_beholder_on_done_emits_failed_assertion_events() {
3508 use crate::types::TaskRunId;
3509 let json = r#"{
3510 "success": false,
3511 "numTotalTests": 2,
3512 "numPassedTests": 1,
3513 "numFailedTests": 1,
3514 "numPendingTests": 0,
3515 "testResults": [
3516 {
3517 "testFilePath": "src/foo.test.ts",
3518 "assertionResults": [
3519 {
3520 "fullName": "suite > passes",
3521 "status": "passed",
3522 "failureMessages": []
3523 },
3524 {
3525 "fullName": "suite > fails",
3526 "status": "failed",
3527 "failureMessages": ["Error: expected 1 to equal 2\n at foo (src/foo.test.ts:10)"]
3528 }
3529 ]
3530 }
3531 ]
3532 }"#;
3533 let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3534 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3535 let run_id = TaskRunId::new();
3536 let events = b.on_done(&run_id, 0);
3537 assert_eq!(events.len(), 2, "expected summary + one failure event; got: {events:?}");
3539 let failure = &events[1];
3540 assert_eq!(failure.level, Level::Error);
3541 assert_eq!(failure.target, "vitest::test");
3542 assert_eq!(failure.msg, "Error: expected 1 to equal 2");
3543 assert_eq!(failure.fields["test"]["name"], "suite > fails");
3544 assert_eq!(failure.fields["file"]["path"], "src/foo.test.ts");
3545 }
3546
3547 #[test]
3548 fn js_test_beholder_on_done_multiple_suites_and_failures() {
3549 use crate::types::TaskRunId;
3550 let json = r#"{
3551 "success": false,
3552 "numTotalTests": 4,
3553 "numPassedTests": 2,
3554 "numFailedTests": 2,
3555 "numPendingTests": 0,
3556 "testResults": [
3557 {
3558 "testFilePath": "src/a.test.ts",
3559 "assertionResults": [
3560 { "fullName": "A passes", "status": "passed", "failureMessages": [] },
3561 { "fullName": "A fails", "status": "failed", "failureMessages": ["err A"] }
3562 ]
3563 },
3564 {
3565 "testFilePath": "src/b.test.ts",
3566 "assertionResults": [
3567 { "fullName": "B passes", "status": "passed", "failureMessages": [] },
3568 { "fullName": "B fails", "status": "failed", "failureMessages": ["err B"] }
3569 ]
3570 }
3571 ]
3572 }"#;
3573 let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3574 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3575 let events = b.on_done(&TaskRunId::new(), 0);
3576 assert_eq!(events.len(), 3);
3578 assert_eq!(events[1].fields["file"]["path"], "src/a.test.ts");
3579 assert_eq!(events[2].fields["file"]["path"], "src/b.test.ts");
3580 }
3581
3582 #[test]
3583 fn js_test_beholder_on_done_empty_buffer_returns_no_events() {
3584 use crate::types::TaskRunId;
3585 let b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3586 let mut b = b;
3588 let events = b.on_done(&TaskRunId::new(), 0);
3589 assert!(events.is_empty());
3590 }
3591
3592 #[test]
3593 fn js_test_beholder_on_done_invalid_json_flags_unknown_format() {
3594 use crate::types::TaskRunId;
3595 let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3596 b.parse_chunk(&dummy_chunk(b"not json"));
3597 let events = b.on_done(&TaskRunId::new(), 0);
3598 assert!(events.is_empty());
3599 assert!(b.unknown_format_reason().is_some());
3600 }
3601
3602 #[test]
3603 fn js_test_beholder_on_done_chunks_split_across_calls() {
3604 use crate::types::TaskRunId;
3605 let json = r#"{"success":true,"numTotalTests":1,"numPassedTests":1,"numFailedTests":0,"numPendingTests":0,"testResults":[]}"#;
3606 let mid = json.len() / 2;
3607 let mut b = JsTestBeholder::new("jest", "27.0", jest_adjust_argv);
3608 b.parse_chunk(&dummy_chunk(json[..mid].as_bytes()));
3609 b.parse_chunk(&dummy_chunk(json[mid..].as_bytes()));
3610 let events = b.on_done(&TaskRunId::new(), 0);
3611 assert_eq!(events.len(), 1, "split chunks must still parse correctly");
3612 assert_eq!(events[0].level, Level::Info);
3613 }
3614
3615 #[test]
3616 fn js_test_beholder_failure_msg_uses_first_line_only() {
3617 use crate::types::TaskRunId;
3618 let json = r#"{
3619 "success": false,
3620 "numTotalTests": 1, "numPassedTests": 0, "numFailedTests": 1, "numPendingTests": 0,
3621 "testResults": [{
3622 "testFilePath": "x.test.ts",
3623 "assertionResults": [{
3624 "fullName": "fails",
3625 "status": "failed",
3626 "failureMessages": ["Error: oops\n at Object.<anonymous> (x.test.ts:5:5)\n at ...]"]
3627 }]
3628 }]
3629 }"#;
3630 let mut b = JsTestBeholder::new("vitest", "1.0", vitest_adjust_argv);
3631 b.parse_chunk(&dummy_chunk(json.as_bytes()));
3632 let events = b.on_done(&TaskRunId::new(), 0);
3633 assert_eq!(events.len(), 2);
3634 assert_eq!(events[1].msg, "Error: oops");
3636 }
3637
3638 fn args(s: &str) -> Vec<String> { s.split_whitespace().map(str::to_owned).collect() }
3641
3642 #[test]
3643 fn pytest_factory_matches_direct() {
3644 let f = PytestBeholderFactory;
3645 assert!(f.matches(&args("pytest")));
3646 assert!(f.matches(&args("pytest tests/")));
3647 assert!(f.matches(&args("py.test -v")));
3648 }
3649
3650 #[test]
3651 fn pytest_factory_matches_python_m_pytest() {
3652 let f = PytestBeholderFactory;
3653 assert!(f.matches(&args("python -m pytest")));
3654 assert!(f.matches(&args("python3 -m pytest tests/")));
3655 }
3656
3657 #[test]
3658 fn pytest_factory_declines_version_and_help() {
3659 let f = PytestBeholderFactory;
3660 assert!(!f.matches(&args("pytest --version")));
3661 assert!(!f.matches(&args("pytest -V")));
3662 assert!(!f.matches(&args("pytest --help")));
3663 assert!(!f.matches(&args("pytest -h")));
3664 }
3665
3666 #[test]
3667 fn pytest_factory_declines_collect_only() {
3668 let f = PytestBeholderFactory;
3669 assert!(!f.matches(&args("pytest --collect-only")));
3670 assert!(!f.matches(&args("pytest --co")));
3671 }
3672
3673 #[test]
3674 fn pytest_factory_declines_non_pytest() {
3675 let f = PytestBeholderFactory;
3676 assert!(!f.matches(&args("cargo test")));
3677 assert!(!f.matches(&args("python script.py")));
3678 assert!(!f.matches(&args("python -m flask run")));
3679 }
3680
3681 #[test]
3682 fn pytest_factory_mode_is_parser() {
3683 assert!(matches!(PytestBeholderFactory.mode(), BeholderMode::Parser));
3684 }
3685
3686 fn pytest_output_chunk(lines: &str, seq: u32) -> OutputChunk {
3689 use crate::types::TaskRunId;
3690 OutputChunk {
3691 run_id: TaskRunId::new(),
3692 seq,
3693 offset_ms: 0,
3694 stream: crate::types::Stream::Stdout,
3695 bytes: lines.as_bytes().to_vec(),
3696 }
3697 }
3698
3699 #[test]
3700 fn pytest_beholder_parses_failure_lines() {
3701 let mut b = PytestBeholder::default();
3702 let chunk = pytest_output_chunk(
3703 "FAILED tests/test_foo.py::test_bar - AssertionError: assert 1 == 2\n\
3704 FAILED tests/test_baz.py::test_qux - ZeroDivisionError: division by zero\n",
3705 1,
3706 );
3707 let events = b.parse_chunk(&chunk);
3708 assert_eq!(events.len(), 2);
3709 assert_eq!(events[0].level, Level::Error);
3710 assert_eq!(events[0].target, "pytest::test");
3711 assert_eq!(events[0].msg, "AssertionError: assert 1 == 2");
3712 assert_eq!(events[0].fields["test"]["name"], "test_bar");
3713 assert_eq!(events[0].fields["file"]["path"], "tests/test_foo.py");
3714 assert_eq!(events[1].msg, "ZeroDivisionError: division by zero");
3715 }
3716
3717 #[test]
3718 fn pytest_beholder_parses_error_lines() {
3719 let mut b = PytestBeholder::default();
3720 let chunk = pytest_output_chunk(
3721 "ERROR tests/test_broken.py - ImportError: No module named 'foo'\n",
3722 1,
3723 );
3724 let events = b.parse_chunk(&chunk);
3725 assert_eq!(events.len(), 1);
3726 assert_eq!(events[0].msg, "ImportError: No module named 'foo'");
3727 assert_eq!(events[0].fields["file"]["path"], "tests/test_broken.py");
3728 }
3729
3730 #[test]
3731 fn pytest_beholder_failure_without_reason() {
3732 let mut b = PytestBeholder::default();
3733 let chunk = pytest_output_chunk("FAILED tests/test_foo.py::test_bar\n", 1);
3734 let events = b.parse_chunk(&chunk);
3735 assert_eq!(events.len(), 1);
3736 assert_eq!(events[0].msg, "FAILED tests/test_foo.py::test_bar");
3737 }
3738
3739 #[test]
3740 fn pytest_beholder_ignores_lines_without_py() {
3741 let mut b = PytestBeholder::default();
3742 let chunk = pytest_output_chunk(
3744 "FAILED some_other_thing\nERROR not_a_module\n",
3745 1,
3746 );
3747 let events = b.parse_chunk(&chunk);
3748 assert!(events.is_empty());
3749 }
3750
3751 #[test]
3752 fn pytest_beholder_on_done_success_summary() {
3753 use crate::types::TaskRunId;
3754 let mut b = PytestBeholder::default();
3755 let chunk = pytest_output_chunk(
3756 "collected 5 items\n\
3757 ========================= 5 passed in 0.85s ==========================\n",
3758 1,
3759 );
3760 b.parse_chunk(&chunk);
3761 let events = b.on_done(&TaskRunId::new(), 0);
3762 assert_eq!(events.len(), 1);
3763 let ev = &events[0];
3764 assert_eq!(ev.level, Level::Info);
3765 assert_eq!(ev.target, "pytest");
3766 assert_eq!(ev.msg, "5 passed");
3767 assert_eq!(ev.fields["test"]["passed"], 5u32);
3768 assert_eq!(ev.fields["test"]["failed"], 0u32);
3769 assert_eq!(ev.fields["build"]["success"], true);
3770 }
3771
3772 #[test]
3773 fn pytest_beholder_on_done_failure_summary() {
3774 use crate::types::TaskRunId;
3775 let mut b = PytestBeholder::default();
3776 let chunk = pytest_output_chunk(
3777 "FAILED tests/a.py::t1 - err\n\
3778 ======= 1 failed, 4 passed in 1.23s =======\n",
3779 1,
3780 );
3781 b.parse_chunk(&chunk);
3782 let events = b.on_done(&TaskRunId::new(), 0);
3783 assert_eq!(events.len(), 1);
3784 let ev = &events[0];
3785 assert_eq!(ev.level, Level::Error);
3786 assert_eq!(ev.msg, "1 failed, 4 passed");
3787 assert_eq!(ev.fields["test"]["failed"], 1u32);
3788 assert_eq!(ev.fields["test"]["passed"], 4u32);
3789 assert_eq!(ev.fields["build"]["success"], false);
3790 }
3791
3792 #[test]
3793 fn pytest_beholder_on_done_mixed_summary() {
3794 use crate::types::TaskRunId;
3795 let mut b = PytestBeholder::default();
3796 let chunk = pytest_output_chunk(
3797 "====== 2 failed, 1 error, 5 passed, 1 skipped in 3.14s ======\n",
3798 1,
3799 );
3800 b.parse_chunk(&chunk);
3801 let events = b.on_done(&TaskRunId::new(), 0);
3802 assert_eq!(events.len(), 1);
3803 let ev = &events[0];
3804 assert_eq!(ev.level, Level::Error);
3805 assert_eq!(ev.msg, "2 failed, 1 error, 5 passed, 1 skipped");
3806 assert_eq!(ev.fields["test"]["failed"], 2u32);
3807 assert_eq!(ev.fields["test"]["errors"], 1u32);
3808 assert_eq!(ev.fields["test"]["passed"], 5u32);
3809 assert_eq!(ev.fields["test"]["skipped"], 1u32);
3810 }
3811
3812 #[test]
3813 fn pytest_beholder_on_done_no_summary_returns_empty() {
3814 use crate::types::TaskRunId;
3815 let mut b = PytestBeholder::default();
3816 let events = b.on_done(&TaskRunId::new(), 0);
3817 assert!(events.is_empty());
3818 }
3819
3820 #[test]
3821 fn pytest_beholder_chunks_split_across_calls() {
3822 let line = "FAILED tests/foo.py::bar - AssertionError\n";
3823 let mid = line.len() / 2;
3824 let mut b = PytestBeholder::default();
3825 b.parse_chunk(&pytest_output_chunk(&line[..mid], 1));
3826 let ev1 = b.parse_chunk(&pytest_output_chunk(&line[mid..], 2));
3827 assert_eq!(ev1.len(), 1);
3829 assert_eq!(ev1[0].msg, "AssertionError");
3830 }
3831
3832 #[test]
3835 fn parse_pytest_summary_all_passed() {
3836 let s = parse_pytest_summary("5 passed in 0.85s").unwrap();
3837 assert_eq!(s.passed, 5);
3838 assert_eq!(s.failed, 0);
3839 }
3840
3841 #[test]
3842 fn parse_pytest_summary_mixed() {
3843 let s = parse_pytest_summary("2 failed, 5 passed, 1 skipped in 3.14s").unwrap();
3844 assert_eq!(s.failed, 2);
3845 assert_eq!(s.passed, 5);
3846 assert_eq!(s.skipped, 1);
3847 assert_eq!(s.errors, 0);
3848 }
3849
3850 #[test]
3851 fn parse_pytest_summary_with_errors() {
3852 let s = parse_pytest_summary("1 error, 3 passed in 1.0s").unwrap();
3853 assert_eq!(s.errors, 1);
3854 assert_eq!(s.passed, 3);
3855 }
3856
3857 #[test]
3858 fn parse_pytest_summary_rejects_garbage() {
3859 assert!(parse_pytest_summary("not a summary").is_err());
3860 assert!(parse_pytest_summary("").is_err());
3861 }
3862
3863 #[test]
3866 fn vite_build_factory_matches_bare() {
3867 let f = ViteBuildBeholderFactory;
3868 assert!(f.matches(&args("vite build")));
3869 assert!(f.matches(&args("vite build --outDir dist")));
3870 }
3871
3872 #[test]
3873 fn vite_build_factory_declines_no_build_subcommand() {
3874 let f = ViteBuildBeholderFactory;
3875 assert!(!f.matches(&args("vite")));
3876 assert!(!f.matches(&args("vite preview")));
3877 assert!(!f.matches(&args("vite dev")));
3878 }
3879
3880 #[test]
3881 fn vite_build_factory_declines_help_and_version() {
3882 let f = ViteBuildBeholderFactory;
3883 assert!(!f.matches(&args("vite build --help")));
3884 assert!(!f.matches(&args("vite build -h")));
3885 assert!(!f.matches(&args("vite build --version")));
3886 }
3887
3888 #[test]
3889 fn vite_build_factory_declines_non_vite() {
3890 let f = ViteBuildBeholderFactory;
3891 assert!(!f.matches(&args("cargo build")));
3892 assert!(!f.matches(&args("tsc")));
3893 }
3894
3895 #[test]
3896 fn vite_build_factory_mode_is_parser() {
3897 assert!(matches!(ViteBuildBeholderFactory.mode(), BeholderMode::Parser));
3898 }
3899
3900 fn vite_chunk(lines: &str, seq: u32) -> OutputChunk {
3903 use crate::types::TaskRunId;
3904 OutputChunk {
3905 run_id: TaskRunId::new(),
3906 seq,
3907 offset_ms: 100,
3908 stream: crate::types::Stream::Stdout,
3909 bytes: lines.as_bytes().to_vec(),
3910 }
3911 }
3912
3913 #[test]
3914 fn vite_beholder_parses_warning_line() {
3915 let mut b = ViteBuildBeholder::default();
3916 let chunk = vite_chunk(
3917 "(!) Some chunks are larger than 500 kB after minification.\n",
3918 1,
3919 );
3920 let events = b.parse_chunk(&chunk);
3921 assert_eq!(events.len(), 1);
3922 assert_eq!(events[0].level, Level::Warn);
3923 assert_eq!(events[0].target, "vite-build::warning");
3924 assert_eq!(events[0].msg, "Some chunks are larger than 500 kB after minification.");
3925 assert_eq!(events[0].fields["build"]["warning"], true);
3926 }
3927
3928 #[test]
3929 fn vite_beholder_parses_bundle_line_with_gzip() {
3930 let mut b = ViteBuildBeholder::default();
3931 let chunk = vite_chunk(
3932 "dist/assets/index-CKBFsjV8.js 141.01 kB \u{2502} gzip: 45.33 kB\n",
3933 1,
3934 );
3935 let events = b.parse_chunk(&chunk);
3936 assert_eq!(events.len(), 1);
3937 let ev = &events[0];
3938 assert_eq!(ev.level, Level::Info);
3939 assert_eq!(ev.target, "vite-build::bundle");
3940 assert_eq!(ev.fields["file"]["path"], "dist/assets/index-CKBFsjV8.js");
3941 assert!((ev.fields["build"]["size_kb"].as_f64().unwrap() - 141.01).abs() < 0.01);
3942 assert!((ev.fields["build"]["gzip_kb"].as_f64().unwrap() - 45.33).abs() < 0.01);
3943 }
3944
3945 #[test]
3946 fn vite_beholder_parses_bundle_line_no_gzip() {
3947 let mut b = ViteBuildBeholder::default();
3948 let chunk = vite_chunk("dist/index.html 0.46 kB\n", 1);
3949 let events = b.parse_chunk(&chunk);
3950 assert_eq!(events.len(), 1);
3951 assert_eq!(events[0].target, "vite-build::bundle");
3952 assert!((events[0].fields["build"]["size_kb"].as_f64().unwrap() - 0.46).abs() < 0.01);
3953 assert!(events[0].fields["build"].get("gzip_kb").is_none()
3954 || events[0].fields["build"]["gzip_kb"].is_null());
3955 }
3956
3957 #[test]
3958 fn vite_beholder_parses_built_timing() {
3959 let mut b = ViteBuildBeholder::default();
3960 let chunk = vite_chunk("\u{2713} built in 2.43s\n", 1);
3961 let events = b.parse_chunk(&chunk);
3962 assert_eq!(events.len(), 1);
3963 let ev = &events[0];
3964 assert_eq!(ev.level, Level::Info);
3965 assert_eq!(ev.target, "vite-build");
3966 assert_eq!(ev.fields["build"]["duration_ms"], 2430u64);
3967 assert_eq!(ev.fields["build"]["success"], true);
3968 }
3969
3970 #[test]
3971 fn vite_beholder_parses_built_timing_no_checkmark() {
3972 let mut b = ViteBuildBeholder::default();
3973 let chunk = vite_chunk("built in 0.99s\n", 1);
3974 let events = b.parse_chunk(&chunk);
3975 assert_eq!(events.len(), 1);
3976 assert_eq!(events[0].fields["build"]["duration_ms"], 990u64);
3977 }
3978
3979 #[test]
3980 fn vite_beholder_ignores_unrelated_lines() {
3981 let mut b = ViteBuildBeholder::default();
3982 let chunk = vite_chunk(
3983 "vite v5.4.0 building for production...\n\
3984 \u{2713} 1234 modules transformed.\n",
3985 1,
3986 );
3987 let events = b.parse_chunk(&chunk);
3988 assert!(events.is_empty(), "got unexpected events: {events:?}");
3989 }
3990
3991 #[test]
3992 fn vite_beholder_multiple_events_one_chunk() {
3993 let mut b = ViteBuildBeholder::default();
3994 let chunk = vite_chunk(
3995 "(!) Use of eval is strongly discouraged.\n\
3996 dist/assets/index.js 50.00 kB \u{2502} gzip: 15.00 kB\n\
3997 \u{2713} built in 1.50s\n",
3998 1,
3999 );
4000 let events = b.parse_chunk(&chunk);
4001 assert_eq!(events.len(), 3);
4002 assert_eq!(events[0].level, Level::Warn);
4003 assert_eq!(events[1].target, "vite-build::bundle");
4004 assert_eq!(events[2].target, "vite-build");
4005 }
4006
4007 #[test]
4008 fn vite_beholder_chunks_split_across_calls() {
4009 let line = "(!) Large chunk warning.\n";
4010 let mid = line.len() / 2;
4011 let mut b = ViteBuildBeholder::default();
4012 let ev1 = b.parse_chunk(&vite_chunk(&line[..mid], 1));
4013 let ev2 = b.parse_chunk(&vite_chunk(&line[mid..], 2));
4014 assert!(ev1.is_empty(), "partial line should not emit");
4015 assert_eq!(ev2.len(), 1);
4016 assert_eq!(ev2[0].level, Level::Warn);
4017 }
4018
4019 #[test]
4022 fn parse_vite_built_ms_with_checkmark() {
4023 assert_eq!(parse_vite_built_ms("\u{2713} built in 2.43s"), Some(2430));
4024 }
4025
4026 #[test]
4027 fn parse_vite_built_ms_bare() {
4028 assert_eq!(parse_vite_built_ms("built in 0.50s"), Some(500));
4029 }
4030
4031 #[test]
4032 fn parse_vite_built_ms_not_a_timing_line() {
4033 assert!(parse_vite_built_ms("vite v5.0.0 building for production...").is_none());
4034 assert!(parse_vite_built_ms("").is_none());
4035 }
4036}