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