use super::*;
use crate::model::Rule;
use std::fs;
use std::path::PathBuf;
#[test]
fn control_flow_behavior_events_map_typescript_node_kinds_only() {
use crate::model::{BehaviorEvent, ControlFlowOperation};
let cases = [
("if_statement", ControlFlowOperation::If),
("switch_statement", ControlFlowOperation::Switch),
("for_statement", ControlFlowOperation::For),
("for_in_statement", ControlFlowOperation::ForIn),
("while_statement", ControlFlowOperation::While),
("do_statement", ControlFlowOperation::Do),
("return_statement", ControlFlowOperation::Return),
("throw_statement", ControlFlowOperation::Throw),
];
for (node_kind, operation) in cases {
let event = super::handler::control_flow_behavior_event(node_kind);
assert_eq!(event, BehaviorEvent::ControlFlow(operation));
assert_eq!(event.into_legacy(), node_kind);
}
let unknown = super::handler::control_flow_behavior_event("custom_statement");
assert_eq!(
unknown,
BehaviorEvent::Legacy("custom_statement".to_owned())
);
assert_eq!(unknown.into_legacy(), "custom_statement");
}
fn extract_ts(source: &str) -> Vec<crate::model::StepDefinition> {
extract(source, &file(SourceLanguage::TypeScript)).unwrap()
}
fn file(language: SourceLanguage) -> SourceFile {
SourceFile {
path: PathBuf::from("features/steps/example.ts"),
language,
}
}
#[test]
fn framework_entrypoints_preserve_duplicate_findings_and_attribution() {
let directory = tempfile::tempdir().unwrap();
let config = crate::config::Config::load(directory.path(), Default::default()).unwrap();
for (module, framework) in [
("@cucumber/cucumber", Framework::CucumberJs),
("cucumber", Framework::CucumberJs),
("playwright-bdd", Framework::PlaywrightBdd),
("playwright-bdd/decorators", Framework::PlaywrightBdd),
(
"@badeball/cypress-cucumber-preprocessor",
Framework::CypressCucumber,
),
(
"cypress-cucumber-preprocessor/steps",
Framework::CypressCucumber,
),
("cypress-cucumber-preprocessor", Framework::Unknown),
("playwright-bdd/decorators-extra", Framework::Unknown),
("@cucumber/cucumber-extra", Framework::Unknown),
("@cucumber/cucumber/unknown", Framework::Unknown),
] {
for binding in [
format!("import {{ Given as Setup }} from '{module}';"),
format!("const {{ Given: Setup }} = require('{module}');"),
] {
let body = if module == "playwright-bdd/decorators" {
"class Steps {
@Setup('same step') first() { firstAction(); }
@Setup('same step') second() { secondAction(); }
}"
} else {
"Setup('same step', () => firstAction());
Setup('same step', () => secondAction());"
};
let definitions = extract_ts(&format!("{binding}\n{body}"));
let supported = framework != Framework::Unknown;
assert_eq!(
definitions.len(),
if supported { 2 } else { 0 },
"{binding}"
);
assert!(
definitions
.iter()
.all(|definition| definition.framework == framework),
"{binding}"
);
let result = crate::analysis::analyze(definitions, vec![], &config).unwrap();
assert_eq!(
result
.findings
.iter()
.filter(|finding| finding.rule == Rule::DuplicateMatcher)
.count(),
usize::from(supported),
"{binding}",
);
if !supported {
assert!(result.findings.is_empty(), "{binding}");
}
}
}
}
#[test]
fn extracts_cucumber_import_alias_and_regex() {
let source = r#"
import { Given as G, Then } from '@cucumber/cucumber';
G('a user named {string}', async (name: string) => {
await createUser(name);
});
Then(/^the user exists$/, function () { expect(this.user).toBeTruthy(); });
"#;
let definitions = extract(source, &file(SourceLanguage::TypeScript)).unwrap();
assert_eq!(definitions.len(), 2);
assert_eq!(definitions[0].registration, "Given");
assert_eq!(definitions[0].normalized_matcher, "a user named {string}");
assert_eq!(definitions[1].matcher_kind, MatcherKind::RegularExpression);
assert_eq!(definitions[1].matcher, "^the user exists$");
}
#[test]
fn extracts_playwright_bdd_destructuring_alias() {
let source = r#"
import { createBdd } from 'playwright-bdd';
const { Given: Setup, Then } = createBdd(test);
Setup(`a loaded page`, async ({ page }) => { await page.goto('/'); });
"#;
let definitions = extract(source, &file(SourceLanguage::TypeScript)).unwrap();
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].framework, Framework::PlaywrightBdd);
assert_eq!(definitions[0].registration, "Given");
}
#[test]
fn extracts_cypress_cucumber_preprocessor_definitions() {
let source = r#"
import { Given as Setup, Then } from '@badeball/cypress-cucumber-preprocessor';
Setup('a Cypress project', () => cy.visit('/'));
Then('the page is visible', () => cy.get('main').should('be.visible'));
"#;
let definitions = extract(source, &file(SourceLanguage::TypeScript)).unwrap();
assert_eq!(definitions.len(), 2);
assert!(definitions
.iter()
.all(|definition| definition.framework == Framework::CypressCucumber));
}
#[test]
fn extracts_supported_legacy_cucumber_registration_modules_in_esm_and_cjs() {
for (module, expected_framework) in [
("cucumber", Framework::CucumberJs),
(
"cypress-cucumber-preprocessor/steps",
Framework::CypressCucumber,
),
] {
for (syntax, matcher) in [
(
format!(
"import {{ Given as Setup }} from '{module}';\nSetup('esm {module}', () => work());"
),
format!("esm {module}"),
),
(
format!(
"const legacy = require('{module}');\nlegacy.Given('cjs {module}', () => work());"
),
format!("cjs {module}"),
),
] {
let definitions = extract_ts(&syntax);
assert_eq!(definitions.len(), 1, "{module}: {syntax}");
assert_eq!(definitions[0].matcher, matcher);
assert_eq!(definitions[0].registration, "Given");
assert_eq!(definitions[0].framework, expected_framework);
}
}
}
#[test]
fn legacy_cypress_preprocessor_root_and_similar_prefixes_are_not_registration_modules() {
for module in [
"cypress-cucumber-preprocessor",
"cypress-cucumber-preprocessor/steps-extra",
] {
let source = format!(
"import {{ Given as Setup }} from '{module}';\nSetup('not a registration export', () => work());"
);
assert!(extract_ts(&source).is_empty(), "{module}");
}
}
#[test]
fn playwright_step_decorator_name_is_not_invented_for_other_framework_modules() {
for source in [
"import { Step } from '@cucumber/cucumber';\nStep('not exported', () => work());",
"const { Step } = require('@badeball/cypress-cucumber-preprocessor');\nStep('not exported', () => work());",
] {
assert!(extract_ts(source).is_empty(), "{source}");
}
assert!(extract_ts(
"import { Fixture as Given } from 'playwright-bdd/decorators';\nGiven('not a step registration', () => work());"
)
.is_empty());
}
#[test]
fn extracts_playwright_bdd_method_decorators_with_aliases_and_options() {
let source = r#"
import { Fixture, Given as Setup, Step } from 'playwright-bdd/decorators';
export class TodoSteps {
@Fixture('ignored fixture metadata')
page = {};
@Setup('a prepared todo', { timeout: 1000 })
async prepare({ page: localPage }) { await localPage.goto('/todos'); }
@Step(`the todo is visible`)
async verify({ page }) { await page.goto('/todos'); }
}
"#;
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 2);
assert_eq!(definitions[0].matcher, "a prepared todo");
assert_eq!(definitions[0].registration, "Given");
assert_eq!(definitions[1].matcher, "the todo is visible");
assert_eq!(definitions[1].registration, "Step");
assert!(definitions
.iter()
.all(|definition| definition.framework == Framework::PlaywrightBdd));
assert!(definitions
.iter()
.all(|definition| definition.handler.comparable));
assert_eq!(
definitions[0].handler.alpha_normalized,
definitions[1].handler.alpha_normalized
);
assert!(definitions[0]
.handler
.source_snippet
.contains("localPage.goto"));
assert!(!definitions[0].handler.source_snippet.contains("prepare"));
}
#[test]
fn extracts_playwright_bdd_method_decorators_from_cjs_namespaces() {
let source = r#"
const decorators = require('playwright-bdd/decorators');
class WorkspaceSteps {
@decorators.Given('a CJS decorator')
async prepare() { await work(); }
}
"#;
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].matcher, "a CJS decorator");
assert_eq!(definitions[0].registration, "Given");
assert_eq!(definitions[0].framework, Framework::PlaywrightBdd);
}
#[test]
fn extracts_playwright_bdd_method_decorators_with_javascript_grammar() {
let source = r#"
import { Given } from 'playwright-bdd/decorators';
class JavaScriptSteps {
@Given('a JavaScript decorator')
async prepare() { await work(); }
}
"#;
for extension in ["js", "jsx"] {
let source_file = SourceFile {
path: PathBuf::from(format!("features/steps/example.{extension}")),
language: SourceLanguage::JavaScript,
};
let extracted = extract_detailed(source, &source_file).unwrap();
assert!(extracted.diagnostics.is_empty(), "{extension}");
assert_eq!(extracted.definitions.len(), 1, "{extension}");
assert_eq!(extracted.definitions[0].matcher, "a JavaScript decorator");
assert_eq!(extracted.definitions[0].framework, Framework::PlaywrightBdd);
}
}
#[test]
fn unsupported_cjs_destructured_exports_shadow_ambient_registration_names() {
for source in [
r#"
const { Fixture: Given } = require('playwright-bdd/decorators');
Given('not a decorator registration', () => work());
"#,
r#"
const { unsupported: Given } = require('cypress-cucumber-preprocessor/steps');
Given('not a legacy Cypress registration', () => work());
"#,
] {
assert!(extract_ts(source).is_empty(), "{source}");
}
}
#[test]
fn call_and_decorator_registration_apis_are_not_interchangeable() {
let decorator_as_call = r#"
import { Given } from 'playwright-bdd/decorators';
Given('not an ordinary registration', () => work());
"#;
assert!(extract_ts(decorator_as_call).is_empty());
let call_as_decorator = r#"
import { createBdd } from 'playwright-bdd';
const { Given } = createBdd();
class InvalidSteps {
@Given('not a decorator registration')
async prepare() { await work(); }
}
"#;
assert!(extract_ts(call_as_decorator).is_empty());
let cucumber_call_as_decorator = r#"
import { Given } from '@cucumber/cucumber';
class InvalidCucumberSteps {
@Given('not a Cucumber decorator')
prepare() { work(); }
}
"#;
assert!(extract_ts(cucumber_call_as_decorator).is_empty());
let unsupported_create_bdd_export = r#"
import { createBdd } from 'playwright-bdd';
const { Step } = createBdd();
Step('not exported by createBdd', () => work());
"#;
assert!(extract_ts(unsupported_create_bdd_export).is_empty());
}
#[test]
fn project_reexports_preserve_playwright_decorator_registration_provenance() {
let source = r#"
import { Setup } from './decorators';
class WorkspaceSteps {
@Setup('a re-exported decorator')
async prepare() { await work(); }
}
"#;
let extracted = extract_in_project(
&[(
"decorators.ts",
"export { Given as Setup } from 'playwright-bdd/decorators';\n",
)],
source,
);
assert!(extracted.diagnostics.is_empty());
assert_eq!(extracted.definitions.len(), 1);
assert_eq!(extracted.definitions[0].registration, "Given");
assert_eq!(extracted.definitions[0].framework, Framework::PlaywrightBdd);
}
#[test]
fn dynamic_playwright_bdd_decorator_matchers_warn_without_inventing_definitions() {
let source = r#"
import { Given } from 'playwright-bdd/decorators';
class DynamicSteps {
@Given(computeMatcher())
async prepare() { await work(); }
}
"#;
let file = file(SourceLanguage::TypeScript);
let extracted = extract_detailed(source, &file).unwrap();
assert!(extracted.definitions.is_empty());
assert!(extracted.diagnostics.iter().any(|diagnostic| diagnostic
.message
.contains("dynamic or unsupported step matcher")));
}
#[test]
fn decorated_stub_methods_keep_the_existing_trivial_handler_guard() {
let source = r#"
import { Given, Then } from 'playwright-bdd/decorators';
class PendingSteps {
@Given('a pending setup')
async prepare() { await pending(); }
@Then('a pending result')
verify() { throw new Error('not implemented'); }
}
"#;
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 2);
assert!(definitions
.iter()
.all(|definition| definition.handler.trivial));
}
#[test]
fn decorated_method_fingerprints_include_runtime_declaration_semantics() {
let source = r#"
import { Given, When, Then, Step } from 'playwright-bdd/decorators';
class RuntimeSemantics {
@Given('async handler')
async asyncHandler(value) { return work(value); }
@When('sync handler')
syncHandler(value) { return work(value); }
@Then('first default')
withFirstDefault(value = first()) { return work(value); }
@Step('second default')
withSecondDefault(value = second()) { return work(value); }
@Given('renamed default one')
renamedDefaultOne(value = seed()) { return work(value); }
@When('renamed default two')
renamedDefaultTwo(renamed = seed()) { return work(renamed); }
@Then('static handler')
static staticHandler(value) { return work(value); }
@Step('instance handler')
instanceHandler(value) { return work(value); }
@Given('generator handler')
*generatorHandler(value) { return work(value); }
}
"#;
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 9);
assert_ne!(
definitions[0].handler.alpha_normalized, definitions[1].handler.alpha_normalized,
"async and sync methods are behaviorally distinct"
);
assert_ne!(
definitions[2].handler.alpha_normalized, definitions[3].handler.alpha_normalized,
"different default initializers must remain visible"
);
assert!(definitions[2]
.handler
.behavior_signature
.iter()
.any(|event| event == "call:first"));
assert!(!definitions[2].handler.trivial);
assert_eq!(
definitions[4].handler.alpha_normalized, definitions[5].handler.alpha_normalized,
"renaming a parameter must not change an equivalent defaulted handler"
);
assert_ne!(
definitions[6].handler.alpha_normalized, definitions[7].handler.alpha_normalized,
"static and instance methods are behaviorally distinct"
);
assert_ne!(
definitions[7].handler.alpha_normalized, definitions[8].handler.alpha_normalized,
"generator and ordinary methods are behaviorally distinct"
);
}
#[test]
fn decorated_method_modifiers_are_detected_across_comments() {
let source = r#"
import { Given, When, Then, Step } from 'playwright-bdd/decorators';
class CommentedModifiers {
@Given('commented async')
async/**/commentedAsync(value) { return work(value); }
@When('ordinary sync')
ordinarySync(value) { return work(value); }
@Then('commented static')
static/* note */commentedStatic(value) { return work(value); }
@Step('ordinary instance')
ordinaryInstance(value) { return work(value); }
}
"#;
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 4);
assert_ne!(
definitions[0].handler.alpha_normalized,
definitions[1].handler.alpha_normalized
);
assert_ne!(
definitions[2].handler.alpha_normalized,
definitions[3].handler.alpha_normalized
);
}
#[test]
fn malformed_decorator_registrations_warn_without_crossing_class_members() {
let source = r#"
import { Given } from 'playwright-bdd/decorators';
class InvalidSteps {
@Given()
emptyMatcher() { work(); }
@Given('not attached to a method')
value = 1;
}
"#;
let extracted = extract_detailed(source, &file(SourceLanguage::TypeScript)).unwrap();
assert!(extracted.definitions.is_empty());
assert!(extracted.diagnostics.iter().any(|diagnostic| diagnostic
.message
.contains("dynamic or unsupported step matcher")));
assert!(extracted.diagnostics.iter().any(|diagnostic| diagnostic
.message
.contains("dynamic or unsupported step handler")));
}
#[test]
fn project_resolved_registrations_propagate_framework_metadata() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir_all(directory.path().join("support")).unwrap();
fs::create_dir_all(directory.path().join("packages/bdd/src")).unwrap();
fs::write(
directory.path().join("package.json"),
r##"{"imports":{"#bdd":"./support/world.ts"},"workspaces":["packages/*"]}"##,
)
.unwrap();
fs::write(
directory.path().join("support/world.ts"),
"export { Given } from '@cucumber/cucumber';\n",
)
.unwrap();
fs::write(
directory.path().join("packages/bdd/package.json"),
r#"{"name":"@example/bdd","exports":"./src/index.ts"}"#,
)
.unwrap();
fs::write(
directory.path().join("packages/bdd/src/index.ts"),
"export { Given } from 'playwright-bdd';\n",
)
.unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
for (name, module, expected) in [
("imports.ts", "#bdd", Framework::CucumberJs),
("workspace.ts", "@example/bdd", Framework::PlaywrightBdd),
] {
let path = directory.path().join(name);
fs::write(
&path,
format!("import {{ Given }} from '{module}';\nGiven('resolved', () => work());\n"),
)
.unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let source = fs::read_to_string(&file.path).unwrap();
let extracted = extract_detailed_impl(&source, &file, &mut session).unwrap();
assert_eq!(extracted.definitions.len(), 1, "{module}");
assert_eq!(extracted.definitions[0].framework, expected, "{module}");
}
}
#[test]
fn mixed_framework_detection_is_scoped_to_each_source_file() {
let directory = tempfile::tempdir().unwrap();
let cases = [
(
"playwright.steps.ts",
"import { createBdd } from 'playwright-bdd';\nconst { Given } = createBdd();\nGiven('playwright step', () => work());\n",
Framework::PlaywrightBdd,
),
(
"cucumber.steps.ts",
"import { Given } from '@cucumber/cucumber';\nGiven('cucumber step', () => work());\n",
Framework::CucumberJs,
),
(
"cypress.steps.ts",
"import { Given } from '@badeball/cypress-cucumber-preprocessor';\nGiven('cypress step', () => work());\n",
Framework::CypressCucumber,
),
];
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
for (name, source, expected) in cases {
let path = directory.path().join(name);
fs::write(&path, source).unwrap();
let source_file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let extracted = extract_detailed_impl(source, &source_file, &mut session).unwrap();
assert_eq!(extracted.definitions.len(), 1, "{name}");
assert_eq!(extracted.definitions[0].framework, expected, "{name}");
}
}
#[test]
fn playwright_bdd_factory_requires_import_evidence_and_supports_aliases() {
for source in [
"import { createBdd as makeBdd } from 'playwright-bdd';\n\
const { Given } = makeBdd();\nGiven('aliased esm', () => work());",
"const { createBdd: makeBdd } = require('playwright-bdd');\n\
const { Given } = makeBdd();\nGiven('aliased cjs', () => work());",
"const { createBdd } = require('playwright-bdd');\n\
const { Given } = createBdd();\nGiven('shorthand cjs', () => work());",
] {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert_eq!(definitions[0].framework, Framework::PlaywrightBdd);
}
let unrelated = extract_ts(
"const createBdd = () => ({ Given: () => {} });\n\
const { Given } = createBdd();\nGiven('not a step', () => work());",
);
assert!(unrelated.is_empty());
}
#[test]
fn mixed_framework_bindings_keep_per_registration_provenance_in_any_import_order() {
for imports in [
r#"
import { Given as CypressGiven } from '@badeball/cypress-cucumber-preprocessor';
import { Given as DecoratorGiven } from 'playwright-bdd/decorators';
import { Then as CucumberThen } from '@cucumber/cucumber';
"#,
r#"
import { Then as CucumberThen } from '@cucumber/cucumber';
import { Given as DecoratorGiven } from 'playwright-bdd/decorators';
import { Given as CypressGiven } from '@badeball/cypress-cucumber-preprocessor';
"#,
] {
let source = format!(
r#"{imports}
CypressGiven('a Cypress step', () => cy.visit('/'));
CucumberThen('a Cucumber step', () => work());
class MixedSteps {{
@DecoratorGiven('a Playwright decorator')
async prepare() {{ await work(); }}
}}
"#
);
let definitions = extract_ts(&source);
assert_eq!(definitions.len(), 3, "{source}");
let frameworks = definitions
.iter()
.map(|definition| (definition.matcher.as_str(), definition.framework))
.collect::<std::collections::BTreeMap<_, _>>();
assert_eq!(
frameworks.get("a Cypress step"),
Some(&Framework::CypressCucumber)
);
assert_eq!(
frameworks.get("a Cucumber step"),
Some(&Framework::CucumberJs)
);
assert_eq!(
frameworks.get("a Playwright decorator"),
Some(&Framework::PlaywrightBdd)
);
}
}
#[test]
fn project_resolves_playwright_bdd_registrations_created_and_exported_by_local_fixture() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir_all(directory.path().join("fixtures")).unwrap();
fs::create_dir_all(directory.path().join("steps")).unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("tsconfig.json"),
r#"{"compilerOptions":{"paths":{"~/*":["./*"]}}}"#,
)
.unwrap();
fs::write(
directory.path().join("fixtures/test.ts"),
r#"
import { createBdd as makeBdd } from 'playwright-bdd';
const test = {};
export const { Given, When: Action, Then } = makeBdd(test);
"#,
)
.unwrap();
let path = directory.path().join("steps/example.steps.ts");
fs::write(
&path,
r#"
import { Given, Action, Then } from '~/fixtures/test';
Given('a local fixture', () => prepare());
Action('an aliased registration', () => act());
Then('the result is visible', () => verify());
"#,
)
.unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let source = fs::read_to_string(&file.path).unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(&source, &file, &mut session).unwrap();
assert!(extracted.diagnostics.is_empty());
assert_eq!(extracted.definitions.len(), 3);
assert_eq!(extracted.definitions[0].registration, "Given");
assert_eq!(extracted.definitions[1].registration, "When");
assert_eq!(extracted.definitions[2].registration, "Then");
assert!(extracted
.definitions
.iter()
.all(|definition| definition.framework == Framework::PlaywrightBdd));
}
#[test]
fn resolved_framework_facades_provide_expect_import_provenance() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir_all(directory.path().join("fixtures")).unwrap();
fs::create_dir_all(directory.path().join("steps")).unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("tsconfig.json"),
r#"{"compilerOptions":{"paths":{"~/*":["./*"]}}}"#,
)
.unwrap();
fs::write(
directory.path().join("fixtures/test.ts"),
r#"
import { createBdd } from 'playwright-bdd';
export const { Then } = createBdd({});
export const expect = createAssertionFactory();
"#,
)
.unwrap();
fs::write(
directory.path().join("fixtures/lookalike.ts"),
r#"
export const Then = makeLocalCallback();
export const expect = makeLocalAssertionFactory();
"#,
)
.unwrap();
let path = directory.path().join("steps/example.steps.ts");
let source = r#"
import { Then } from '~/fixtures/test';
import { expect } from '~/fixtures/test';
import * as api from '~/fixtures/test';
import { Given } from '@cucumber/cucumber';
import { Then as helper, expect as localCheck } from '~/fixtures/lookalike';
Then('a facade assertion', ({ state }) => expect(state).toBe('ready'));
api.Then('a facade namespace is not assertion provenance', ({ state }) => api.expect(state).toBe('ready'));
Given('an unrelated module is not assertion provenance', ({ state }) => localCheck(state).toBe('ready'));
"#;
fs::write(&path, source).unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(source, &file, &mut session).unwrap();
assert!(extracted.diagnostics.is_empty());
assert_eq!(extracted.definitions.len(), 3);
assert_eq!(extracted.definitions[0].handler.behavior_signature.len(), 1);
assert!(
extracted.definitions[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:")
);
assert!(extracted.definitions[1]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")));
assert!(extracted.definitions[2]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")));
for specifier in [
"../fixtures/test",
"../fixtures/test.ts",
"~/fixtures/test.ts",
] {
let split_source = source.replace(
"import { expect } from '~/fixtures/test';",
&format!("import {{ expect }} from '{specifier}';"),
);
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let split = extract_detailed_impl(&split_source, &file, &mut session).unwrap();
assert!(split.diagnostics.is_empty());
assert_eq!(
split.definitions[0].handler.behavior_signature,
extracted.definitions[0].handler.behavior_signature,
"{specifier}"
);
}
}
#[test]
fn project_resolves_commonjs_playwright_bdd_factory_aliases() {
for (name, fixture) in [
(
"alias",
"const { createBdd: makeBdd } = require('playwright-bdd');\n\
export const { Given } = makeBdd(test);\n",
),
(
"shorthand",
"const { createBdd } = require('playwright-bdd');\n\
export const { Given } = createBdd(test);\n",
),
] {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(directory.path().join("fixture.ts"), fixture).unwrap();
let path = directory.path().join(format!("{name}.steps.ts"));
fs::write(
&path,
"import { Given } from './fixture';\nGiven('from cjs fixture', () => work());\n",
)
.unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let source = fs::read_to_string(&file.path).unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(&source, &file, &mut session).unwrap();
assert!(extracted.diagnostics.is_empty(), "{name}");
assert_eq!(extracted.definitions.len(), 1, "{name}");
assert_eq!(extracted.definitions[0].framework, Framework::PlaywrightBdd);
}
}
#[test]
fn project_resolves_reexported_runtime_registration_imports() {
for source in [
"import { Given as Setup } from '@cucumber/cucumber';\nexport { Setup as Given };\n",
"const { Given: Setup } = require('@cucumber/cucumber');\nexport { Setup as Given };\n",
"const { Given } = require('@cucumber/cucumber');\nexport { Given };\n",
] {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(directory.path().join("barrel.ts"), source).unwrap();
let path = directory.path().join("example.steps.ts");
fs::write(
&path,
"import { Given } from './barrel';\nGiven('through import barrel', () => work());\n",
)
.unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let source = fs::read_to_string(&file.path).unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(&source, &file, &mut session).unwrap();
assert!(extracted.diagnostics.is_empty(), "{source}");
assert_eq!(extracted.definitions.len(), 1, "{source}");
assert_eq!(extracted.definitions[0].framework, Framework::CucumberJs);
}
}
#[test]
fn project_resolves_chained_import_then_export_provenance() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("registration-source.ts"),
"export { Given } from '@cucumber/cucumber';\n",
)
.unwrap();
fs::write(
directory.path().join("registration-barrel.ts"),
"import { Given as Setup } from './registration-source';\nexport { Setup as Given };\n",
)
.unwrap();
fs::write(
directory.path().join("factory-source.ts"),
"export { createBdd } from 'playwright-bdd';\n",
)
.unwrap();
fs::write(
directory.path().join("factory-barrel.ts"),
"import { createBdd as makeBdd } from './factory-source';\nexport { makeBdd };\n",
)
.unwrap();
let path = directory.path().join("example.steps.ts");
fs::write(
&path,
r#"
import { Given as CucumberGiven } from './registration-barrel';
import { makeBdd as buildBdd } from './factory-barrel';
const { Given: PlaywrightGiven } = buildBdd(test);
CucumberGiven('through a registration chain', () => prepare());
PlaywrightGiven('through a factory chain', () => act());
"#,
)
.unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let source = fs::read_to_string(&file.path).unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(&source, &file, &mut session).unwrap();
assert!(extracted.diagnostics.is_empty());
assert_eq!(extracted.definitions.len(), 2);
assert_eq!(extracted.definitions[0].framework, Framework::CucumberJs);
assert_eq!(extracted.definitions[1].framework, Framework::PlaywrightBdd);
}
#[test]
fn module_resolution_clears_active_paths_after_recoverable_errors() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
let module = directory.path().join("fixture.ts");
fs::write(&module, "export { Given from '@cucumber/cucumber';\n").unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let first_path = directory.path().join("first.steps.ts");
fs::write(
&first_path,
"import { Given } from './fixture';\nGiven('first attempt', () => work());\n",
)
.unwrap();
let first_file = SourceFile {
path: first_path,
language: SourceLanguage::TypeScript,
};
let first_source = fs::read_to_string(&first_file.path).unwrap();
let first = extract_detailed_impl(&first_source, &first_file, &mut session).unwrap();
assert!(first.definitions.is_empty());
assert!(!first.diagnostics.is_empty());
fs::write(&module, "export { Given } from '@cucumber/cucumber';\n").unwrap();
let second_path = directory.path().join("second.steps.ts");
fs::write(
&second_path,
"import { Given } from './fixture';\nGiven('second attempt', () => work());\n",
)
.unwrap();
let second_file = SourceFile {
path: second_path,
language: SourceLanguage::TypeScript,
};
let second_source = fs::read_to_string(&second_file.path).unwrap();
let second = extract_detailed_impl(&second_source, &second_file, &mut session).unwrap();
assert!(second.diagnostics.is_empty());
assert_eq!(second.definitions.len(), 1);
assert_eq!(second.definitions[0].framework, Framework::CucumberJs);
}
#[test]
fn project_resolves_explicit_exports_but_rejects_untrusted_create_bdd_lookalikes() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("fixture.ts"),
r#"
import { createBdd } from 'playwright-bdd';
const { Given: Setup } = createBdd(test);
export { Setup as Given };
"#,
)
.unwrap();
fs::write(
directory.path().join("lookalike.ts"),
r#"
const createBdd = () => ({ Given: () => {} });
export const { Given } = createBdd();
"#,
)
.unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
for (module, expected_definitions) in [("./fixture", 1), ("./lookalike", 0)] {
let path = directory.path().join(format!("{}.steps.ts", &module[2..]));
fs::write(
&path,
format!(
"import {{ Given }} from '{module}';\nGiven('resolved safely', () => work());\n"
),
)
.unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let source = fs::read_to_string(&file.path).unwrap();
let extracted = extract_detailed_impl(&source, &file, &mut session).unwrap();
assert_eq!(
extracted.definitions.len(),
expected_definitions,
"{module}"
);
}
}
#[test]
fn project_resolves_wrapped_create_bdd_results_through_star_exports() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("fixture.ts"),
r#"
import { createBdd } from 'playwright-bdd';
export const { Given } = (createBdd(test) as ReturnType<typeof createBdd>)!;
"#,
)
.unwrap();
fs::write(
directory.path().join("barrel.ts"),
"export * from './fixture';\n",
)
.unwrap();
let path = directory.path().join("example.steps.ts");
fs::write(
&path,
"import { Given } from './barrel';\nGiven('through a barrel', () => work());\n",
)
.unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let source = fs::read_to_string(&file.path).unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(&source, &file, &mut session).unwrap();
assert!(extracted.diagnostics.is_empty());
assert_eq!(extracted.definitions.len(), 1);
assert_eq!(extracted.definitions[0].registration, "Given");
assert_eq!(extracted.definitions[0].framework, Framework::PlaywrightBdd);
}
#[test]
fn project_does_not_trust_type_only_create_bdd_imports() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("fixture.ts"),
r#"
import { type createBdd as Factory } from 'playwright-bdd';
export const { Given } = Factory(test);
"#,
)
.unwrap();
let path = directory.path().join("example.steps.ts");
fs::write(
&path,
"import { Given } from './fixture';\nGiven('not trusted', () => work());\n",
)
.unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let source = fs::read_to_string(&file.path).unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(&source, &file, &mut session).unwrap();
assert!(extracted.definitions.is_empty());
assert_eq!(extracted.diagnostics.len(), 1);
assert!(extracted.diagnostics[0]
.message
.contains("could not be resolved"));
}
#[test]
fn alpha_fingerprint_ignores_local_renames_but_not_opposite_assertions() {
let first = extract(
"Then('visible', async ({ page }) => { const item = page.locator('x'); await expect(item).toBeVisible(); });",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
let renamed = extract(
"Then('shown', async ({ page }) => { const element = page.locator('x'); await expect(element).toBeVisible(); });",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
let opposite = extract(
"Then('hidden', async ({ page }) => { const item = page.locator('x'); await expect(item).not.toBeVisible(); });",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
assert_eq!(
first.handler.alpha_normalized,
renamed.handler.alpha_normalized
);
assert_ne!(
first.handler.alpha_normalized,
opposite.handler.alpha_normalized
);
}
#[test]
fn handler_source_snippets_are_normalized_and_bounded() {
let source = format!(" {}\r\n", "界".repeat(MAX_HANDLER_SNIPPET_CHARS + 1));
let snippet = bounded_source_snippet(&source, MAX_HANDLER_SNIPPET_CHARS, false);
assert_eq!(snippet.chars().count(), MAX_HANDLER_SNIPPET_CHARS);
assert!(snippet.ends_with('…'));
assert!(!snippet.contains('\r'));
for (source, max_chars, preserve_prefix_bytes, expected) in [
("", 0, false, ""),
("abc", 0, false, ""),
("abc", 1, false, "…"),
("abc", 2, false, "a…"),
("ab", 2, false, "ab"),
("abc", 0, true, ""),
("abc", 1, true, ""),
("abc", 2, true, ""),
("abcd", 3, true, "…"),
("abcde", 4, true, "a…"),
("界界", 1, true, "…"),
] {
assert_eq!(
bounded_source_snippet(source, max_chars, preserve_prefix_bytes),
expected,
"source={source:?} max_chars={max_chars} preserve_prefix_bytes={preserve_prefix_bytes}"
);
}
}
#[test]
fn extracts_rule_scoped_inline_suppressions_and_rejects_malformed_directives() {
let extracted = extract_detailed(
r#"
// cuke-dedup:ignore duplicate-matcher -- wording fixed by an external contract
// cuke-dedup:ignore unused-definition -- exercised by a remote suite
Given('external wording', () => work());
// cuke-dedup:ignore duplicate-handler
Then('broken directive', () => other());
// cuke-dedup:ignore unknown-rule -- not a real rule
When('unknown rule', () => another());
// cuke-dedup:ignore unused-definition --
When('empty reason', () => finalAction());
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_eq!(extracted.definitions[0].inline_suppressions.len(), 2);
assert_eq!(
extracted.definitions[0].inline_suppressions[0].rule,
Rule::DuplicateMatcher
);
assert_eq!(extracted.diagnostics.len(), 3);
assert_eq!(
extracted.diagnostics[0].level,
ExtractionDiagnosticLevel::Error
);
assert!(extracted.diagnostics[0]
.message
.contains("cuke-dedup:ignore RULE -- REASON"));
assert!(extracted.diagnostics[1].message.contains("unknown rule"));
assert!(extracted.diagnostics[2]
.message
.contains("reason must not be empty"));
}
#[test]
fn rejects_oversized_inline_suppression_reasons() {
let reason = "x".repeat(crate::resource_limits::MAX_SUPPRESSION_REASON_CHARS + 1);
let extracted = extract_detailed(
&format!(
"// cuke-dedup:ignore duplicate-handler -- {reason}\nGiven('step', () => work());"
),
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert!(extracted.definitions[0].inline_suppressions.is_empty());
assert!(extracted.diagnostics[0]
.message
.contains("512-character limit"));
}
#[test]
fn structural_fingerprint_masks_string_literal_contents() {
let first = extract(
"Then('save', async ({ page }) => { await page.locator('#save').click(); });",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
let second = extract(
"Then('saves', async ({ page }) => { await page.locator('#saves').click(); });",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
assert_ne!(
first.handler.alpha_normalized,
second.handler.alpha_normalized
);
assert_eq!(first.handler.structural, second.handler.structural);
}
#[test]
fn behavior_signatures_canonicalize_calls_without_treating_await_as_behavior() {
struct Case {
name: &'static str,
source: &'static str,
expected: &'static [&'static str],
}
for case in [
Case {
name: "direct member call",
source: "Given('step', async function () { await this.page.click('#save'); });",
expected: &["call:this.page#click"],
},
Case {
name: "fluent member call",
source:
"Given('step', async function () { await this.page.locator('#save').click(); });",
expected: &["call:this.page#click", "call:this.page#locator"],
},
Case {
name: "parenthesized awaited receiver",
source:
"Given('step', async function () { await (await this.page.locator('#save')).click(); });",
expected: &["call:this.page#click", "call:this.page#locator"],
},
Case {
name: "factory call receiver",
source: "Given('step', async function () { await browser().click('#save'); });",
expected: &["call:browser#click", "call:browser"],
},
Case {
name: "free local callback",
source: "Given('step', async (perform) => { await perform(); });",
expected: &["call:v0"],
},
Case {
name: "receiver identity",
source: "Given('step', async ({ audit }) => { await audit.click(); });",
expected: &["call:v0#click"],
},
] {
let definition = extract(case.source, &file(SourceLanguage::TypeScript))
.unwrap()
.remove(0);
assert_eq!(
definition.handler.behavior_signature, case.expected,
"{}",
case.name
);
}
}
#[test]
fn behavior_signatures_alpha_normalize_declared_receivers() {
let definitions = extract_ts(
"Given('first', async (page) => { await page.goto('/'); });\n\
Given('second', async (browser) => { await browser.goto('/'); });\n\
Given('third', page => page.goto('/'));\n\
Given('fourth', browser => browser.goto('/'));",
);
assert_eq!(definitions.len(), 4);
assert_eq!(
definitions[0].handler.alpha_normalized,
definitions[1].handler.alpha_normalized
);
assert_eq!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
assert_eq!(definitions[0].handler.behavior_signature, ["call:v0#goto"]);
assert_eq!(
definitions[2].handler.alpha_normalized,
definitions[3].handler.alpha_normalized
);
assert_eq!(definitions[2].handler.behavior_signature, ["call:v0#goto"]);
}
#[test]
fn behavior_signatures_preserve_assertion_subjects_and_polarity() {
let definitions = extract_ts(
r#"
Then('primary value', async ({ page }, expected) => {
await expect(new Form(page).primaryInput).toHaveValue(expected);
});
Then('secondary value', async ({ page }, expected) => {
await expect(new Form(page).secondaryInput).toHaveValue(expected);
});
Then('panel active', async ({ page }) => {
await expect(new Navigation(page).panel).toHaveClass(/active/);
});
Then('panel inactive', async ({ page }) => {
await expect(new Navigation(page).panel).not.toHaveClass(/active/);
});
Then('status active', async ({ page }) => {
await expect(new Navigation(page).status).toBe('active');
});
Then('status inactive', async ({ page }) => {
await expect(new Navigation(page).status).toBe('inactive');
});
"#,
);
assert_eq!(definitions.len(), 6);
assert_ne!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
assert_ne!(
definitions[2].handler.behavior_signature,
definitions[3].handler.behavior_signature
);
assert_ne!(
definitions[4].handler.behavior_signature,
definitions[5].handler.behavior_signature
);
assert!(definitions.iter().all(|definition| {
definition.handler.behavior_signature.len() == 1
&& definition.handler.behavior_signature[0].starts_with("assert:expect")
}));
assert!(definitions[3].handler.behavior_signature[0].contains(".not#toHaveClass"));
}
#[test]
fn behavior_signatures_still_alpha_normalize_assertion_subject_locals() {
let definitions = extract_ts(
r#"
Then('first assertion', async ({ page }) => {
const item = page.locator('#status');
await expect(item).toBeVisible();
});
Then('second assertion', async ({ page }) => {
const element = page.locator('#status');
await expect(element).toBeVisible();
});
"#,
);
assert_eq!(definitions.len(), 2);
assert_eq!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
}
#[test]
fn assertion_values_resolve_safe_local_constant_initializers() {
let definitions = extract_ts(
r#"
Then('first subject', ({ page }) => {
const target = new Form(page).primaryInput;
expect(target).toHaveValue('ready');
});
Then('second subject', ({ page }) => {
const target = new Form(page).secondaryInput;
expect(target).toHaveValue('ready');
});
Then('first expected value', ({ state }) => {
const expected = 'ready';
expect(state).toBe(expected);
});
Then('second expected value', ({ state }) => {
const expected = 'idle';
expect(state).toBe(expected);
});
Then('renamed equivalent constant', ({ state }) => {
const wanted = 'ready';
expect(state).toBe(wanted);
});
Then('chained ready constant', ({ state }) => {
const base = 'ready';
const expected = base;
expect(state).toBe(expected);
});
Then('chained idle constant', ({ state }) => {
const base = 'idle';
const expected = base;
expect(state).toBe(expected);
});
"#,
);
assert_eq!(definitions.len(), 7);
assert_ne!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
assert_ne!(
definitions[2].handler.behavior_signature,
definitions[3].handler.behavior_signature
);
assert_eq!(
definitions[2].handler.behavior_signature,
definitions[4].handler.behavior_signature
);
assert_ne!(
definitions[5].handler.behavior_signature,
definitions[6].handler.behavior_signature
);
}
#[test]
fn assertion_chains_unwrap_typescript_expression_wrappers() {
for source in [
"Then('as wrapper', ({ state }) => (expect(state) as any).toBe('ready'));",
"Then('satisfies wrapper', ({ state }) => (expect(state) satisfies Assertion).toBe('ready'));",
"Then('non-null wrapper', ({ state }) => expect(state)!.toBe('ready'));",
"Then('type assertion wrapper', ({ state }) => (<Assertion>expect(state)).toBe('ready'));",
"Then('generic call', ({ state }) => expect<string>(state).toBe('ready'));",
] {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert!(
definitions[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{source}: {:?}",
definitions[0].handler.behavior_signature
);
}
}
#[test]
fn assertion_review_scope_boundaries() {
for body in [
"const run = () => { const expected = 'ready'; expect(state).toBe(expected); }; run();",
"const expected = 'ready'; items.forEach(expected => expect(state).toBe(expected));",
] {
let definitions = extract_ts(&format!("Then('nested', () => {{ {body} }});"));
assert_eq!(definitions.len(), 1);
assert!(definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")));
}
for body in [
"const status = VALUE; expect(page.status).toBe('ready');",
"const status = VALUE; expect({ status: page.value }).toBe('ready');",
"class X { static { const expected = VALUE; } } expect(state).toBe(expected);",
] {
let signatures: Vec<_> = ["'ready'", "'idle'"]
.into_iter()
.map(|value| {
extract_ts(&format!(
"Then('scope', () => {{ {} }});",
body.replace("VALUE", value)
))[0]
.handler
.behavior_signature
.clone()
})
.collect();
assert_eq!(signatures[0], signatures[1], "{body}");
}
for keyword in ["namespace", "module"] {
for binding in ["var", "const"] {
let source = format!("{keyword} Helpers {{ {binding} expect = custom; Then('inner', () => expect(state).toBe('ready')); }} Then('outer', () => expect(state).toBe('ready'));");
let definitions = extract_ts(&source);
assert_eq!(definitions.len(), 2);
assert!(
definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{source}"
);
assert!(
definitions[1]
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:")),
"{source}"
);
}
}
}
#[test]
fn behavior_signatures_cover_supported_expect_variants_and_incomplete_chains() {
let definitions = extract_ts(
r#"
Then('soft assertion', async ({ page }) => {
await expect.soft(page.locator('#status')).toBeVisible();
});
Then('polled assertion', async ({ state }) => {
await expect.poll(() => state.value).toBe('ready');
});
Then('standalone expect call', ({ value }) => {
expect(value);
});
Then('missing assertion subject', () => {
expect().toBeVisible();
});
"#,
);
assert_eq!(definitions.len(), 4);
assert_eq!(definitions[0].handler.behavior_signature.len(), 1);
assert!(definitions[0].handler.behavior_signature[0].starts_with("assert:expect.soft#"));
assert_eq!(definitions[1].handler.behavior_signature.len(), 1);
assert!(definitions[1].handler.behavior_signature[0].starts_with("assert:expect.poll#"));
assert_eq!(definitions[2].handler.behavior_signature, ["call:expect"]);
assert_eq!(
definitions[3].handler.behavior_signature,
["call:expect#toBeVisible", "call:expect"]
);
let deep_chain = format!(
"Then('bounded chain', ({{ value }}) => expect(value){}.toBeVisible());",
".not".repeat(20)
);
let definition = extract_ts(&deep_chain).remove(0);
assert!(!definition.handler.behavior_signature.is_empty());
}
#[test]
fn behavior_signatures_resolve_trusted_expect_imports_and_requires() {
let cases = [
r#"
import { expect as check } from '@playwright/test';
Then('aliased ESM assertion', ({ state }) => check(state).toBe('ready'));
"#,
r#"
import * as testApi from '@playwright/test';
Then('namespaced ESM assertion', ({ state }) => testApi.expect(state).toBe('ready'));
"#,
r#"
const { expect: verify } = require('@jest/globals');
Then('aliased CJS assertion', ({ state }) => verify(state).toBe('ready'));
"#,
r#"
const testApi = require('@playwright/test');
Then('namespaced CJS assertion', ({ state }) => testApi.expect(state).toBe('ready'));
"#,
r#"
import check from 'expect';
Then('default assertion import', ({ state }) => check(state).toBe('ready'));
"#,
];
for source in cases {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert_eq!(
definitions[0].handler.behavior_signature.len(),
1,
"{source}"
);
assert!(
definitions[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{source}: {:?}",
definitions[0].handler.behavior_signature
);
}
}
#[test]
fn behavior_signatures_do_not_trust_expect_aliases_from_unrelated_modules() {
let definition = extract_ts(
r#"
import { expect as check } from 'unrelated-assertion-library';
Then('unrelated assertion alias', ({ state }) => check(state).toBe('ready'));
"#,
)
.remove(0);
assert_eq!(
definition.handler.behavior_signature,
["call:check#toBe", "call:check"]
);
}
#[test]
fn runtime_expect_bindings_shadow_the_ambient_assertion_factory() {
let cases = [
r#"
import { expect } from 'unrelated-assertion-library';
Then('shadowed by ESM import', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
const expect = require('unrelated-assertion-library');
Then('shadowed by CJS require', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
const expect = makeAssertionFactory();
Then('shadowed by variable', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
if (enabled) { var expect = makeAssertionFactory(); }
Then('shadowed by module var hoisting', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
for (var expect of factories) { consume(expect); }
Then('shadowed by module loop var hoisting', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
if (enabled) { var require = makeLoader(); }
const { expect: check } = require('@playwright/test');
Then('shadowed module require var has no provenance', ({ state }) => check(state).toBe('ready'));
"#,
r#"
function expect(value) { return makeAssertion(value); }
Then('shadowed by declaration', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
Then('shadowed by parameter', (expect, state) => expect(state).toBe('ready'));
"#,
r#"
expect = makeAssertionFactory();
Then('shadowed by assignment', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
import { expect as check } from '@playwright/test';
Then('trusted alias shadowed by parameter', (check, state) => check(state).toBe('ready'));
"#,
r#"
import { expect as check } from '@playwright/test';
Then('trusted alias shadowed by one parameter', check => check(state).toBe('ready'));
"#,
r#"
function helper() {
const { expect: check } = require('@playwright/test');
return check;
}
Then('nested trusted binding does not leak', ({ state }) => check(state).toBe('ready'));
"#,
r#"
function require() { return unrelatedModule(); }
const { expect: check } = require('@playwright/test');
Then('shadowed require has no provenance', ({ state }) => check(state).toBe('ready'));
"#,
r#"
Then('named function expression shadows ambient', function expect(state) {
expect(state).toBe('ready');
});
"#,
r#"
Then('shadowed by catch binding', async ({ state }) => {
try { await work(); } catch (expect) { expect(state).toBe('ready'); }
});
"#,
r#"
Then('shadowed by loop binding', ({ state, factories }) => {
for (const expect of factories) { expect(state).toBe('ready'); }
});
"#,
r#"
Then('shadowed by function var binding', ({ state, enabled }) => {
if (enabled) { var expect = makeAssertionFactory(); }
expect(state).toBe('ready');
});
"#,
r#"
Then('shadowed by function loop var binding', ({ state, factories }) => {
for (var expect of factories) { consume(expect); }
expect(state).toBe('ready');
});
"#,
r#"
Then('shadowed across switch cases', ({ state }) => {
switch (state.kind) {
case 'local': let expect = makeAssertionFactory(); break;
case 'assert': expect(state).toBe('ready'); break;
}
});
"#,
r#"
({ expect } = assertionFactories);
Then('shadowed by destructuring assignment', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
enum expect { Ready }
Then('shadowed by runtime enum', ({ state }) => expect(state).toBe('ready'));
"#,
];
for source in cases {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert!(
definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{source}: {:?}",
definitions[0].handler.behavior_signature
);
}
}
#[test]
fn typescript_runtime_and_erased_declarations_have_distinct_assertion_shadowing() {
let cases = [
(
r#"
namespace expect { export const custom = true; }
Then('runtime namespace shadows ambient', ({ state }) => expect(state).toBe('ready'));
"#,
false,
),
(
r#"
module expect { export const custom = true; }
Then('runtime module shadows ambient', ({ state }) => expect(state).toBe('ready'));
"#,
false,
),
(
r#"
declare namespace expect { const custom: boolean; }
Then('declared namespace is erased', ({ state }) => expect(state).toBe('ready'));
"#,
true,
),
(
r#"
declare const expect: AssertionFactory;
Then('declared constant is erased', ({ state }) => expect(state).toBe('ready'));
"#,
true,
),
];
for (source, expected_assertion) in cases {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert_eq!(
definitions[0]
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:")),
expected_assertion,
"{source}: {:?}",
definitions[0].handler.behavior_signature
);
}
}
#[test]
fn assertion_namespaces_named_expect_do_not_restore_the_callable_ambient() {
for source in [
r#"
import * as expect from '@playwright/test';
Then('ESM namespace occupies expect', ({ state }) => expect(state).toBe('ready'));
Then('ESM namespace property remains trusted', ({ state }) => expect.expect(state).toBe('ready'));
"#,
r#"
const expect = require('@playwright/test');
Then('CJS namespace occupies expect', ({ state }) => expect(state).toBe('ready'));
Then('CJS namespace property remains trusted', ({ state }) => expect.expect(state).toBe('ready'));
"#,
] {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 2, "{source}");
assert!(
definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{source}: {:?}",
definitions[0].handler.behavior_signature
);
assert!(
definitions[1]
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:")),
"{source}: {:?}",
definitions[1].handler.behavior_signature
);
}
}
#[test]
fn handler_local_shadows_do_not_disable_assertions_in_other_handlers() {
let cases = [
r#"
const { expect: check } = require('@playwright/test');
function helper(require) { return require('local-helper'); }
Then('module CJS assertion remains trusted', ({ state }) => check(state).toBe('ready'));
"#,
r#"
import { expect as check } from '@playwright/test';
const helper = check => check;
Then('module ESM assertion remains trusted', ({ state }) => check(state).toBe('ready'));
"#,
r#"
Then('local function name is shadowed', function expect(state) {
expect(state).toBe('ready');
});
Then('ambient assertion remains trusted elsewhere', ({ state }) => expect(state).toBe('ready'));
"#,
];
for source in cases {
let definitions = extract_ts(source);
let trusted = definitions.last().expect("assertion step");
assert!(
trusted
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:")),
"{source}: {:?}",
trusted.handler.behavior_signature
);
if definitions.len() == 2 {
assert!(definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")));
}
}
}
#[test]
fn nested_scope_shadows_do_not_disable_outer_assertions_in_the_same_handler() {
let definition = extract_ts(
r#"
Then('outer assertion remains trusted', ({ state }) => {
expect(state).toBe('ready');
function helper(expect) { return expect(state).toBe('local'); }
});
"#,
)
.remove(0);
assert_eq!(
definition
.handler
.behavior_signature
.iter()
.filter(|event| event.starts_with("assert:"))
.count(),
1
);
}
#[test]
fn property_and_lexically_resolved_assignments_preserve_assertion_provenance() {
let cases = [
r#"
import * as pw from '@playwright/test';
Then('namespace property assignment', ({ state }) => {
pw.someConfig = true;
pw.expect(state).toBe('ready');
});
"#,
r#"
Then('bare assertion property assignment', ({ state }) => {
expect.custom = true;
expect(state).toBe('ready');
});
"#,
r#"
Then('destructured assertion property assignment', ({ state, source }) => {
({ value: expect.custom } = source);
expect(state).toBe('ready');
});
"#,
r#"
import * as pw from '@playwright/test';
Then('array namespace property assignment', ({ state, source }) => {
[pw.value] = source;
pw.expect(state).toBe('ready');
});
"#,
r#"
{ let expect; expect = makeAssertionFactory(); }
Then('block assignment stays local', ({ state }) => expect(state).toBe('ready'));
"#,
];
for source in cases {
let definition = extract_ts(source).remove(0);
assert!(
definition
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:")),
"{source}: {:?}",
definition.handler.behavior_signature
);
}
let definition = extract_ts(
r#"
function mutate() { expect = makeAssertionFactory(); }
Then('unresolved outer mutation is conservative', ({ state }) => expect(state).toBe('ready'));
"#,
)
.remove(0);
assert!(definition
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")));
}
#[test]
fn javascript_using_loop_bindings_shadow_ambient_expect() {
let source = r#"
Then('using loop binding', ({ state, resources }) => {
for (using expect of resources) { expect(state).toBe('ready'); }
});
"#;
let definitions = extract(source, &file(SourceLanguage::JavaScript)).unwrap();
assert_eq!(definitions.len(), 1);
assert!(definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")));
}
#[test]
fn runtime_class_names_shadow_ambient_expect_only_inside_the_class() {
for class in [
"class expect",
"const Wrapper = class expect",
"abstract class expect",
] {
let source = format!(
r#"
import {{ Then }} from 'playwright-bdd/decorators';
import {{ Given }} from '@cucumber/cucumber';
{class} {{
@Then('class name is runtime binding')
verify(state) {{ expect(state).toBe('ready'); }}
}}
Given('ambient remains trusted outside the class', ({{ state }}) => expect(state).toBe('ready'));
"#
);
let definitions = extract_ts(&source);
assert_eq!(definitions.len(), 2, "{source}");
assert!(
definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{source}: {:?}",
definitions[0].handler.behavior_signature
);
assert_eq!(
definitions[1]
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:")),
class.starts_with("const "),
"{source}: {:?}",
definitions[1].handler.behavior_signature
);
}
}
#[test]
fn block_and_class_static_bindings_stay_within_their_lexical_scopes() {
let cases = [
r#"
{ class expect {} }
Then('block class does not shadow ambient assertion', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
{ function expect(value) { return value; } }
Then('block function does not shadow ambient assertion', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
Then('class static var stays local', ({ state }) => {
class Helper { static { var expect = makeAssertionFactory(); consume(expect); } }
expect(state).toBe('ready');
});
"#,
];
for source in cases {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert_eq!(
definitions[0]
.handler
.behavior_signature
.iter()
.filter(|event| event.starts_with("assert:"))
.count(),
1,
"{source}: {:?}",
definitions[0].handler.behavior_signature
);
}
}
#[test]
fn method_names_do_not_shadow_assertion_bindings_inside_their_bodies() {
let definitions = extract_ts(
r#"
import { Then } from 'playwright-bdd/decorators';
class Assertions {
@Then('method name is not a lexical binding')
expect({ state }) { expect(state).toBe('ready'); }
}
"#,
);
assert_eq!(definitions.len(), 1);
assert!(definitions[0]
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:")));
}
#[test]
fn shadowed_expect_calls_retain_generic_behavior_comparison() {
let definitions = extract_ts(
r#"
import { expect } from 'unrelated-assertion-library';
Then('the local state shows the first condition', ({ state }) => expect(state).toBe('ready'));
Then('the local state shows the final condition', ({ state }) => expect(state).toBe('idle'));
"#,
);
assert_eq!(definitions.len(), 2);
assert_eq!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
assert!(definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")));
}
#[test]
fn trusted_and_type_only_expect_imports_preserve_assertion_semantics() {
for source in [
r#"
import { expect } from '@playwright/test';
Then('trusted runtime binding', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
import type { expect } from 'unrelated-types';
Then('ambient binding remains', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
const check = require('expect');
Then('standalone CJS binding', ({ state }) => check(state).toBe('ready'));
"#,
r#"
const { expect } = require('@playwright/test');
Then('shorthand CJS binding', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
import check = require('expect');
Then('TypeScript import require binding', ({ state }) => check(state).toBe('ready'));
"#,
] {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert_eq!(
definitions[0].handler.behavior_signature.len(),
1,
"{source}"
);
assert!(
definitions[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{source}: {:?}",
definitions[0].handler.behavior_signature
);
}
}
#[test]
fn ambient_variable_declarations_do_not_shadow_runtime_assertion_provenance() {
for source in [
r#"
declare var expect: unknown;
Then('ambient expect remains available', ({ state }) => expect(state).toBe('ready'));
"#,
r#"
declare var require: unknown;
const { expect } = require('@playwright/test');
Then('CommonJS expect remains available', ({ state }) => expect(state).toBe('ready'));
"#,
] {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert!(
definitions[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{source}"
);
}
}
#[test]
fn decorated_method_metadata_does_not_inflate_executable_behavior_similarity() {
let definitions = extract_ts(
r#"
import { Given } from 'playwright-bdd/decorators';
import { expect as check } from '@playwright/test';
class StatusSteps {
@Given('the account status shows the first condition')
first({ page }) { check(page.status).toBe('ready'); }
@Given('the account status shows the final condition')
second({ page }) { check(page.status).toBe('idle'); }
}
"#,
);
assert_eq!(definitions.len(), 2);
assert!(definitions.iter().all(|definition| {
definition.handler.behavior_signature.len() == 2
&& definition.handler.behavior_signature[0] == "method:instance sync"
&& definition.handler.behavior_signature[1].starts_with("assert:expect#toBe:")
}));
assert_ne!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
}
#[test]
fn alpha_fingerprints_assign_identifiers_by_declaration_order() {
let definitions = extract_ts(
"Given('first', (z, a) => z.goto(a));\n\
Given('second', (b, y) => b.goto(y));",
);
assert_eq!(definitions.len(), 2);
assert_eq!(
definitions[0].handler.alpha_normalized,
definitions[1].handler.alpha_normalized
);
assert_eq!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
}
#[test]
fn defaulted_destructured_parameters_preserve_initializer_behavior() {
let definitions = extract_ts(
"Given('first', ({ page: browser = makePage() } = {}) => browser.goto('/'));\n\
Given('second', ({ page: tab = makePage() } = {}) => tab.goto('/'));",
);
assert_eq!(definitions.len(), 2);
assert_eq!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
assert_eq!(
definitions[0].handler.behavior_signature,
["call:makePage", "call:v0#goto"]
);
assert!(definitions
.iter()
.all(|definition| !definition.handler.trivial));
}
#[test]
fn computed_callees_keep_the_structural_fallback() {
let definition = extract(
"Given('step', ({ page, method }) => page[method]());",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
assert_eq!(definition.handler.behavior_signature.len(), 1);
assert!(definition.handler.behavior_signature[0].starts_with("call:(subscript_expression"));
}
#[test]
fn ignores_unrelated_member_calls_and_destructuring_aliases() {
let source = r#"
somePromise.then('not a step', () => {});
const { Given: G } = require('unrelated-lib');
G('also not a step', () => {});
"#;
assert!(extract(source, &file(SourceLanguage::JavaScript))
.unwrap()
.is_empty());
}
#[test]
fn retains_regex_flags_and_decodes_javascript_hex_escapes() {
let definitions = extract(
r#"Then(/^THE USER$/i, namedHandler); Given("letter \x41 and \u{1F600}", namedHandler);"#,
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(definitions.len(), 2);
assert_eq!(definitions[0].matcher_flags, "i");
assert_eq!(definitions[1].matcher, "letter A and 😀");
}
#[test]
fn preserves_all_flags_but_normalizes_only_semantic_regex_flags() {
let definition = extract(
r"Then(/^VALUE$/ygusm, () => work());",
&file(SourceLanguage::JavaScript),
)
.unwrap()
.remove(0);
assert_eq!(definition.matcher_flags, "ygusm");
assert_eq!(definition.normalized_matcher, "[regex-flags:msu] ^VALUE$");
}
#[test]
fn resolves_named_handlers_to_bodies_without_cross_file_name_collisions() {
let first = extract(
"function handler() { doAlpha(); } Given('alpha step', handler);",
&SourceFile {
path: PathBuf::from("steps/a.ts"),
language: SourceLanguage::TypeScript,
},
)
.unwrap()
.remove(0);
let second = extract(
"function handler() { doBetaDifferently(); } Given('bravo step', handler);",
&SourceFile {
path: PathBuf::from("steps/b.ts"),
language: SourceLanguage::TypeScript,
},
)
.unwrap()
.remove(0);
assert!(first.handler.comparable);
assert!(second.handler.comparable);
assert!(first.handler.source_snippet.contains("doAlpha"));
assert_ne!(
first.handler.alpha_normalized,
second.handler.alpha_normalized
);
}
#[test]
fn resolves_bound_named_handlers_without_dropping_the_definition() {
let definition = extract(
"function handler() { doWork(); } Given('bound step', handler.bind(world));",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
assert!(definition.handler.comparable);
assert!(definition.handler.source_snippet.contains("doWork"));
}
#[test]
fn bound_arguments_are_part_of_handler_identity() {
let definitions = extract(
r#"
function handler(value) { doWork(value); }
Given('one', handler.bind(null, 1));
Given('two', handler.bind(null, 2));
Given('another one', handler.bind(null, 1));
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_ne!(
definitions[0].handler.alpha_normalized,
definitions[1].handler.alpha_normalized
);
assert_eq!(
definitions[0].handler.alpha_normalized,
definitions[2].handler.alpha_normalized
);
assert!(definitions[0]
.handler
.source_snippet
.contains("bound with (null, 1)"));
}
#[test]
fn unresolved_handler_references_are_not_comparable() {
let definition = extract(
"import { handler } from './shared'; Given('a step', handler);",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
assert!(!definition.handler.comparable);
assert!(!definition.handler.trivial);
}
#[test]
fn recognizes_resolved_and_inline_stub_handler_shapes() {
let definitions = extract(
r#"
function notImplemented() { throw new Error('pending'); }
Given('named stub', notImplemented);
Given('expression stub', () => pending());
Given('return stub', function () { return 'pending'; });
Given('await stub', async () => await pending());
Given('block await stub', async () => { await this.pending(); });
Given('return await stub', async function () { return await pending(); });
Given('promise stub', () => Promise.resolve());
Given('void stub', () => void 0);
Given('real handler', () => doWork());
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_eq!(definitions.len(), 9);
assert!(
definitions[0].handler.trivial,
"resolved named stub: {:?}",
definitions[0].handler
);
assert!(definitions[1].handler.trivial);
assert!(definitions[2].handler.trivial);
assert!(definitions[3].handler.trivial);
assert!(definitions[4].handler.trivial);
assert!(definitions[5].handler.trivial);
assert!(definitions[6].handler.trivial);
assert!(definitions[7].handler.trivial);
assert!(!definitions[8].handler.trivial);
}
#[test]
fn real_handlers_with_stub_like_text_remain_comparable() {
let definitions = extract(
r#"
Given('append', () => appending(row));
Given('timer', () => world.suspendingTimer());
Given('return value', () => { return 'the confirmed order id'; });
Given('domain error', () => { throw new DomainError(order.id); });
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_eq!(definitions.len(), 4);
assert!(definitions
.iter()
.all(|definition| !definition.handler.trivial));
}
#[test]
fn structural_templates_preserve_substitution_behavior() {
let definitions = extract(
r#"
Given('user name', () => render(`${user.firstName} ${user.lastName}`));
Given('order total', () => render(`${order.subtotal + order.tax}`));
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_ne!(
definitions[0].handler.structural,
definitions[1].handler.structural
);
}
#[test]
fn lowercase_registrations_require_supported_import_evidence() {
let unrelated = extract(
"function fn() { work(); } then('not a step', fn); const alias = then; alias('also not a step', fn);",
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert!(unrelated.is_empty());
let supported = extract(
r#"
import { createBdd } from 'playwright-bdd';
const { given } = createBdd(test);
given('a real step', () => work());
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_eq!(supported.len(), 1);
for source in [
"// import { given } from '@cucumber/cucumber';\ngiven('phantom', () => work());",
"const documentation = \"import { given } from 'playwright-bdd'\";\ngiven('phantom', () => work());",
] {
assert!(extract(source, &file(SourceLanguage::TypeScript))
.unwrap()
.is_empty());
}
}
#[test]
fn regular_expression_normalization_preserves_matching_semantics() {
assert_eq!(
normalize_regular_expression(r"^I have (\d{2}) (foo|bar)$"),
r"^I have (\d{2}) (foo|bar)$"
);
assert_eq!(
normalize_regular_expression(r"^value (?=ahead)(\w+)$"),
r"^value (?=ahead)(\w+)$"
);
assert_eq!(
normalize_regular_expression(r"^(?<name>\w+) wins$"),
normalize_regular_expression(r"^(\w+) wins$")
);
assert_ne!(
normalize_regular_expression(r"^I wait (\d+) seconds$"),
normalize_regular_expression(r"^I wait (a few|several) seconds$")
);
assert_ne!(
normalize_regular_expression(r"^(a)\1$"),
normalize_regular_expression(r"^(b)\1$")
);
assert_eq!(
normalize_regular_expression(r"^a (?:optional )?step$"),
"^a (?:optional )?step$"
);
assert_ne!(
normalize_regular_expression(r"^a step$"),
normalize_regular_expression(r"a step")
);
assert_eq!(normalize_regular_expression(r"[\s+]"), r"[\s+]");
}
#[test]
fn regex_flag_encoding_cannot_collide_with_literal_matcher_text() {
let definitions = extract(
r#"
Given(/^a step here$/i, () => first());
Given('a step here /i', () => second());
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_ne!(
definitions[0].normalized_matcher,
definitions[1].normalized_matcher
);
}
#[test]
fn resolves_supported_registration_import_shapes_and_alias_chains() {
for source in [
"export { given } from '@cucumber/cucumber'; given('step', () => work());",
"import * as c from '@cucumber/cucumber'; const { given } = c; given('step', () => work());",
"const G = Given; const H = G; H('step', () => work());",
"const { given: g } = require('@cucumber/cucumber'); const h = g; h('step', () => work());",
] {
let definitions = extract(source, &file(SourceLanguage::TypeScript)).unwrap();
assert_eq!(definitions.len(), 1, "source: {source}");
}
}
#[test]
fn runtime_absent_and_shadowed_registrations_are_ignored() {
for source in [
"import type { given } from '@cucumber/cucumber'; given('phantom', () => work());",
"function Given(name, handler) { return handler; } Given('phantom', () => work());",
"const Given = (name, handler) => handler; Given('phantom', () => work());",
"let Given; Given = wrap; Given('phantom', () => work());",
"const Given = wrap; Given('phantom', () => work());",
"class Given {} Given('phantom', () => work());",
"type Given = (name: string) => void; export type { Given }; Given('phantom', () => work());",
] {
let definitions = extract(source, &file(SourceLanguage::TypeScript)).unwrap();
assert!(definitions.is_empty(), "source: {source}");
}
}
#[test]
fn type_only_imports_neither_resolve_modules_nor_shadow_runtime_globals() {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("broken.ts"),
"export { Given } from '@cucumber/cucumber'; const broken = ;\n",
)
.unwrap();
let path = directory.path().join("steps.ts");
let source = r#"
import /* comment */ type { Given } from './broken';
import { type Then } from './missing';
Given('ambient runtime registration', () => work());
"#;
fs::write(&path, source).unwrap();
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(
source,
&SourceFile {
path,
language: SourceLanguage::TypeScript,
},
&mut session,
)
.unwrap();
assert!(extracted.diagnostics.is_empty());
assert_eq!(extracted.definitions.len(), 1);
assert_eq!(
extracted.definitions[0].matcher,
"ambient runtime registration"
);
assert_eq!(extracted.definitions[0].framework, Framework::Unknown);
}
#[test]
fn side_effect_framework_imports_preserve_runtime_framework_metadata() {
for (module, expected) in [
("@cucumber/cucumber", Framework::CucumberJs),
(
"@badeball/cypress-cucumber-preprocessor",
Framework::CypressCucumber,
),
] {
let definitions = extract_ts(&format!(
"import '{module}';\nGiven('side effect framework', () => work());\n"
));
assert_eq!(definitions.len(), 1, "{module}");
assert_eq!(definitions[0].framework, expected, "{module}");
}
}
#[test]
fn reports_unsupported_regexes_and_malformed_string_matchers() {
let extracted = extract_detailed(
r#"
Given(/value (?=ahead)/, () => work());
Given('broken \xZZ', () => broken());
Given('valid step', () => valid());
"#,
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(extracted.definitions.len(), 2);
assert_eq!(extracted.diagnostics.len(), 3);
assert!(extracted.diagnostics.iter().any(|diagnostic| {
diagnostic.level == ExtractionDiagnosticLevel::Warning
&& diagnostic.message.contains("syntax errors")
}));
assert!(extracted.diagnostics.iter().any(|diagnostic| {
diagnostic.level == ExtractionDiagnosticLevel::Warning
&& diagnostic
.message
.contains("unsupported by static usage analysis")
}));
assert!(extracted.diagnostics.iter().any(|diagnostic| {
diagnostic.level == ExtractionDiagnosticLevel::Error
&& diagnostic.message.contains("invalid JavaScript escape")
}));
}
#[test]
fn supported_regexes_remain_authoritative_under_resource_limits() {
for (matcher, flags) in [
(r"^the user exists$", ""),
(r"^the USER exists$", "i"),
(r"^(?:one|two|three) users$", "u"),
] {
assert_eq!(
rust_regex_support(matcher, flags),
RegexSupport::Supported,
"{matcher}/{flags}"
);
}
}
#[test]
fn grammar_limitations_stay_incomplete() {
let controls = [
(
SourceLanguage::Tsx,
"const n=<panel>body</panel>; Given('clean',()=>n);",
),
(
SourceLanguage::TypeScript,
"const x=<T>(v:T)=>v; const b=1<2; Given('clean',()=>x(b));",
),
(
SourceLanguage::JavaScript,
"import x from './x.json' with {type:'json'}; Given('clean',()=>x);",
),
];
for (language, source) in controls {
let extracted = extract_detailed(source, &file(language)).unwrap();
assert!(extracted.diagnostics.is_empty(), "{source}");
assert_eq!(extracted.definitions.len(), 1, "{source}");
}
let limitations = [
(
SourceLanguage::TypeScript,
"const x=f<typeof import('x')>();",
),
(
SourceLanguage::TypeScript,
"const x=tag.a<{on:boolean}>`x`;",
),
(
SourceLanguage::TypeScript,
"export type * as ns from './x';",
),
(SourceLanguage::TypeScript, "type F=(string)=>void;"),
(SourceLanguage::TypeScript, "type T=import('x').T[];"),
(
SourceLanguage::TypeScript,
"const x={} as unknown as (typeof module)['key'];",
),
(SourceLanguage::Tsx, "const x=<a href='?a=1&b=2' />;"),
(SourceLanguage::Tsx, "const x=<a>a&b</a>;"),
(SourceLanguage::Tsx, "const x=<a>🗑</a>;"),
(SourceLanguage::Tsx, "const x=<a title='🗑' />;"),
(SourceLanguage::JavaScript, "const x=<a href='?a=1&b=2' />;"),
(SourceLanguage::JavaScript, "const x=<a>🗑</a>;"),
(SourceLanguage::JavaScript, "const x=<button in />;"),
];
for (language, construct) in limitations {
let source =
format!("Given('before',()=>first());\n{construct}\nGiven('after',()=>last());");
let extracted = extract_detailed(&source, &file(language)).unwrap();
let diagnostics: Vec<_> = extracted
.diagnostics
.iter()
.filter(|diagnostic| crate::source_adapter::is_unparseable_diagnostic(diagnostic))
.collect();
assert_eq!(diagnostics.len(), 1, "{construct}");
assert_eq!(
diagnostics[0].level,
ExtractionDiagnosticLevel::Warning,
"{construct}"
);
}
}
#[test]
fn deeply_nested_handlers_do_not_use_the_process_stack() {
let depth = 8_000;
let source = format!(
"Given('deep step', () => {}value{});",
"[".repeat(depth),
"]".repeat(depth)
);
let extracted = extract_detailed(&source, &file(SourceLanguage::TypeScript)).unwrap();
assert_eq!(extracted.definitions.len(), 1);
}
#[test]
fn source_locations_count_unicode_code_points() {
let definition = extract(
"const prefix = '日本語'; Given('unicode column', () => work());",
&file(SourceLanguage::TypeScript),
)
.unwrap()
.remove(0);
assert_eq!(definition.location.column, 23);
}
#[test]
fn generator_handlers_are_extracted_and_comparable() {
let definitions = extract(
"Given('generator', function* () { yield performWork(); });",
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(definitions.len(), 1);
assert!(definitions[0].handler.comparable);
}
#[test]
fn dynamic_matchers_and_handlers_emit_diagnostics_instead_of_disappearing() {
let extracted = extract_detailed(
"Given(dynamicMatcher, () => work()); Given('wrapped', wrap(handler)); Given('object handler', { timeout: 1 }); Given('missing handler');",
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_eq!(extracted.definitions.len(), 1);
assert!(!extracted.definitions[0].handler.comparable);
assert!(extracted
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("step matcher")));
assert!(extracted
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("step handler")));
}
#[test]
fn unresolved_registration_shaped_calls_are_visible_without_flagging_helpers() {
let cases = [
(
"import { Given } from '@company/bdd';\nGiven('missing', () => work());",
1,
),
("const bdd = {}; bdd.Then('missing', () => work());", 1),
(
"Step('missing', () => work()); And('also missing', () => work());",
2,
),
("helper('ordinary call', () => work());", 0),
("load().then(() => work());", 0),
(
"import * as utilities from '@company/helpers'; utilities.log('ordinary call');",
0,
),
];
for (source, expected_calls) in cases {
let extracted = extract_detailed(source, &file(SourceLanguage::TypeScript)).unwrap();
assert!(extracted.definitions.is_empty(), "{source}");
let completeness = extracted
.diagnostics
.iter()
.filter(|diagnostic| crate::source_adapter::is_completeness_diagnostic(diagnostic))
.collect::<Vec<_>>();
if expected_calls == 0 {
assert!(completeness.is_empty(), "{source}");
} else {
assert_eq!(completeness.len(), 1, "{source}");
assert!(
completeness[0]
.message
.contains(&format!("{expected_calls} call(s)")),
"{source}"
);
}
}
}
#[test]
fn unresolved_registration_imports_name_the_module_in_diagnostics() {
for source in [
"import { Given } from '@company/bdd'; Given('missing', () => work());",
"import * as bdd from '@company/bdd'; bdd.Given('missing', () => work());",
] {
let extracted = extract_detailed(source, &file(SourceLanguage::TypeScript)).unwrap();
let diagnostics = extracted
.diagnostics
.iter()
.filter(|diagnostic| crate::source_adapter::is_completeness_diagnostic(diagnostic))
.collect::<Vec<_>>();
assert_eq!(diagnostics.len(), 1, "{source}");
assert!(
diagnostics[0]
.message
.contains("unresolved module `@company/bdd`"),
"{source}: {}",
diagnostics[0].message
);
}
}
#[test]
fn resolved_registration_with_an_unsupported_matcher_is_not_called_unresolved() {
let extracted = extract_detailed(
"Given(dynamicMatcher, () => work());",
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert!(extracted.definitions.is_empty());
assert!(extracted
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("step matcher")));
assert!(extracted
.diagnostics
.iter()
.any(crate::source_adapter::is_completeness_diagnostic));
assert!(!extracted.diagnostics.iter().any(|d| d
.message
.starts_with(UNRESOLVED_REGISTRATION_DIAGNOSTIC_PREFIX)));
}
#[test]
fn unresolved_calls_remain_visible_when_the_same_file_yields_definitions() {
let extracted = extract_detailed(
r#"
import { Given } from '@cucumber/cucumber';
import { Then } from '@company/bdd';
Given('visible step', () => visible());
Then('invisible step', () => invisible());
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_eq!(extracted.definitions.len(), 1);
let completeness = extracted
.diagnostics
.iter()
.filter(|diagnostic| crate::source_adapter::is_completeness_diagnostic(diagnostic))
.collect::<Vec<_>>();
assert_eq!(completeness.len(), 1);
assert_eq!(completeness[0].location.line, 5);
assert!(completeness[0].message.contains("1 call(s)"));
}
#[test]
fn extracts_registrations_through_transparent_wrappers_and_static_subscripts() {
let extracted = extract_detailed(
r#"
import { Given } from '@cucumber/cucumber';
import * as bdd from '@cucumber/cucumber';
(Given)('parenthesized', () => first());
(Given as typeof Given)('asserted', () => second());
(Given satisfies typeof Given)('satisfied', () => third());
Given!('non-null', () => fourth());
(<typeof Given>Given)('type-asserted', () => fifth());
(Given<string>)('instantiated', () => sixth());
bdd['Then']('subscripted', () => seventh());
bdd['Th\u0065n']('escaped subscript', () => eighth());
(bdd as typeof bdd).When('wrapped namespace', () => ninth());
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_eq!(extracted.definitions.len(), 9);
assert_eq!(extracted.definitions[0].matcher, "parenthesized");
assert_eq!(extracted.definitions[1].matcher, "asserted");
assert_eq!(extracted.definitions[2].matcher, "satisfied");
assert_eq!(extracted.definitions[3].matcher, "non-null");
assert_eq!(extracted.definitions[4].matcher, "type-asserted");
assert_eq!(extracted.definitions[5].matcher, "instantiated");
assert_eq!(extracted.definitions[6].registration, "Then");
assert_eq!(extracted.definitions[7].matcher, "escaped subscript");
assert_eq!(extracted.definitions[8].matcher, "wrapped namespace");
assert!(!extracted
.diagnostics
.iter()
.any(crate::source_adapter::is_completeness_diagnostic));
}
#[test]
fn static_subscript_registrations_are_included_in_completeness_diagnostics() {
let extracted = extract_detailed(
r#"
import { Given } from '@cucumber/cucumber';
import * as custom from '@company/bdd';
Given('visible step', () => visible());
custom['Then']('invisible step', () => invisible());
"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert_eq!(extracted.definitions.len(), 1);
let completeness = extracted
.diagnostics
.iter()
.filter(|diagnostic| crate::source_adapter::is_completeness_diagnostic(diagnostic))
.collect::<Vec<_>>();
assert_eq!(completeness.len(), 1);
assert_eq!(completeness[0].location.line, 5);
}
#[test]
fn computed_subscripts_and_lowercase_then_calls_are_not_registration_evidence() {
let extracted = extract_detailed(
"const property = 'Then'; custom[property]('dynamic', handler); promise['then'](handler);",
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert!(extracted.definitions.is_empty());
assert!(!extracted
.diagnostics
.iter()
.any(crate::source_adapter::is_completeness_diagnostic));
}
#[test]
fn deeply_parenthesized_registration_callees_are_unwrapped_iteratively() {
let depth = 1_000;
let source = format!(
"{}Given{}('deep', () => work());",
"(".repeat(depth),
")".repeat(depth)
);
let extracted = extract_detailed(&source, &file(SourceLanguage::TypeScript)).unwrap();
assert_eq!(extracted.definitions.len(), 1);
assert_eq!(extracted.definitions[0].matcher, "deep");
}
#[test]
fn positional_registration_wrappers_are_inferred_but_reordering_ones_are_not() {
let inferred = extract_ts(
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { Given(text, handler); }
step("wrapped step", () => work());"#,
);
assert_eq!(inferred.len(), 1);
assert_eq!(inferred[0].matcher, "wrapped step");
assert_eq!(inferred[0].registration, "Given");
let returned = extract_ts(
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { return Given(text, handler); }
step("returned step", () => work());"#,
);
assert_eq!(returned.len(), 1);
assert_eq!(returned[0].matcher, "returned step");
for source in [
r#"import { Given } from "@cucumber/cucumber";
function step(handler, text) { Given(text, handler); }
step(() => work(), "wrapped step");"#,
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { Given("prefix " + text, handler); }
step("wrapped step", () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
function step({ text }, handler) { Given(text, handler); }
step({ text: "wrapped step" }, () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { return [text, handler]; }
step("wrapped step", () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { return () => Given(text, handler); }
step("wrapped step", () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { if (enabled) Given(text, handler); }
step("wrapped step", () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { trace(text); Given(text, handler); }
step("wrapped step", () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
async function step(text, handler) { Given(text, handler); }
step("wrapped step", () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
function* step(text, handler) { Given(text, handler); }
step("wrapped step", () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
function outer() { function step(text, handler) { Given(text, handler); } }
step("wrapped step", () => work());"#,
] {
assert!(
extract_ts(source).is_empty(),
"must not infer a wrapper from: {source}"
);
}
}
#[test]
fn wrapper_inference_follows_chains_without_looping_on_self_reference() {
let chained = extract_ts(
r#"import { Given } from "@cucumber/cucumber";
function inner(text, handler) { Given(text, handler); }
function outer(text, handler) { inner(text, handler); }
outer("chained step", () => work());"#,
);
assert_eq!(chained.len(), 1);
assert_eq!(chained[0].matcher, "chained step");
assert!(extract_ts(
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { step(text, handler); }
step("looping step", () => work());"#,
)
.is_empty());
}
#[test]
fn wrapper_inference_resolves_long_chains_without_quadratic_work() {
let chain = |depth: usize| {
let mut source = String::from("import { Given } from \"@cucumber/cucumber\";\n");
for index in (1..depth).rev() {
source.push_str(&format!(
"function w{index}(text, handler) {{ w{}(text, handler); }}\n",
index - 1
));
}
source.push_str("function w0(text, handler) { Given(text, handler); }\n");
source.push_str(&format!("w{}(\"chained\", () => work());\n", depth - 1));
source
};
assert_eq!(extract_ts(&chain(200)).len(), 1);
assert_eq!(extract_ts(&chain(4000)).len(), 1);
}
#[test]
fn registration_discovery_covers_static_alias_and_shadowing_forms() {
let source = r#"
import type { Given as TypeGiven } from '@cucumber/cucumber';
import /* comment before the modifier */ type { Given as CommentTypeGiven } from '@cucumber/cucumber';
import { type Then as SpecifierTypeThen } from '@cucumber/cucumber';
import { Given as ImportedGiven } from '@cucumber/cucumber';
export /* comment before the modifier */ type { When as ExportedTypeWhen } from '@cucumber/cucumber';
export { type Then as ExportedSpecifierTypeThen } from '@cucumber/cucumber';
const cucumber = require('@cucumber/cucumber');
const { When: RequiredWhen, Then } = require('@cucumber/cucumber');
const { Given: NamespaceGiven } = cucumber;
const AliasGiven = ImportedGiven;
let AssignedGiven;
AssignedGiven = AliasGiven;
export function wrapped(text: string, handler: () => void) {
return (((ImportedGiven)))(text, handler);
}
ImportedGiven('imported', () => work());
cucumber.Then('namespace', () => work());
cucumber['When']('subscript', () => work());
RequiredWhen('required', () => work());
Then('destructured', () => work());
NamespaceGiven('namespace destructured', () => work());
AliasGiven('alias', () => work());
AssignedGiven('assigned', () => work());
wrapped('wrapped', () => work());
TypeGiven('type only', () => work());
CommentTypeGiven('commented type only', () => work());
SpecifierTypeThen('specifier type only', () => work());
ExportedTypeWhen('exported type only', () => work());
ExportedSpecifierTypeThen('exported specifier type only', () => work());
"#;
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 9);
assert!(definitions
.iter()
.all(|definition| definition.framework == Framework::CucumberJs));
for matcher in [
"type only",
"commented type only",
"specifier type only",
"exported type only",
"exported specifier type only",
] {
assert!(!definitions
.iter()
.any(|definition| definition.matcher == matcher));
}
let untrusted_create_bdd = extract_ts(
"const factory = createBdd(); const { Given } = factory; Given('not bdd', () => work());",
);
assert!(untrusted_create_bdd.is_empty());
}
#[test]
fn configured_assertion_modules_are_trusted_in_every_import_form() {
let configured = vec!["./fixtures".to_owned()];
let assertion = |header: &str, factory: &str, modules: &[String]| {
let mut session =
TypeScriptExtractionSession::for_root(std::path::Path::new("."), &[], modules);
let source = format!(
"{header}\nimport {{ Given }} from '@cucumber/cucumber';\n\
Given('a declared factory', () => {factory}(gauge()).toBe(1));"
);
let extracted =
extract_detailed_impl(&source, &file(SourceLanguage::TypeScript), &mut session)
.unwrap();
assert_eq!(extracted.definitions.len(), 1, "{header}");
extracted.definitions[0]
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:"))
};
for (header, factory) in [
("import { expect } from './fixtures';", "expect"),
("import * as fixtures from './fixtures';", "fixtures.expect"),
] {
assert!(
assertion(header, factory, &configured),
"declared module must be trusted: {header}"
);
assert!(
!assertion(header, factory, &[]),
"undeclared module must stay untrusted: {header}"
);
}
}
#[test]
fn configured_registration_names_override_inference_without_duplicating_known_aliases() {
let configured = vec!["step".to_owned(), "Given".to_owned()];
let mut session =
TypeScriptExtractionSession::for_root(std::path::Path::new("."), &configured, &[]);
let source = "function step(text, handler) { dynamic(text, handler); }\nstep('configured', () => work());";
let extracted =
extract_detailed_impl(source, &file(SourceLanguage::TypeScript), &mut session).unwrap();
assert_eq!(extracted.definitions.len(), 1);
assert_eq!(extracted.definitions[0].registration, "step");
}
#[test]
fn wrapper_forwards_unwrap_parentheses_but_reject_namespaced_or_short_calls() {
let parenthesized = extract_ts(
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { return (Given(text, handler)); }
step("parenthesized forward", () => work());"#,
);
assert_eq!(parenthesized.len(), 1);
assert_eq!(parenthesized[0].matcher, "parenthesized forward");
assert_eq!(parenthesized[0].registration, "Given");
for source in [
r#"import * as bdd from "@cucumber/cucumber";
function step(text, handler) { bdd.Given(text, handler); }
step("namespaced forward", () => work());"#,
r#"import { Given } from "@cucumber/cucumber";
function step(text, handler) { Given(text); }
step("short forward", () => work());"#,
] {
assert!(
extract_ts(source).is_empty(),
"must not infer a wrapper from: {source}"
);
}
}
#[test]
fn type_only_specifiers_never_alias_registrations_in_resolved_or_unresolved_imports() {
let definitions = extract_ts(
r#"import { type Given as Shape, Given as Setup } from '@cucumber/cucumber';
Setup('a step', () => work());
Shape('not a step', () => work());"#,
);
assert_eq!(definitions.len(), 1);
assert_eq!(definitions[0].matcher, "a step");
assert_eq!(definitions[0].registration, "Given");
let extracted = extract_detailed(
r#"import { type Given as Shape, When as Setup } from '@company/bdd';
Setup('missing', () => work());
Shape('not a registration', () => work());"#,
&file(SourceLanguage::TypeScript),
)
.unwrap();
assert!(extracted.definitions.is_empty());
let completeness = extracted
.diagnostics
.iter()
.filter(|diagnostic| crate::source_adapter::is_completeness_diagnostic(diagnostic))
.collect::<Vec<_>>();
assert_eq!(completeness.len(), 1);
assert!(
completeness[0].message.contains("1 call(s)"),
"{}",
completeness[0].message
);
}
#[test]
fn re_exports_and_destructures_only_alias_names_the_framework_publishes() {
let re_exported = extract_ts(
r#"export { World, Given as Setup } from '@cucumber/cucumber';
Setup('a step', () => work());
World('not a step', () => work());"#,
);
assert_eq!(re_exported.len(), 1);
assert_eq!(re_exported[0].matcher, "a step");
assert_eq!(re_exported[0].registration, "Given");
assert!(extract_ts(
r#"const [Setup] = require('@cucumber/cucumber');
Setup('not a step', () => work());"#,
)
.is_empty());
let with_rest = extract_ts(
r#"const { Given: Setup, ...others } = require('@cucumber/cucumber');
Setup('a step', () => work());
others.Given('not a step', () => work());"#,
);
assert_eq!(with_rest.len(), 1);
assert_eq!(with_rest[0].matcher, "a step");
assert_eq!(with_rest[0].registration, "Given");
}
#[test]
fn language_adapters_reject_sources_from_another_language() {
let error = TYPESCRIPT_ADAPTER
.extract(
"Given('a step', () => work());",
&file(SourceLanguage::JavaScript),
)
.unwrap_err()
.to_string();
assert!(
error.contains("typescript adapter cannot parse")
&& error.contains("JavaScript")
&& error.contains("features/steps/example.ts"),
"{error}"
);
let error = JAVASCRIPT_ADAPTER
.extract(
"Given('a step', () => work());",
&file(SourceLanguage::TypeScript),
)
.unwrap_err()
.to_string();
assert!(error.contains("javascript adapter cannot parse"), "{error}");
}
#[test]
fn decorated_method_parameter_defaults_are_part_of_handler_identity() {
let definitions = extract_ts(
r#"
import { Given } from 'playwright-bdd/decorators';
class Steps {
@Given('the first page opens')
first({ page: tab = makePage() }) { tab.goto('/'); }
@Given('the second page opens')
second({ page: view = makePage() }) { view.goto('/'); }
@Given('the third page opens')
third({ page: tab = makeOtherPage() }) { tab.goto('/'); }
@Given('the fourth page is defaulted but empty')
fourth({ page: tab = makePage() }) {}
@Given('the fifth page is empty')
fifth({ page: tab }) {}
}
"#,
);
assert_eq!(definitions.len(), 5);
assert_eq!(
definitions[0].handler.alpha_normalized,
definitions[1].handler.alpha_normalized
);
assert_ne!(
definitions[0].handler.alpha_normalized,
definitions[2].handler.alpha_normalized
);
assert_eq!(
definitions[0].handler.behavior_signature,
["method:instance sync", "call:makePage", "call:v0#goto"]
);
assert!(!definitions[3].handler.trivial);
assert!(definitions[4].handler.trivial);
}
#[test]
fn javascript_decorated_method_defaults_change_handler_identity() {
let source = |initializer: &str| {
format!(
r#"
import {{ Given }} from 'playwright-bdd/decorators';
class Steps {{
@Given('the page opens')
open(page = {initializer}) {{ page.goto('/'); }}
}}
"#
)
};
let first = extract(&source("makePage()"), &file(SourceLanguage::JavaScript)).unwrap();
let renamed = extract(
&source("makePage()").replace("page", "tab"),
&file(SourceLanguage::JavaScript),
)
.unwrap();
let other = extract(
&source("makeOtherPage()"),
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(first.len(), 1);
assert_eq!(
first[0].handler.behavior_signature,
["method:instance sync", "call:makePage", "call:v0#goto"]
);
assert_eq!(
first[0].handler.alpha_normalized,
renamed[0].handler.alpha_normalized
);
assert_ne!(
first[0].handler.alpha_normalized,
other[0].handler.alpha_normalized
);
}
#[test]
fn named_handlers_resolve_to_the_innermost_and_latest_binding() {
let nested = extract(
r#"
function run() { outerAction(); }
function register() {
function run() { innerAction(); }
Given('a nested registration', run);
}
"#,
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(nested.len(), 1);
assert_eq!(nested[0].handler.behavior_signature, ["call:innerAction"]);
let redeclared = extract(
r#"
var run = () => firstAction();
var run = () => secondAction();
Given('a redeclared registration', run);
"#,
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(redeclared.len(), 1);
assert_eq!(
redeclared[0].handler.behavior_signature,
["call:secondAction"]
);
}
#[test]
fn property_handler_expressions_stay_uncomparable() {
for source in [
"Given('a member handler', steps.run);",
"Given('a bound member handler', steps.run.bind(steps));",
] {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert!(!definitions[0].handler.comparable, "{source}");
assert!(!definitions[0].handler.trivial, "{source}");
}
}
#[test]
fn nullish_and_marker_handlers_are_recognized_as_stubs() {
let definitions = extract(
r#"
Given('null stub', () => null);
Given('undefined stub', () => undefined);
Given('thrown marker stub', () => { throw pending; });
Given('real handler', () => doWork());
"#,
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(definitions.len(), 4);
assert!(definitions[0].handler.trivial);
assert!(definitions[1].handler.trivial);
assert!(definitions[2].handler.trivial);
assert!(!definitions[3].handler.trivial);
}
#[test]
fn calls_on_unnameable_receivers_keep_their_structural_callee() {
let definitions = extract(
r#"
Given('literal receiver', () => 'the value'.trim());
Given('other literal receiver', () => 'the value'.trimEnd());
"#,
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(definitions.len(), 2);
assert_eq!(definitions[0].handler.behavior_signature.len(), 1);
assert!(
definitions[0].handler.behavior_signature[0].starts_with("call:(member_expression"),
"{:?}",
definitions[0].handler.behavior_signature
);
assert_ne!(
definitions[0].handler.behavior_signature,
definitions[1].handler.behavior_signature
);
}
#[test]
fn comments_do_not_change_normalized_handler_fingerprints() {
let definitions = extract(
r#"
Given('with comment', () => { /* explain the wait */ work(); });
Given('without comment', () => { work(); });
"#,
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(definitions.len(), 2);
assert_eq!(
definitions[0].handler.normalized,
definitions[1].handler.normalized
);
assert_ne!(definitions[0].handler.exact, definitions[1].handler.exact);
}
#[test]
fn loop_declarations_shadow_the_ambient_assertion_within_their_scope() {
let lexical = extract_ts(
r#"
Then('lexical loop declaration', ({ state }) => {
for (let expect = 0; expect < 1; expect += 1) { expect(state).toBe('ready'); }
expect(state).toBe('done');
});
"#,
);
assert_eq!(lexical.len(), 1);
assert_eq!(
lexical[0]
.handler
.behavior_signature
.iter()
.filter(|event| event.starts_with("assert:"))
.count(),
1,
"{:?}",
lexical[0].handler.behavior_signature
);
let hoisted = extract_ts(
r#"
Then('hoisted loop declaration', ({ state }) => {
for (var expect = 0; expect < 1; expect += 1) { record(expect); }
expect(state).toBe('done');
});
"#,
);
assert_eq!(hoisted.len(), 1);
assert!(
hoisted[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{:?}",
hoisted[0].handler.behavior_signature
);
}
#[test]
fn nested_namespace_declarations_shadow_assertions_only_inside_their_block() {
let definitions = extract_ts(
r#"
Then('assertion inside the namespace block', ({ state }) => {
{
namespace expect { export const custom = true; }
expect(state).toBe('ready');
}
});
Then('assertion beside the namespace block', ({ state }) => {
{ namespace expect { export const custom = true; } }
expect(state).toBe('ready');
});
"#,
);
assert_eq!(definitions.len(), 2);
assert!(
definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{:?}",
definitions[0].handler.behavior_signature
);
assert!(
definitions[1]
.handler
.behavior_signature
.iter()
.any(|event| event.starts_with("assert:")),
"{:?}",
definitions[1].handler.behavior_signature
);
}
#[test]
fn import_require_clauses_bind_assertion_namespaces_by_module() {
let namespaced = extract_ts(
r#"
import testApi = require('@playwright/test');
Then('namespace property assertion', ({ state }) => testApi.expect(state).toBe('ready'));
"#,
);
assert_eq!(namespaced.len(), 1);
assert!(
namespaced[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{:?}",
namespaced[0].handler.behavior_signature
);
let called = extract_ts(
r#"
import testApi = require('@playwright/test');
Then('namespace called directly', ({ state }) => testApi(state).toBe('ready'));
"#,
);
assert_eq!(called.len(), 1);
assert!(
called[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{:?}",
called[0].handler.behavior_signature
);
}
#[test]
fn asymmetric_matchers_are_recognized_through_namespaced_factories() {
let namespaced = extract_ts(
r#"
import * as testApi from '@playwright/test';
Then('namespaced matcher', ({ state }) =>
testApi.expect(state).toEqual(testApi.expect.objectContaining({ ready: true })));
"#,
);
let ambient = extract_ts(
r#"
Then('ambient matcher', ({ state }) =>
expect(state).toEqual(expect.objectContaining({ ready: true })));
"#,
);
assert_eq!(namespaced.len(), 1);
assert_eq!(ambient.len(), 1);
assert!(
namespaced[0].handler.behavior_signature[0].starts_with("assert:expect#toEqual:"),
"{:?}",
namespaced[0].handler.behavior_signature
);
assert_eq!(
namespaced[0].handler.behavior_signature,
ambient[0].handler.behavior_signature
);
let opaque = extract_ts(
r#"
Then('opaque expected value', ({ state }) => expect(state).toEqual(buildExpected()));
"#,
);
assert_eq!(opaque.len(), 1);
assert_ne!(
opaque[0].handler.behavior_signature,
ambient[0].handler.behavior_signature
);
}
#[test]
fn array_destructured_requires_shadow_rather_than_trust_the_assertion_factory() {
let definitions = extract_ts(
r#"
const [expect] = require('expect');
Then('array destructured require', ({ state }) => expect(state).toBe('ready'));
"#,
);
assert_eq!(definitions.len(), 1);
assert!(
definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{:?}",
definitions[0].handler.behavior_signature
);
}
#[test]
fn type_only_specifiers_beside_a_runtime_expect_import_preserve_trust() {
let definitions = extract_ts(
r#"
import { expect, type Locator } from '@playwright/test';
Then('mixed specifier import', ({ state }) => expect(state).toBe('ready'));
"#,
);
assert_eq!(definitions.len(), 1);
assert!(
definitions[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{:?}",
definitions[0].handler.behavior_signature
);
}
#[test]
fn decorator_registrations_skip_sibling_decorators_to_reach_their_method() {
for source in [
r#"
import { Given } from 'playwright-bdd/decorators';
class Steps {
@Given('a decorated step')
@log()
run() { work(); }
}
"#,
r#"
import { Given } from 'playwright-bdd/decorators';
class Steps {
@log()
@Given('a decorated step')
run() { work(); }
}
"#,
] {
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1, "{source}");
assert_eq!(definitions[0].matcher, "a decorated step", "{source}");
assert_eq!(
definitions[0].handler.behavior_signature,
["method:instance sync", "call:work"],
"{source}"
);
}
}
#[test]
fn assertion_chains_unwrap_angle_bracket_type_assertions() {
let asserted =
extract_ts("Then('cast assertion', ({ state }) => (<any>expect(state)).toBe('ready'));");
let plain = extract_ts("Then('plain assertion', ({ state }) => expect(state).toBe('ready'));");
assert_eq!(asserted.len(), 1);
assert_eq!(plain.len(), 1);
assert!(
asserted[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{:?}",
asserted[0].handler.behavior_signature
);
assert_eq!(
asserted[0].handler.behavior_signature,
plain[0].handler.behavior_signature
);
}
#[test]
fn awaited_angle_bracket_casts_still_resolve_the_assertion_chain() {
let cast = extract_ts(
"Then('awaited cast assertion', async ({ state }) => (await <any>expect(state)).toBe('ready'));",
);
let plain = extract_ts(
"Then('awaited assertion', async ({ state }) => (await expect(state)).toBe('ready'));",
);
assert_eq!(cast.len(), 1);
assert_eq!(plain.len(), 1);
assert!(
cast[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{:?}",
cast[0].handler.behavior_signature
);
assert_eq!(
cast[0].handler.behavior_signature,
plain[0].handler.behavior_signature
);
}
#[test]
fn loop_heads_without_a_declaration_introduce_no_binding() {
let definitions = extract_ts(
r#"
Then('loop without a declaration', ({ state }) => {
for (;;) { expect(state).toBe('ready'); break; }
});
"#,
);
assert_eq!(definitions.len(), 1);
assert_eq!(
definitions[0].handler.behavior_signature[0],
"for_statement"
);
assert!(
definitions[0].handler.behavior_signature[1].starts_with("assert:expect#toBe:"),
"{:?}",
definitions[0].handler.behavior_signature
);
}
#[test]
fn labelled_module_level_var_declarations_shadow_the_ambient_assertion() {
let definitions = extract_ts(
r#"
outer: var expect = makeLocalAssertion();
Then('labelled module var', ({ state }) => expect(state).toBe('ready'));
"#,
);
assert_eq!(definitions.len(), 1);
assert!(
definitions[0]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{:?}",
definitions[0].handler.behavior_signature
);
}
#[test]
fn only_named_receivers_revoke_namespace_assertion_trust() {
let assertion_events = |write: &str| {
let definitions = extract_ts(&format!(
r#"
import * as testApi from '@playwright/test';
{write}
Then('namespace assertion', ({{ state }}) => testApi.expect(state).toBe('ready'));
"#
));
assert_eq!(definitions.len(), 1, "{write}");
definitions[0]
.handler
.behavior_signature
.iter()
.filter(|event| event.starts_with("assert:"))
.count()
};
assert_eq!(assertion_events("makeApi().expect = custom;"), 1);
assert_eq!(assertion_events("testApi.expect = custom;"), 0);
}
#[test]
fn nested_block_writes_resolve_to_the_shadowing_binding_not_the_namespace() {
let assertion_events = |binding: &str| {
let definitions = extract_ts(&format!(
r#"
import * as testApi from '@playwright/test';
function helper() {{
{binding}
{{
testApi.expect = custom;
}}
}}
Then('namespace assertion', ({{ state }}) => testApi.expect(state).toBe('ready'));
"#
));
assert_eq!(definitions.len(), 1, "{binding}");
definitions[0]
.handler
.behavior_signature
.iter()
.filter(|event| event.starts_with("assert:"))
.count()
};
assert_eq!(assertion_events("let testApi = other;"), 1);
assert_eq!(assertion_events("let unrelated = other;"), 0);
}
#[test]
fn alias_provenance_resolves_deep_scopes_with_many_assignment_edges_without_quadratic_work() {
let mut source = String::from("import { Given } from '@cucumber/cucumber';\n");
for depth in 0..40 {
source.push_str(&format!("function level{depth}() {{\n"));
for index in 0..50 {
source.push_str(&format!("var a{index} = b{index};\n"));
source.push_str(&format!("a{index} = b{index};\n"));
}
}
source.push_str("Given('deep', () => work());\n");
source.push_str(&"}\n".repeat(40));
let definitions = extract_ts(&source);
assert_eq!(definitions.len(), 1);
}
#[test]
fn alias_propagation_terminates_on_cyclic_subscript_reassignments() {
let source = r#"
import { expect } from 'chai';
function child(a, c, e, f, h, k, l, m, o, p, r, s, u, x, y, S) {
d = !1;
if (c) {
if (y) {
while (l) {
a = e;
while (a = a[l]) if (x ? a.nodeName.toLowerCase() === f : 1 === a.nodeType) return !1;
u = l = "only" === h && !u && "nextSibling";
}
return !0;
}
if (u = [m ? c.firstChild : c.lastChild], m && p) {
d = (s = (r = (i = (o = (a = c)[S] || (a[S] = {}))[a.uniqueID] || (o[a.uniqueID] = {}))[h] || [])[0] === k && r[1]) && r[2];
}
}
}
Then('subscript cycle', ({ state }) => expect(state).toBe('ready'));
"#;
let definitions = extract_ts(source);
assert_eq!(definitions.len(), 1);
assert_eq!(
definitions[0]
.handler
.behavior_signature
.iter()
.filter(|event| event.starts_with("assert:"))
.count(),
1
);
}
#[test]
fn defaulted_destructured_matcher_aliases_propagate_writes_but_unrelated_ones_do_not() {
let matcher_event = |alias: &str| {
let definitions = extract_ts(&format!(
r#"
import * as testApi from '@playwright/test';
const {{ {alias} = fallback }} = testApi;
{alias}.objectContaining = custom;
Then('namespaced matcher', ({{ state }}) =>
testApi.expect(state).toEqual(testApi.expect.objectContaining({{ ready: true }})));
"#
));
assert_eq!(definitions.len(), 1, "{alias}");
definitions[0].handler.behavior_signature[0].clone()
};
let unrelated = matcher_event("unrelated");
let aliased = matcher_event("not");
assert!(
unrelated.starts_with("assert:expect#toEqual:"),
"{unrelated}"
);
assert_ne!(unrelated, aliased);
}
#[test]
fn rest_copies_alias_the_namespace_only_through_a_bound_identifier() {
let matcher_event = |target: &str| {
let definitions = extract_ts(&format!(
r#"
import * as testApi from '@playwright/test';
({{ ...{target} }} = testApi);
{target}.not.objectContaining = custom;
Then('namespaced matcher', ({{ state }}) =>
testApi.expect(state).toEqual(testApi.expect.objectContaining({{ ready: true }})));
"#
));
assert_eq!(definitions.len(), 1, "{target}");
definitions[0].handler.behavior_signature[0].clone()
};
let property_target = matcher_event("holder.copy");
let binding_target = matcher_event("copy");
assert!(
property_target.starts_with("assert:expect#toEqual:"),
"{property_target}"
);
assert_ne!(property_target, binding_target);
}
#[test]
fn facade_require_imports_bind_namespaces_without_assertion_trust() {
let directory = tempfile::tempdir().unwrap();
fs::create_dir_all(directory.path().join("fixtures")).unwrap();
fs::create_dir_all(directory.path().join("steps")).unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
fs::write(
directory.path().join("tsconfig.json"),
r#"{"compilerOptions":{"paths":{"~/*":["./*"]}}}"#,
)
.unwrap();
fs::write(
directory.path().join("fixtures/test.ts"),
r#"
import { createBdd } from 'playwright-bdd';
export const { Then } = createBdd({});
export const expect = createAssertionFactory();
"#,
)
.unwrap();
let path = directory.path().join("steps/example.steps.ts");
let source = r#"
import { Then } from '~/fixtures/test';
import { expect } from '~/fixtures/test';
import facade = require('~/fixtures/test');
Then('a named facade import is trusted', ({ state }) => expect(state).toBe('ready'));
Then('a facade require namespace is not', ({ state }) => facade.expect(state).toBe('ready'));
"#;
fs::write(&path, source).unwrap();
let file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
let extracted = extract_detailed_impl(source, &file, &mut session).unwrap();
assert_eq!(extracted.definitions.len(), 2);
assert!(
extracted.definitions[0].handler.behavior_signature[0].starts_with("assert:expect#toBe:"),
"{:?}",
extracted.definitions[0].handler.behavior_signature
);
assert!(
extracted.definitions[1]
.handler
.behavior_signature
.iter()
.all(|event| !event.starts_with("assert:")),
"{:?}",
extracted.definitions[1].handler.behavior_signature
);
}
#[test]
fn constant_aliases_of_parameters_normalize_without_resolving_a_value() {
let definitions = extract(
r#"
Given('first', (page, other) => { const alias = page; alias.goto('/'); });
Given('second', (tab, extra) => { const copy = tab; copy.goto('/'); });
Given('third', (page, other) => { const alias = other; alias.goto('/'); });
"#,
&file(SourceLanguage::JavaScript),
)
.unwrap();
assert_eq!(definitions.len(), 3);
assert_eq!(
definitions[0].handler.alpha_normalized,
definitions[1].handler.alpha_normalized
);
assert_ne!(
definitions[0].handler.alpha_normalized,
definitions[2].handler.alpha_normalized
);
}
#[test]
fn a_string_literal_does_not_forge_sibling_structure_into_an_equal_fingerprint() {
let two_args = extract_ts(r#"Given('two args', () => { g("m", "n"); });"#);
let forged_single = extract_ts(
r#"Given('forged', () => { g("m \":\") ,:, (string \":\" string_fragment:n"); });"#,
);
assert_eq!(two_args.len(), 1);
assert_eq!(forged_single.len(), 1);
assert_ne!(
two_args[0].handler.alpha_normalized,
forged_single[0].handler.alpha_normalized,
);
}
#[test]
fn assertion_event_components_escape_delimiters_so_computed_keys_do_not_forge_chains() {
let dotted = extract_ts("Then('h', ({ state }) => expect(state).not.toBe('x'));");
let computed_same = extract_ts("Then('h', ({ state }) => expect(state)['not'].toBe('x'));");
let computed_delimited =
extract_ts("Then('h', ({ state }) => expect(state)['not.toBe']('x'));");
assert_eq!(
dotted[0].handler.behavior_signature,
computed_same[0].handler.behavior_signature,
);
assert_ne!(
dotted[0].handler.behavior_signature,
computed_delimited[0].handler.behavior_signature,
);
assert!(computed_delimited[0].handler.behavior_signature[0].contains("not\\.toBe"));
}
fn extract_in_project(files: &[(&str, &str)], source: &str) -> Extraction {
let directory = tempfile::tempdir().unwrap();
fs::write(directory.path().join("package.json"), "{}").unwrap();
for (name, contents) in files {
fs::write(directory.path().join(name), contents).unwrap();
}
let path = directory.path().join("steps.ts");
fs::write(&path, source).unwrap();
let source_file = SourceFile {
path,
language: SourceLanguage::TypeScript,
};
let mut session = TypeScriptExtractionSession::for_root(directory.path(), &[], &[]);
extract_detailed_impl(source, &source_file, &mut session).unwrap()
}
#[test]
fn default_and_import_equals_bindings_register_only_where_they_bind_the_module() {
let modules = [
("barrel.ts", "export { Given } from '@cucumber/cucumber';\n"),
(
"cjs.js",
"const { Given } = require('@cucumber/cucumber');\nmodule.exports = { Given };\n",
),
];
let call = "c.Given('a step', () => work());\n";
let generic = "look like step registrations";
for (import, registered, warning) in [
("import c from '@cucumber/cucumber';\n", 0, Some(generic)),
("import c = require('@cucumber/cucumber');\n", 1, None),
("import c = require('./cjs');\n", 1, None),
("import c from './barrel';\n", 0, Some(generic)),
(
"import c from './missing';\n",
0,
Some("module `./missing`"),
),
(
"import c = require('./missing');\n",
0,
Some("module `./missing`"),
),
] {
let extracted = extract_in_project(&modules, &format!("{import}{call}"));
assert_eq!(extracted.definitions.len(), registered, "{import}");
let messages: Vec<_> = extracted
.diagnostics
.iter()
.map(|diagnostic| diagnostic.message.as_str())
.collect();
match warning {
None => assert!(messages.is_empty(), "{import}: {messages:?}"),
Some(expected) => assert!(
messages.iter().any(|message| message.contains(expected)),
"{import}: {messages:?}"
),
}
}
}
#[test]
fn every_runtime_import_form_shadows_the_ambient_registration_global() {
let modules = [(
"cjs.js",
"module.exports = { Given: (text, fn) => text };\n",
)];
let call = "Given('a step', () => work());\n";
for import in [
"import Given from './cjs';\n",
"import * as Given from './cjs';\n",
"import Given = require('./cjs');\n",
"import { Given } from './cjs';\n",
] {
let extracted = extract_in_project(&modules, &format!("{import}{call}"));
assert!(extracted.definitions.is_empty(), "{import}");
}
for control in ["", "import type Given from './cjs';\n"] {
let extracted = extract_in_project(&modules, &format!("{control}{call}"));
assert_eq!(extracted.definitions.len(), 1, "control: {control:?}");
}
}
#[test]
fn passing_a_registration_to_a_function_is_reported() {
let modules = [(
"helper.js",
"module.exports = (register, text, fn) => register(text, fn);\n",
)];
let passed = "pass a step registration to a function";
for (source, reported) in [
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(Given, 'a step', () => work());\n",
true,
),
(
"import * as cucumber from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(cucumber.Given, 'a step', () => work());\n",
true,
),
(
"import cucumber = require('@cucumber/cucumber');\nimport helper from './helper';\nhelper((cucumber.Given), 'a step', () => work());\n",
true,
),
(
"import helper from './helper';\nhelper(Given, 'a step', () => work());\n",
true,
),
(
"import helper from './helper';\nhelper('a step', () => work());\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper({ register: Given }, 'a step', () => work());\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper({ Given }, 'a step', () => work());\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper([Given], 'a step', () => work());\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(() => Given, 'a step', () => work());\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(new Wrapper(Given));\n",
true,
),
(
"import { Given, When } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(flag ? Given : When);\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(Given || fallback);\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(function () { if (ready) { return Given; } });\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(() => { try { return Given; } finally { done(); } });\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(function () { switch (kind) { case 'given': return Given; default: return null; } });\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(() => { for (const item of items) { if (item) { return Given; } } });\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(function* () { yield Given; });\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper({ register() { return Given; } });\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper({ get register() { return Given; } });\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper((setup(), Given));\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(...[Given]);\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper({ ...Given }, [...Given]);\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nlet value = '';\nhelper(value += Given);\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper((Given, 42));\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(() => { function inner() { return Given; } return 1; });\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(register = Given);\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(Given.bind(null), 'a step', () => work());\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(Given.toString());\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(Given.name, typeof Given, Given !== undefined);\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(new Given(), Given ? 1 : 2);\n",
false,
),
(
"import helper from './helper';\nhelper({ register: other, list: [1] }, 'a step', () => work());\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nimport helper from './helper';\nhelper(() => Given('a step', () => work()));\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nexport const fixtures = { When: [({ Given }, use) => use(Given)] };\n",
false,
),
(
"import * as cucumber from '@cucumber/cucumber';\nexport const fixtures = { When: [(cucumber, use) => use(cucumber.Given)] };\n",
false,
),
(
"import * as cucumber from '@cucumber/cucumber';\nexport const fixtures = { When: [(cucumber, use) => use((cucumber.Given))] };\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nexport function setup(use) {\n const Given = make();\n use(Given);\n}\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nexport function setup(use) {\n function Given() {}\n use(Given);\n}\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nexport function setup(use) {\n class Given {}\n use(Given);\n}\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\ntry { run(); } catch (Given) { use(Given); }\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nfor (const Given of values) { use(Given); }\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nfor (let Given = first(); Given; Given = next()) { use(Given); }\n",
false,
),
(
"import { Given } from '@cucumber/cucumber';\nfor (Given of values) { use(Given); }\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nfor (const item of values) { use({ Given }); }\n",
true,
),
(
"import { Given } from '@cucumber/cucumber';\nGiven('a step', () => work());\n",
false,
),
] {
let extracted = extract_in_project(&modules, source);
let messages: Vec<_> = extracted
.diagnostics
.iter()
.map(|diagnostic| diagnostic.message.as_str())
.collect();
assert_eq!(
messages.iter().any(|message| message.contains(passed)),
reported,
"{source}: {messages:?}"
);
}
}