use cuke_dedup::analysis::{analyze, analyze_with_diagnostics};
use cuke_dedup::config::{Config, ConfigOverrides, ReporterKind};
use cuke_dedup::discovery::{SourceFile, SourceLanguage};
use cuke_dedup::model::SourceLocation;
use cuke_dedup::model::{AnalysisResult, Rule, Severity};
use cuke_dedup::modes::{BaselineFile, BASELINE_SCHEMA_VERSION};
use cuke_dedup::reporters::{
render_html, render_json, render_jsonl, render_sarif, write_terminal, ExecutionMetrics,
ReportContext,
};
use cuke_dedup::source_adapter::{
Extraction, ExtractionDiagnostic, ExtractionDiagnosticLevel, SourceAdapter,
SourceAdapterRegistration, SourceParameterType, SOURCE_ADAPTER_REGISTRY,
};
use cuke_dedup::typescript;
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
fn registered_adapter(language: SourceLanguage) -> &'static dyn SourceAdapter {
SOURCE_ADAPTER_REGISTRY
.iter()
.map(|registration| registration.adapter)
.find(|adapter| adapter.language() == language)
.expect("an adapter is registered for the language")
}
#[test]
fn extraction_session_preserves_public_constructors_auto_traits_and_adapter_defaults() {
use cuke_dedup::source_adapter::SourceExtractionSession;
use std::panic::{RefUnwindSafe, UnwindSafe};
fn assert_auto_traits<T: Send + Sync + Unpin + UnwindSafe + RefUnwindSafe>() {}
assert_auto_traits::<SourceExtractionSession>();
struct ExternalAdapter;
impl SourceAdapter for ExternalAdapter {
fn name(&self) -> &'static str {
"external"
}
fn language(&self) -> SourceLanguage {
SourceLanguage::JavaScript
}
fn extract(&self, _: &str, _: &SourceFile) -> anyhow::Result<Extraction> {
anyhow::bail!("external adapter reached")
}
}
let project = tempfile::tempdir().unwrap();
let file = SourceFile {
path: project.path().join("steps.js"),
language: SourceLanguage::JavaScript,
};
let adapter: &dyn SourceAdapter = &ExternalAdapter;
assert!(adapter.discover_by_default());
assert!(!adapter.supports_indirect_usage());
assert_eq!(adapter.inspect_source(&file, &[]), None);
let configured = BTreeMap::from([("custom".to_owned(), "[a-z]+".to_owned())]);
assert_eq!(
adapter.resolve_parameter_types(Vec::new(), &configured),
(configured.clone(), Vec::new())
);
let declaration = SourceParameterType::new(
Some("custom".to_owned()),
Some(".+".to_owned()),
SourceLocation::new(PathBuf::from("steps.js"), 1, 1, 1, 2),
);
let (patterns, diagnostics) = adapter.resolve_parameter_types(vec![declaration], &configured);
assert!(patterns.is_empty());
assert!(!diagnostics.is_empty());
for mut session in [
SourceExtractionSession::default(),
SourceExtractionSession::new(project.path()),
SourceExtractionSession::with_registrations(project.path(), &["Setup".into()]),
] {
assert_eq!(
adapter
.extract_with_session("", &file, &mut session)
.unwrap_err()
.to_string(),
"external adapter reached"
);
}
}
fn reporter_name(reporter: ReporterKind) -> &'static str {
match reporter {
ReporterKind::Terminal => "terminal",
ReporterKind::Json => "json",
ReporterKind::Jsonl => "jsonl",
ReporterKind::Html => "html",
ReporterKind::Sarif => "sarif",
_ => "future reporter",
}
}
#[test]
fn external_callers_can_preserve_partial_results_and_operational_diagnostics() {
let definitions = typescript::extract(
"Given(/a{1000000}/, () => work());",
&SourceFile {
path: PathBuf::from("steps.ts"),
language: SourceLanguage::TypeScript,
},
)
.unwrap();
let directory = tempfile::tempdir().unwrap();
let config = Config::load(directory.path(), ConfigOverrides::default()).unwrap();
let outcome = analyze_with_diagnostics(definitions.clone(), Vec::new(), &config).unwrap();
assert_eq!(outcome.result.definitions.len(), 1);
assert_eq!(outcome.incomplete.len(), 1);
assert!(outcome.incomplete[0].contains("regex resource limit"));
let error = analyze(definitions, Vec::new(), &config).unwrap_err();
assert!(error.to_string().contains("regex resource limit"));
}
#[test]
fn sessionless_file_extraction_uses_the_nearest_package_boundary() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("support.ts"),
"export { Given } from '@cucumber/cucumber';\n",
)
.unwrap();
let steps = directory.path().join("nested/deeper/steps.ts");
fs::create_dir_all(steps.parent().unwrap()).unwrap();
fs::write(
&steps,
"import { Given } from '../../support';\nGiven('nested public API step', () => work());\n",
)
.unwrap();
let definitions = typescript::extract_file(&SourceFile {
path: steps,
language: SourceLanguage::TypeScript,
})
.unwrap();
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].matcher, "nested public API step");
}
#[test]
fn extensible_public_outputs_use_stable_constructors() {
let overrides = ConfigOverrides {
config_file: None,
definitions: None,
features: None,
exclude: None,
exclude_defaults: None,
include_hidden: None,
reporters: Some(vec![ReporterKind::Json]),
output: None,
threshold: None,
require_features: None,
no_metrics: None,
rules: BTreeMap::<Rule, Severity>::new(),
};
assert_eq!(overrides.reporters, Some(vec![ReporterKind::Json]));
let metrics = ExecutionMetrics::new(1, 2, 1.0, 2.0, 3.0);
assert_eq!(metrics.files_discovered, 3);
let diagnostic = ExtractionDiagnostic::new(
ExtractionDiagnosticLevel::Warning,
SourceLocation::new("steps.ts", 1, 1, 1, 2),
"dynamic matcher",
);
let extraction = Extraction::new(Vec::new(), vec![diagnostic]);
assert_eq!(extraction.diagnostics.len(), 1);
let baseline = BaselineFile::new(BTreeMap::from([("finding".to_owned(), 1)]));
assert_eq!(baseline.fingerprints["finding"], 1);
let default_baseline = BaselineFile::default();
assert_eq!(default_baseline.schema_version, BASELINE_SCHEMA_VERSION);
assert!(default_baseline.fingerprints.is_empty());
let registration =
SourceAdapterRegistration::new(".custom", SOURCE_ADAPTER_REGISTRY[0].adapter);
assert_eq!(registration.suffix, ".custom");
let all_rules: &[Rule] = Rule::ALL;
let duplication_rules: &[Rule] = Rule::DUPLICATION_THRESHOLD;
assert!(all_rules.len() > duplication_rules.len());
assert_eq!(reporter_name(ReporterKind::Json), "json");
}
#[test]
fn report_context_is_the_single_public_rendering_entry_point() {
let root = PathBuf::from("/repo");
let result = AnalysisResult::new(Vec::new(), Vec::new(), Vec::new());
let context = ReportContext::new(&result, &root, 5.0);
assert!(render_json(&context)
.unwrap()
.contains("\"threshold\": 5.0"));
assert!(render_jsonl(&context)
.unwrap()
.contains("\"type\":\"summary\""));
assert!(render_html(&context).unwrap().contains("<!doctype html>"));
assert!(render_sarif(&context)
.unwrap()
.contains("\"version\": \"2.1.0\""));
let mut terminal = Vec::new();
write_terminal(&context, &mut terminal).unwrap();
assert!(String::from_utf8(terminal)
.unwrap()
.contains("Analyzed 0 definitions"));
}
#[test]
fn configured_registrations_enable_lowercase_global_step_names() {
use cuke_dedup::source_adapter::SourceExtractionSession;
let project = tempfile::tempdir().unwrap();
let file = SourceFile {
path: project.path().join("steps.ts"),
language: SourceLanguage::TypeScript,
};
let adapter = registered_adapter(SourceLanguage::TypeScript);
let source = "given('a lowercase global step', () => work());";
let mut plain = SourceExtractionSession::new(project.path());
assert!(adapter
.extract_with_session(source, &file, &mut plain)
.unwrap()
.definitions
.is_empty());
let mut configured =
SourceExtractionSession::with_registrations(project.path(), &["given".into()]);
assert_eq!(
adapter
.extract_with_session(source, &file, &mut configured)
.unwrap()
.definitions
.len(),
1
);
}
#[test]
fn local_commonjs_and_esm_barrel_imports_resolve_registrations_in_process() {
use cuke_dedup::source_adapter::SourceExtractionSession;
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("package.json"), "{}").unwrap();
fs::write(
project.path().join("barrel.js"),
"const { Given } = require('@cucumber/cucumber');\nmodule.exports = { Given };\n",
)
.unwrap();
let adapter = registered_adapter(SourceLanguage::JavaScript);
let file = SourceFile {
path: project.path().join("steps.js"),
language: SourceLanguage::JavaScript,
};
let extract = |source: &str| {
let mut session = SourceExtractionSession::new(project.path());
adapter
.extract_with_session(source, &file, &mut session)
.unwrap()
.definitions
.len()
};
assert_eq!(
extract(
"const { Given: reg } = require('./barrel');\nreg('cjs a', () => {});\nreg('cjs b', () => {});"
),
2
);
assert_eq!(
extract(
"import { Given as reg } from './barrel';\nreg('esm a', () => {});\nreg('esm b', () => {});"
),
2
);
assert_eq!(extract("const missing = require();\n"), 0);
assert_eq!(
extract("const { Given: reg } = require('./nope');\nreg('x', () => {});"),
0
);
assert_eq!(extract("const = 5;\nvar ;\nlet x;"), 0);
}
#[test]
fn local_barrel_re_exporting_the_bdd_factory_resolves_through_require() {
use cuke_dedup::source_adapter::SourceExtractionSession;
let project = tempfile::tempdir().unwrap();
fs::write(project.path().join("package.json"), "{}").unwrap();
fs::write(
project.path().join("barrel.js"),
"const { createBdd } = require('playwright-bdd');\nmodule.exports = { createBdd };\n",
)
.unwrap();
let adapter = registered_adapter(SourceLanguage::JavaScript);
let file = SourceFile {
path: project.path().join("steps.js"),
language: SourceLanguage::JavaScript,
};
let mut session = SourceExtractionSession::new(project.path());
let definitions = adapter
.extract_with_session(
"const { createBdd } = require('./barrel');\nconst { Given } = createBdd();\nGiven('a step', () => {});\nGiven('a step', () => {});",
&file,
&mut session,
)
.unwrap()
.definitions;
assert_eq!(definitions.len(), 2);
}
#[test]
fn adapters_can_classify_diagnostics_without_language_specific_messages() {
use cuke_dedup::source_adapter::ExtractionDiagnosticKind;
let diagnostic = ExtractionDiagnostic::with_kind(
ExtractionDiagnosticKind::Incomplete,
ExtractionDiagnosticLevel::Warning,
SourceLocation::new("steps.rb", 1, 1, 1, 1),
"external glue unavailable",
);
assert_eq!(diagnostic.kind, ExtractionDiagnosticKind::Incomplete);
assert_eq!(diagnostic.message, "external glue unavailable");
}
#[test]
fn ruby_usage_metadata_and_legacy_analysis_remain_conservative() {
let root = tempfile::tempdir().unwrap();
let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
let file = SourceFile {
path: root.path().join("steps.rb"),
language: SourceLanguage::Ruby,
};
let extracted = registered_adapter(SourceLanguage::Ruby).extract(
"Given('target') { first() }; Then('unused') { second() }; def helper; step 'target'; end", &file).unwrap();
let features = cuke_dedup::gherkin::extract(
"Feature: usage\n Scenario: other\n Given other\n",
&root.path().join("suite.feature"),
)
.unwrap();
let legacy =
analyze_with_diagnostics(extracted.definitions.clone(), features.clone(), &config).unwrap();
assert!(!legacy.incomplete.is_empty());
assert!(!legacy
.result
.findings
.iter()
.any(|f| f.rule == Rule::UnusedDefinition));
let mut usage = extracted.indirect_usage.unwrap();
let known = cuke_dedup::analysis::analyze_with_step_usage(
extracted.definitions.clone(),
features.clone(),
&config,
&usage,
)
.unwrap();
let unused: Vec<_> = known
.result
.findings
.iter()
.filter(|f| f.rule == Rule::UnusedDefinition)
.collect();
assert_eq!(unused.len(), 1);
assert!(unused[0].message.contains("`unused`"));
assert!(known.incomplete.is_empty());
usage.extend(None);
assert!(usage.unknown);
}
#[test]
fn ruby_extraction_flows_through_the_public_diagnostic_analysis_and_report_api() {
use cuke_dedup::source_adapter::ExtractionDiagnosticKind;
let root = tempfile::tempdir().unwrap();
let file = SourceFile {
path: root.path().join("steps.rb"),
language: SourceLanguage::Ruby,
};
let source = format!(
"Given('kept') {{ kept }}\nGiven('kept') {{ other }}\n\nGiven(/{}/) {{ work }}\n",
"a".repeat(1024 * 1024 + 1)
);
let extracted = registered_adapter(SourceLanguage::Ruby)
.extract(&source, &file)
.unwrap();
assert_eq!(extracted.definitions.len(), 3);
let diagnostic = extracted
.diagnostics
.iter()
.find(|diagnostic| diagnostic.message.contains("regular expression"))
.unwrap();
assert_eq!(diagnostic.kind, ExtractionDiagnosticKind::Incomplete);
assert_eq!(diagnostic.level, ExtractionDiagnosticLevel::Warning);
assert_eq!(
(diagnostic.location.line, diagnostic.location.column),
(4, 1)
);
let config = Config::load(root.path(), ConfigOverrides::default()).unwrap();
let outcome =
analyze_with_diagnostics(extracted.definitions.clone(), Vec::new(), &config).unwrap();
assert_eq!(outcome.result.definitions.len(), 3);
assert!(outcome
.incomplete
.iter()
.any(|message| message.contains("steps.rb:4:1 exceeds")
&& message.contains("regex resource limit")));
assert_eq!(outcome.result.findings.len(), 1);
assert_eq!(outcome.result.findings[0].rule, Rule::DuplicateMatcher);
let error = analyze(extracted.definitions, Vec::new(), &config).unwrap_err();
assert!(error.to_string().contains("regex resource limit"));
let context = ReportContext::new(&outcome.result, root.path(), 5.0);
assert!(render_json(&context)
.unwrap()
.contains("\"path\": \"steps.rb\""));
assert!(render_jsonl(&context)
.unwrap()
.contains("\"type\":\"summary\""));
assert!(render_html(&context).unwrap().contains("steps.rb:1:1"));
assert!(render_sarif(&context)
.unwrap()
.contains("\"uri\": \"steps.rb\""));
let mut terminal = Vec::new();
write_terminal(&context, &mut terminal).unwrap();
let terminal = String::from_utf8(terminal).unwrap();
assert!(terminal.contains("Analyzed 3 definitions"));
assert!(terminal.contains("steps.rb:1:1"));
}