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