use super::*;
use std::collections::BTreeSet;
use std::fs;
fn session_project() -> tempfile::TempDir {
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';",
)
.unwrap();
directory
}
fn session_extract(
session: &mut SourceExtractionSession,
root: &Path,
suffix: &str,
source: &str,
) -> Extraction {
let path = root.join(format!("steps.{suffix}"));
let adapter = adapter_for_path(&path).unwrap();
adapter
.extract_with_session(
source,
&SourceFile {
path,
language: adapter.language(),
},
session,
)
.unwrap()
}
const IMPORTED_STEP: &str =
"import { Given } from './support'; Given('shared step', () => work());";
#[test]
fn session_state_initializes_once_per_backend_and_per_run() {
let ruby = SourceAdapterRegistration::with_session(".rb", &crate::ruby::RUBY_ADAPTER);
assert_eq!(ruby.adapter.language(), SourceLanguage::Ruby);
assert!(ruby.discover_dependencies.is_some());
struct Counter(usize);
impl AdapterSessionState for Counter {
fn initialize(_: Option<&Path>, _: &[String], _: &[String]) -> Self {
Self(0)
}
}
struct Other;
impl AdapterSessionState for Other {
fn initialize(_: Option<&Path>, _: &[String], _: &[String]) -> Self {
Self
}
}
let mut session = SourceExtractionSession::default();
session.state::<Counter>().unwrap().0 = 7;
session.initialize::<Counter>();
session.state::<Other>().unwrap();
assert_eq!(session.states.len(), 2);
assert_eq!(session.state::<Counter>().unwrap().0, 7);
assert_eq!(
SourceExtractionSession::default()
.state::<Counter>()
.unwrap()
.0,
0
);
let project = tempfile::tempdir().unwrap();
let mut explicit = SourceExtractionSession::new(project.path());
assert!(explicit.state::<Counter>().is_err());
assert!(!explicit.states.contains_key(&TypeId::of::<Counter>()));
session
.states
.insert(TypeId::of::<Counter>(), Box::new(Other));
assert!(session.state::<Counter>().is_err());
}
#[test]
fn session_mixed_languages_share_cache_and_preserve_findings_in_either_order() {
for suffixes in [["js", "ts", "tsx"], ["tsx", "ts", "js"]] {
let project = session_project();
let root = project.path();
let mut session = SourceExtractionSession::new(root);
assert_eq!(session.states.len(), 2);
let mut definitions = Vec::new();
for suffix in suffixes {
let extracted = session_extract(&mut session, root, suffix, IMPORTED_STEP);
assert_eq!(extracted.definitions.len(), 1, "{suffix}");
assert!(extracted.diagnostics.is_empty(), "{suffix}");
fs::write(
root.join("support.ts"),
"export { When } from '@cucumber/cucumber';",
)
.unwrap();
definitions.extend(extracted.definitions);
}
fs::write(
root.join("support.ts"),
"export { Given } from '@cucumber/cucumber';",
)
.unwrap();
let mut expected = Vec::new();
for suffix in suffixes {
let extracted = session_extract(
&mut SourceExtractionSession::default(),
root,
suffix,
IMPORTED_STEP,
);
assert!(extracted.diagnostics.is_empty());
expected.extend(extracted.definitions);
}
assert_eq!(definitions, expected);
let config = crate::config::Config::load(root, Default::default()).unwrap();
let result = crate::analysis::analyze(definitions, vec![], &config).unwrap();
assert_eq!(
result
.findings
.iter()
.filter(|finding| finding.rule == crate::model::Rule::DuplicateMatcher)
.count(),
2
);
}
}
#[test]
fn session_keeps_failed_root_resolution_snapshot_and_default_fallback() {
let project = session_project();
let missing = project.path().join("initially-missing");
let mut session = SourceExtractionSession::new(&missing);
fs::create_dir(&missing).unwrap();
let actual = session_extract(&mut session, project.path(), "ts", IMPORTED_STEP);
let expected = session_extract(
&mut SourceExtractionSession::default(),
project.path(),
"ts",
IMPORTED_STEP,
);
assert_eq!(actual, expected);
assert_eq!(actual.definitions.len(), 1);
let rejected = session_extract(
&mut SourceExtractionSession::new(&missing),
project.path(),
"ts",
IMPORTED_STEP,
);
assert!(rejected.definitions.is_empty());
assert!(!rejected.diagnostics.is_empty());
}
#[cfg(unix)]
#[test]
fn session_keeps_valid_root_snapshot_when_symlink_is_retargeted() {
let original = session_project();
let other = session_project();
let links = tempfile::tempdir().unwrap();
let link = links.path().join("root");
std::os::unix::fs::symlink(original.path(), &link).unwrap();
let mut session = SourceExtractionSession::new(&link);
fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(other.path(), &link).unwrap();
let actual = session_extract(&mut session, original.path(), "ts", IMPORTED_STEP);
assert_eq!(actual.definitions.len(), 1);
assert!(actual.diagnostics.is_empty());
let rejected = session_extract(
&mut SourceExtractionSession::new(&link),
original.path(),
"ts",
IMPORTED_STEP,
);
assert!(rejected.definitions.is_empty());
assert!(!rejected.diagnostics.is_empty());
}
#[test]
fn session_roots_and_configured_registrations_are_isolated() {
let project = session_project();
let other = session_project();
let mut first = SourceExtractionSession::with_registrations(project.path(), &["SetupA".into()]);
let mut second = SourceExtractionSession::with_registrations(other.path(), &["SetupB".into()]);
let source = "SetupA('custom', () => work()); SetupB('custom', () => work());";
for (session, root, name) in [
(&mut first, project.path(), "SetupA"),
(&mut second, other.path(), "SetupB"),
] {
let result = session_extract(session, root, "ts", source);
assert_eq!(result.definitions.len(), 1);
assert_eq!(result.definitions[0].registration, name);
assert!(result.diagnostics.is_empty());
}
fs::write(
other.path().join("support.ts"),
"export { When } from '@cucumber/cucumber';",
)
.unwrap();
assert_eq!(
session_extract(&mut first, project.path(), "ts", IMPORTED_STEP)
.definitions
.len(),
1
);
assert!(
session_extract(&mut second, other.path(), "ts", IMPORTED_STEP)
.definitions
.is_empty()
);
}
struct DefaultSessionAdapter;
impl SourceAdapter for DefaultSessionAdapter {
fn name(&self) -> &'static str {
"default-session-test"
}
fn language(&self) -> SourceLanguage {
SourceLanguage::JavaScript
}
fn extract(&self, _source: &str, _file: &SourceFile) -> Result<Extraction> {
anyhow::bail!("default extract delegation reached")
}
}
#[test]
fn registry_routes_supported_suffixes() {
let cases = [
("steps.js", SourceLanguage::JavaScript),
("steps.mjs", SourceLanguage::JavaScript),
("steps.cjs", SourceLanguage::JavaScript),
("steps.jsx", SourceLanguage::JavaScript),
("steps.ts", SourceLanguage::TypeScript),
("steps.mts", SourceLanguage::TypeScript),
("steps.cts", SourceLanguage::TypeScript),
("steps.tsx", SourceLanguage::Tsx),
("steps.rb", SourceLanguage::Ruby),
];
for (path, expected) in cases {
let adapter = adapter_for_path(Path::new(path)).expect(path);
assert_eq!(adapter.language(), expected, "{path}");
let by_language = adapter_for_language(expected);
assert_eq!(by_language.language(), expected, "{path}");
assert_eq!(by_language.name(), adapter.name(), "{path}");
assert!(!adapter.name().is_empty());
}
assert_eq!(registered_suffixes().len(), cases.len());
}
#[test]
fn source_languages_select_their_registered_tree_sitter_grammar() {
let cases = [
(SourceLanguage::JavaScript, "const value = 1;"),
(SourceLanguage::TypeScript, "const value: number = 1;"),
(SourceLanguage::Tsx, "const value = <div />;"),
(SourceLanguage::Ruby, "Given('a') { work() }"),
];
for (language, source) in cases {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&grammar_for_language(language))
.unwrap();
let tree = parser.parse(source, None).unwrap();
assert!(!tree.root_node().has_error(), "{language:?}");
}
}
#[test]
fn every_registration_has_a_unique_suffix_and_produces_shared_ir() {
let mut suffixes = BTreeSet::new();
for registration in SOURCE_ADAPTER_REGISTRY {
assert!(suffixes.insert(registration.suffix), "duplicate suffix");
assert!(
registration.initialize_session.is_some(),
"{} is missing session initialization",
registration.suffix
);
let file = SourceFile {
path: PathBuf::from(format!("steps{}", registration.suffix)),
language: registration.adapter.language(),
};
let extraction = registration
.adapter
.extract(
if file.language == SourceLanguage::Ruby {
"Given('a registered step') { run() }"
} else {
"Given('a registered step', () => run())"
},
&file,
)
.unwrap();
assert_eq!(extraction.definitions.len(), 1, "{}", registration.suffix);
assert!(extraction.diagnostics.is_empty(), "{}", registration.suffix);
}
}
#[test]
fn registry_rejects_declarations_maps_and_unknown_languages() {
for path in [
"types.d.ts",
"types.d.mts",
"types.d.cts",
".d.ts",
".d.mts",
".d.cts",
"steps.js.map",
"steps.py",
] {
assert!(adapter_for_path(Path::new(path)).is_none(), "{path}");
}
}
#[cfg(unix)]
#[test]
fn registry_classifies_non_utf8_names_by_their_ascii_extension() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let source = PathBuf::from(OsString::from_vec(b"steps-\xff.ts".to_vec()));
assert_eq!(language_for_path(&source), Some(SourceLanguage::TypeScript));
let declaration = PathBuf::from(OsString::from_vec(b"types-\xff.d.ts".to_vec()));
assert!(adapter_for_path(&declaration).is_none());
}
#[test]
fn adapter_session_default_preserves_existing_extract_implementations() {
let directory = tempfile::tempdir().unwrap();
let file = SourceFile {
path: directory.path().join("steps.js"),
language: SourceLanguage::JavaScript,
};
let mut session = SourceExtractionSession::new(directory.path());
let adapter = DefaultSessionAdapter;
assert_eq!(adapter.name(), "default-session-test");
assert_eq!(adapter.language(), SourceLanguage::JavaScript);
let error = adapter
.extract_with_session("", &file, &mut session)
.unwrap_err();
assert_eq!(error.to_string(), "default extract delegation reached");
}
#[test]
fn language_lookup_and_suffix_routing_agree_on_one_adapter_table() {
for registration in SOURCE_ADAPTER_REGISTRY {
let expected = registration.adapter.language();
let name = format!("steps{}", registration.suffix);
let routed = adapter_for_path(Path::new(&name)).expect(&name);
assert!(
std::ptr::addr_eq(
routed as *const dyn SourceAdapter,
registration.adapter as *const dyn SourceAdapter
),
"{}",
registration.suffix
);
let by_language = adapter_for_language(expected);
assert!(
std::ptr::addr_eq(
by_language as *const dyn SourceAdapter,
registration.adapter as *const dyn SourceAdapter
),
"{}",
registration.suffix
);
assert_eq!(language_for_path(Path::new(&name)), Some(expected));
}
for language in [
SourceLanguage::JavaScript,
SourceLanguage::TypeScript,
SourceLanguage::Tsx,
] {
assert_eq!(adapter_for_language(language).language(), language);
}
}
#[test]
fn language_routed_and_suffix_routed_extraction_share_session_state() {
let project = session_project();
for suffix in ["js", "ts", "tsx"] {
for language_route_first in [true, false] {
let path = project.path().join(format!("steps.{suffix}"));
let language = language_for_path(&path).expect(suffix);
let file = SourceFile { path, language };
let suffix_routed = adapter_for_path(&file.path).expect(suffix);
let language_routed = adapter_for_language(language);
let mut session = SourceExtractionSession::new(project.path());
let (first, second) = if language_route_first {
(language_routed, suffix_routed)
} else {
(suffix_routed, language_routed)
};
let results = [
first.extract_with_session(IMPORTED_STEP, &file, &mut session),
second.extract_with_session(IMPORTED_STEP, &file, &mut session),
];
let [first, second] = results;
let extraction = first.unwrap();
assert_eq!(&extraction, second.as_ref().unwrap(), "{suffix}");
assert_eq!(extraction.definitions.len(), 1, "{suffix}");
assert!(extraction.diagnostics.is_empty(), "{suffix}");
assert_eq!(session.states.len(), 2, "{suffix}");
}
}
}
#[test]
fn legacy_diagnostics_preserve_message_classification_and_mutation() {
for (message, incomplete, unparseable) in [
("ordinary warning", false, false),
(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX, true, false),
(UNPARSEABLE_SOURCE_DIAGNOSTIC_PREFIX, true, true),
] {
let mut diagnostic = ExtractionDiagnostic::new(
ExtractionDiagnosticLevel::Warning,
SourceLocation::new("steps.ts", 1, 1, 1, 1),
message,
);
assert_eq!(is_completeness_diagnostic(&diagnostic), incomplete);
assert_eq!(is_unparseable_diagnostic(&diagnostic), unparseable);
diagnostic.message = "ordinary warning".into();
assert!(!is_completeness_diagnostic(&diagnostic));
assert!(!is_unparseable_diagnostic(&diagnostic));
}
}
#[test]
fn finalization_scopes_preserve_independent_final_findings() {
use crate::model::Rule;
use UncertaintyCause::{Handler, Registration, Source};
let project = session_project();
let root = project.path();
let mut session = SourceExtractionSession::new(root);
let mut definitions = session_extract(
&mut session,
root,
"ts",
"Given('unrelated control', () => check()); Given('unrelated control', () => check());",
)
.definitions;
definitions.extend(session_extract(&mut session, root, "js",
"Given('opens red panel', () => work()); Given('opens red panel', () => work()); Given('opens blue panel', () => work());").definitions);
let config = crate::config::Config::load(root, Default::default()).unwrap();
let definition = UncertaintyScope::Definition(definitions[4].location.clone());
let file = UncertaintyScope::File(root.join("steps.js"));
let registry = UncertaintyScope::Registry(SourceLanguage::JavaScript);
for (scope, cause, retained, matchers, handlers) in [
(definition.clone(), Registration, 4, 2, 0),
(file.clone(), Registration, 2, 1, 0),
(definition, Handler, 5, 2, 0),
(file, Handler, 5, 2, 0),
(registry, Source, 5, 2, 2),
] {
let mut actual = definitions.clone();
let mut finalization = SourceFinalization::default();
finalization
.uncertainties
.push(SourceUncertainty::new(scope, cause, "test evidence"));
finalization.apply(&mut actual);
assert_eq!(actual.len(), retained, "{finalization:?}");
let result = crate::analysis::analyze(actual, vec![], &config).unwrap();
for (rule, expected) in [
(Rule::DuplicateMatcher, matchers),
(Rule::DuplicateHandler, handlers),
] {
let actual = result.findings.iter().filter(|f| f.rule == rule).count();
assert_eq!(actual, expected, "{finalization:?}");
}
}
}
#[test]
fn registry_uncertainty_isolates_languages_but_groups_ecmascript_variants() {
use crate::model::Rule;
let project = session_project();
let root = project.path();
let mut session = SourceExtractionSession::new(root);
let mut definitions = Vec::new();
for suffix in ["js", "ts", "tsx", "rb"] {
let source = if suffix == "rb" {
"Given('shared step') { work() }; Given('shared step') { work() }"
} else {
IMPORTED_STEP
};
definitions.extend(session_extract(&mut session, root, suffix, source).definitions);
}
let config = crate::config::Config::load(root, Default::default()).unwrap();
for (language, retained, matcher_findings) in [
(SourceLanguage::JavaScript, 2, 1),
(SourceLanguage::TypeScript, 2, 1),
(SourceLanguage::Tsx, 2, 1),
(SourceLanguage::Ruby, 3, 2),
] {
let mut actual = definitions.clone();
let mut finalization = SourceFinalization::default();
finalization.uncertainties.push(SourceUncertainty::new(
UncertaintyScope::Registry(language),
UncertaintyCause::Registration,
"registry effect",
));
finalization.apply(&mut actual);
assert_eq!(actual.len(), retained, "{language:?}");
let result = crate::analysis::analyze_with_diagnostics(actual, vec![], &config)
.unwrap()
.result;
let rule = Rule::DuplicateMatcher;
let actual = result.findings.iter().filter(|f| f.rule == rule).count();
assert_eq!(actual, matcher_findings, "{language:?}");
}
}
#[test]
fn public_session_finalization_applies_late_ruby_effects_in_either_order() {
for mutation_first in [false, true] {
let project = session_project();
let root = project.path();
let mut session = SourceExtractionSession::new(root);
let files: Vec<_> = ["registrations.rb", "mutation.rb"]
.into_iter()
.map(|name| SourceFile {
path: root.join(name),
language: SourceLanguage::Ruby,
})
.collect();
let sources = [
"Given('shared') { work() }; Then('shared') { work() }",
"def Given(text); :replaced; end",
];
let mut definitions = session_extract(&mut session, root, "ts", IMPORTED_STEP).definitions;
for index in if mutation_first { [1, 0] } else { [0, 1] } {
let extracted = adapter_for_language(SourceLanguage::Ruby)
.extract_with_session(sources[index], &files[index], &mut session)
.unwrap();
definitions.extend(extracted.definitions);
}
if !mutation_first {
assert_eq!(definitions.len(), 3);
}
let first = session.finalize(&files, &mut definitions).unwrap();
assert!(!first.uncertainties.is_empty());
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].location.path, root.join("steps.ts"));
let once = definitions.clone();
assert_eq!(session.finalize(&files, &mut definitions).unwrap(), first);
assert_eq!(definitions, once);
}
}