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