use std::path::{Path, PathBuf};
use rustc_hash::FxHashMap;
use oxc_ast::ast::{Expression, ObjectExpression, ObjectPropertyKind};
use super::config_parser;
use super::{PathRule, Plugin, PluginResult};
use crate::scripts;
const DEFAULT_TEST_ENTRY_PATTERNS: &[&str] = &[
"**/*.spec.{ts,tsx,js,jsx}",
"**/*.test.{ts,tsx,js,jsx}",
"tests/**/*.{ts,tsx,js,jsx}",
"e2e/**/*.{ts,tsx,js,jsx}",
];
const TEST_FILE_NAME_PATTERNS: &[&str] =
&["**/*.spec.{ts,tsx,js,jsx}", "**/*.test.{ts,tsx,js,jsx}"];
const TEST_FILE_EXTENSIONS: &str = "{ts,tsx,js,jsx,mts,cts,mjs,cjs}";
const TEST_RUNNER_PACKAGE: &str = "@playwright/test";
const CORE_PACKAGE: &str = "playwright";
const CONFIG_FILES: &[&str] = &["playwright.config.ts", "playwright.config.js"];
const CONFIG_PATTERNS: &[&str] = &["playwright.config.{ts,js}"];
pub struct PlaywrightPlugin;
impl Plugin for PlaywrightPlugin {
fn name(&self) -> &'static str {
"playwright"
}
fn enablers(&self) -> &'static [&'static str] {
&[TEST_RUNNER_PACKAGE]
}
fn is_enabled_with_deps(&self, deps: &[String], root: &Path) -> bool {
let has = |name: &str| deps.iter().any(|dep| dep == name);
has(TEST_RUNNER_PACKAGE)
|| (has(CORE_PACKAGE) && CONFIG_FILES.iter().any(|file| root.join(file).is_file()))
}
fn entry_patterns(&self) -> &'static [&'static str] {
DEFAULT_TEST_ENTRY_PATTERNS
}
fn config_patterns(&self) -> &'static [&'static str] {
CONFIG_PATTERNS
}
fn always_used(&self) -> &'static [&'static str] {
CONFIG_PATTERNS
}
fn tooling_dependencies(&self) -> &'static [&'static str] {
&[TEST_RUNNER_PACKAGE, CORE_PACKAGE]
}
fn fixture_glob_patterns(&self) -> &'static [&'static str] {
&[
"**/fixtures/**/*.{ts,tsx,js,jsx,json}",
"e2e/fixtures/**/*.{ts,tsx,js,jsx,json}",
]
}
fn resolve_config(&self, config_path: &Path, source: &str, root: &Path) -> PluginResult {
let mut result = PluginResult::default();
let config_dir = config_path
.parent()
.filter(|parent| parent.is_absolute())
.unwrap_or(root);
let imports = config_parser::extract_imports(source, config_path);
for imp in &imports {
let dep = crate::resolve::extract_package_name(imp);
result.referenced_dependencies.push(dep);
}
if let Some(setup) =
config_parser::extract_config_string(source, config_path, &["globalSetup"])
{
result
.setup_files
.push(config_dir.join(setup.trim_start_matches("./")));
}
if let Some(teardown) =
config_parser::extract_config_string(source, config_path, &["globalTeardown"])
{
result
.setup_files
.push(config_dir.join(teardown.trim_start_matches("./")));
}
let (web_deps, web_setup) = collect_web_server(source, config_path, root, config_dir);
result.referenced_dependencies.extend(web_deps);
result.setup_files.extend(web_setup);
match test_entry_patterns(source, config_path, root, config_dir) {
Some(test_dir_patterns) => {
result.extend_entry_patterns(TEST_FILE_NAME_PATTERNS.iter().copied());
result.entry_patterns.extend(test_dir_patterns);
}
None => result.extend_entry_patterns(DEFAULT_TEST_ENTRY_PATTERNS.iter().copied()),
}
result.replace_entry_patterns = true;
result.accumulate_config_entry_patterns = true;
result
}
}
#[derive(Clone, Copy)]
enum ScopeValue<T> {
Absent,
Static(T),
Dynamic,
}
impl<T: Clone> ScopeValue<T> {
fn or_inherit(&self, top: &Self) -> Self {
match self {
Self::Absent => top.clone(),
other => other.clone(),
}
}
}
#[derive(Clone)]
struct TestScope {
test_dir: ScopeValue<String>,
test_match: ScopeValue<Vec<String>>,
}
fn test_entry_patterns(
source: &str,
config_path: &Path,
root: &Path,
config_dir: &Path,
) -> Option<Vec<PathRule>> {
let scopes = config_parser::extract_from_source(source, config_path, |program| {
if let Some(arguments) = config_parser::find_config_merge_arguments(program) {
return Some(config_test_scopes(
scope_from(
merged_property(&arguments, "testDir"),
merged_property(&arguments, "testMatch"),
),
merged_projects(&arguments),
));
}
let config = config_parser::find_config_object(program)?;
Some(config_test_scopes(
scope_of(config),
scope_property(config, "projects"),
))
})?;
let mut patterns = Vec::new();
for scope in scopes {
let test_dir = match (scope.test_dir, &scope.test_match) {
(ScopeValue::Static(test_dir), _) => test_dir,
(ScopeValue::Absent, ScopeValue::Static(_)) => {
patterns.extend(conventional_test_dir_patterns());
".".to_string()
}
_ => {
patterns.extend(conventional_test_dir_patterns());
continue;
}
};
let Some(prefix) = test_dir_prefix(&test_dir, root, config_dir) else {
patterns.extend(conventional_test_dir_patterns());
continue;
};
match scope.test_match {
ScopeValue::Absent => {
let glob =
case_insensitive_glob(&format!("*.{{spec,test}}.{TEST_FILE_EXTENSIONS}"));
patterns.push(PathRule::new(format!("{prefix}**/{glob}")));
}
ScopeValue::Static(globs) => {
for glob in globs {
let glob = glob.strip_prefix("**/").unwrap_or(&glob);
let rewritten = fallow_config::extglob::rewrite_include(glob);
let below_test_dir =
|glob: &str| format!("{prefix}**/{}", case_insensitive_glob(glob));
patterns.push(
PathRule::new(below_test_dir(&rewritten.include)).with_excluded_globs(
rewritten
.excludes
.iter()
.map(|exclude| below_test_dir(exclude)),
),
);
}
}
ScopeValue::Dynamic => {
patterns.push(PathRule::new(format!(
"{prefix}**/*.{TEST_FILE_EXTENSIONS}"
)));
}
}
}
Some(patterns)
}
fn conventional_test_dir_patterns() -> impl Iterator<Item = PathRule> {
DEFAULT_TEST_ENTRY_PATTERNS
.iter()
.skip(TEST_FILE_NAME_PATTERNS.len())
.map(|pattern| PathRule::new(*pattern))
}
fn merged_property<'a>(
arguments: &[Option<&'a ObjectExpression<'a>>],
key: &str,
) -> ScopeValue<&'a Expression<'a>> {
for argument in arguments.iter().rev() {
let Some(object) = argument else {
return ScopeValue::Dynamic;
};
match scope_property(object, key) {
ScopeValue::Absent => {}
value => return value,
}
}
ScopeValue::Absent
}
fn merged_projects<'a>(
arguments: &[Option<&'a ObjectExpression<'a>>],
) -> ScopeValue<&'a Expression<'a>> {
let mut projects = ScopeValue::Absent;
for argument in arguments {
let Some(object) = argument else {
return ScopeValue::Dynamic;
};
match (scope_property(object, "projects"), &projects) {
(ScopeValue::Absent, _) => {}
(value, ScopeValue::Absent) => projects = value,
_ => return ScopeValue::Dynamic,
}
}
projects
}
fn config_test_scopes<'a>(
top: TestScope,
projects: ScopeValue<&'a Expression<'a>>,
) -> Vec<TestScope> {
let inherited = TestScope {
test_dir: top.test_dir.clone(),
test_match: ScopeValue::Dynamic,
};
let unknown = TestScope {
test_dir: ScopeValue::Dynamic,
test_match: ScopeValue::Dynamic,
};
let projects = match projects {
ScopeValue::Absent => return vec![top],
ScopeValue::Static(expr) => expr,
ScopeValue::Dynamic => return vec![inherited, unknown],
};
let Some(projects) = config_parser::array_expression(projects) else {
return vec![inherited, unknown];
};
if projects.elements.is_empty() {
return vec![top];
}
let mut scopes = Vec::new();
for element in &projects.elements {
let Some(project) = element
.as_expression()
.and_then(config_parser::object_expression)
else {
scopes.extend([inherited.clone(), unknown.clone()]);
continue;
};
let own = scope_of(project);
if matches!(own.test_dir, ScopeValue::Dynamic) {
scopes.push(TestScope {
test_dir: top.test_dir.clone(),
test_match: own.test_match.or_inherit(&top.test_match),
});
}
scopes.push(TestScope {
test_dir: own.test_dir.or_inherit(&top.test_dir),
test_match: own.test_match.or_inherit(&top.test_match),
});
}
scopes
}
fn scope_of(obj: &ObjectExpression<'_>) -> TestScope {
scope_from(
scope_property(obj, "testDir"),
scope_property(obj, "testMatch"),
)
}
fn scope_from(
test_dir: ScopeValue<&Expression<'_>>,
test_match: ScopeValue<&Expression<'_>>,
) -> TestScope {
let test_dir = match test_dir {
ScopeValue::Absent => ScopeValue::Absent,
ScopeValue::Dynamic => ScopeValue::Dynamic,
ScopeValue::Static(expr) => config_parser::expression_to_path_string(expr)
.map_or(ScopeValue::Dynamic, ScopeValue::Static),
};
let test_match = match test_match {
ScopeValue::Absent => ScopeValue::Absent,
ScopeValue::Dynamic => ScopeValue::Dynamic,
ScopeValue::Static(expr) => {
static_globs(expr).map_or(ScopeValue::Dynamic, ScopeValue::Static)
}
};
TestScope {
test_dir,
test_match,
}
}
fn scope_property<'a>(obj: &'a ObjectExpression<'a>, key: &str) -> ScopeValue<&'a Expression<'a>> {
for property in obj.properties.iter().rev() {
match property {
ObjectPropertyKind::SpreadProperty(_) => return ScopeValue::Dynamic,
ObjectPropertyKind::ObjectProperty(property) => {
if property.computed {
return ScopeValue::Dynamic;
}
if property.key.static_name().is_some_and(|name| name == key) {
return ScopeValue::Static(&property.value);
}
}
}
}
ScopeValue::Absent
}
fn static_globs(expr: &Expression<'_>) -> Option<Vec<String>> {
let globs = match config_parser::array_expression(expr) {
Some(array) => array
.elements
.iter()
.map(|element| config_parser::expression_to_path_string(element.as_expression()?))
.collect::<Option<Vec<_>>>()?,
None => vec![config_parser::expression_to_path_string(expr)?],
};
let in_test_dir = |glob: &String| {
!glob.is_empty() && !glob.starts_with('/') && !glob.split('/').any(|part| part == "..")
};
let file_name_only = |glob: &String| !glob.strip_prefix("**/").unwrap_or(glob).contains('/');
let usable = |glob: &String| {
in_test_dir(glob) && file_name_only(glob) && glob.is_ascii() && !glob.contains(['[', '\\'])
};
(!globs.is_empty() && globs.iter().all(usable)).then_some(globs)
}
fn case_insensitive_glob(glob: &str) -> String {
let mut pattern = String::new();
for ch in glob.chars() {
if ch.is_ascii_alphabetic() {
pattern.push('[');
pattern.push(ch.to_ascii_lowercase());
pattern.push(ch.to_ascii_uppercase());
pattern.push(']');
} else {
pattern.push(ch);
}
}
pattern
}
fn test_dir_prefix(test_dir: &str, root: &Path, config_dir: &Path) -> Option<String> {
let dir = config_parser::lexical_normalize(
&config_dir.join(config_parser::path_from_config_string(test_dir)),
);
let relative = dir
.strip_prefix(config_parser::lexical_normalize(root))
.ok()?;
let relative = globset::escape(&config_parser::path_to_config_string(relative));
Some(if relative.is_empty() {
String::new()
} else {
format!("{relative}/")
})
}
fn collect_web_server(
source: &str,
config_path: &Path,
root: &Path,
config_dir: &Path,
) -> (Vec<String>, Vec<PathBuf>) {
let mut commands: Vec<(String, Option<String>)> = Vec::new();
if let Some(command) =
config_parser::extract_config_command(source, config_path, &["webServer", "command"])
{
let cwd = config_parser::extract_config_string(source, config_path, &["webServer", "cwd"]);
commands.push((command, cwd));
}
commands.extend(config_parser::extract_config_array_object_command_pairs(
source,
config_path,
&["webServer"],
"command",
"cwd",
));
let mut referenced_dependencies = Vec::new();
let mut setup_files = Vec::new();
for (command, cwd) in commands {
let analysis = scripts::analyze_command(&command, root, &FxHashMap::default());
referenced_dependencies.extend(analysis.used_packages);
let base = cwd.map_or_else(
|| config_dir.to_path_buf(),
|dir| config_dir.join(dir.trim_start_matches("./")),
);
for file in analysis
.config_files
.into_iter()
.chain(analysis.entry_files)
{
setup_files.push(base.join(file.trim_start_matches("./")));
}
}
(referenced_dependencies, setup_files)
}
#[cfg(test)]
mod tests {
use super::*;
fn deps(names: &[&str]) -> Vec<String> {
names.iter().map(|name| (*name).to_string()).collect()
}
#[test]
fn enabled_by_test_package_without_config() {
let temp = tempfile::tempdir().expect("tempdir");
assert!(PlaywrightPlugin.is_enabled_with_deps(&deps(&["@playwright/test"]), temp.path()));
}
#[test]
fn enabled_by_bare_package_with_config() {
let temp = tempfile::tempdir().expect("tempdir");
std::fs::write(temp.path().join("playwright.config.ts"), "").expect("write config");
assert!(PlaywrightPlugin.is_enabled_with_deps(&deps(&["playwright"]), temp.path()));
}
#[test]
fn bare_package_without_config_does_not_enable() {
let temp = tempfile::tempdir().expect("tempdir");
assert!(!PlaywrightPlugin.is_enabled_with_deps(&deps(&["playwright"]), temp.path()));
}
#[test]
fn resolve_config_global_setup() {
let source = r#"
export default {
globalSetup: "./global-setup.ts"
};
"#;
let plugin = PlaywrightPlugin;
let result = plugin.resolve_config(
Path::new("playwright.config.ts"),
source,
Path::new("/project"),
);
assert_eq!(
result.setup_files,
vec![Path::new("/project/global-setup.ts")]
);
}
#[test]
fn resolve_config_global_teardown() {
let source = r#"
export default {
globalTeardown: "./global-teardown.ts"
};
"#;
let plugin = PlaywrightPlugin;
let result = plugin.resolve_config(
Path::new("playwright.config.ts"),
source,
Path::new("/project"),
);
assert_eq!(
result.setup_files,
vec![Path::new("/project/global-teardown.ts")]
);
}
#[test]
fn resolve_config_both_setup_and_teardown() {
let source = r#"
export default {
globalSetup: "./setup.ts",
globalTeardown: "./teardown.ts"
};
"#;
let plugin = PlaywrightPlugin;
let result = plugin.resolve_config(
Path::new("playwright.config.ts"),
source,
Path::new("/project"),
);
assert_eq!(
result.setup_files,
vec![
Path::new("/project/setup.ts"),
Path::new("/project/teardown.ts"),
]
);
}
#[test]
fn resolve_config_imports() {
let source = r#"
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: "./setup.ts"
});
"#;
let plugin = PlaywrightPlugin;
let result = plugin.resolve_config(
Path::new("playwright.config.ts"),
source,
Path::new("/project"),
);
assert!(
result
.referenced_dependencies
.contains(&"@playwright/test".to_string())
);
assert_eq!(result.setup_files, vec![Path::new("/project/setup.ts")]);
}
#[test]
fn resolve_config_empty() {
let source = r"export default {};";
let plugin = PlaywrightPlugin;
let result = plugin.resolve_config(
Path::new("playwright.config.ts"),
source,
Path::new("/project"),
);
assert!(result.setup_files.is_empty());
assert!(result.referenced_dependencies.is_empty());
}
#[test]
fn resolve_config_setup_strips_dot_slash() {
let source = r#"
export default {
globalSetup: "./tests/global-setup.ts"
};
"#;
let plugin = PlaywrightPlugin;
let result = plugin.resolve_config(
Path::new("playwright.config.ts"),
source,
Path::new("/project"),
);
assert_eq!(
result.setup_files,
vec![Path::new("/project/tests/global-setup.ts")]
);
}
#[test]
fn resolve_config_setup_without_dot_slash() {
let source = r#"
export default {
globalSetup: "tests/global-setup.ts"
};
"#;
let plugin = PlaywrightPlugin;
let result = plugin.resolve_config(
Path::new("playwright.config.ts"),
source,
Path::new("/project"),
);
assert_eq!(
result.setup_files,
vec![Path::new("/project/tests/global-setup.ts")]
);
}
#[test]
fn fixture_patterns_are_set() {
let plugin = PlaywrightPlugin;
assert!(!plugin.fixture_glob_patterns().is_empty());
}
fn resolve(source: &str) -> PluginResult {
PlaywrightPlugin.resolve_config(
Path::new("playwright.config.ts"),
source,
Path::new("/project"),
)
}
#[test]
fn web_server_object_command_credits_cli_dependency() {
let source = r#"
export default {
webServer: { command: "srvx --port 3000", url: "http://localhost:3000" }
};
"#;
let result = resolve(source);
assert!(
result.referenced_dependencies.contains(&"srvx".to_string()),
"srvx CLI binary should be credited, got {:?}",
result.referenced_dependencies
);
assert!(
result.setup_files.is_empty(),
"a flag-only command seeds no files, got {:?}",
result.setup_files
);
}
#[test]
fn web_server_template_command_credits_pnpm_exec_cli_dependency() {
let source = r"
const PORT = 3000;
export default {
webServer: {
command: `pnpm build && pnpm exec srvx --prod --port ${PORT} --hostname 127.0.0.1`
}
};
";
let result = resolve(source);
assert!(
result.referenced_dependencies.contains(&"srvx".to_string()),
"srvx CLI binary should be credited from pnpm exec template command, got {:?}",
result.referenced_dependencies
);
}
#[test]
fn web_server_array_template_command_credits_pnpm_exec_cli_dependency() {
let source = r"
const PORT = 3000;
export default {
webServer: [
{
command: `pnpm exec srvx --prod --port ${PORT}`,
},
],
};
";
let result = resolve(source);
assert!(
result.referenced_dependencies.contains(&"srvx".to_string()),
"srvx CLI binary should be credited from array template command, got {:?}",
result.referenced_dependencies
);
}
#[test]
fn web_server_array_node_runner_seeds_file_and_credits_runner() {
let source = r#"
export default {
webServer: [{ command: "tsx scripts/e2e-server.ts" }]
};
"#;
let result = resolve(source);
assert!(
result.referenced_dependencies.contains(&"tsx".to_string()),
"tsx node runner should be credited, got {:?}",
result.referenced_dependencies
);
assert_eq!(
result.setup_files,
vec![Path::new("/project/scripts/e2e-server.ts")]
);
}
#[test]
fn web_server_object_command_honors_cwd() {
let source = r#"
export default {
webServer: { command: "node server.js", cwd: "packages/api" }
};
"#;
let result = resolve(source);
assert!(
!result.referenced_dependencies.contains(&"node".to_string()),
"node must not be credited as a dependency, got {:?}",
result.referenced_dependencies
);
assert_eq!(
result.setup_files,
vec![Path::new("/project/packages/api/server.js")],
"server.js must resolve under webServer.cwd"
);
}
#[test]
fn web_server_array_per_element_cwd() {
let source = r#"
export default {
webServer: [
{ command: "tsx scripts/api.ts", cwd: "packages/api" },
{ command: "tsx scripts/web.ts" }
]
};
"#;
let result = resolve(source);
assert!(
result
.setup_files
.contains(&PathBuf::from("/project/packages/api/scripts/api.ts"))
);
assert!(
result
.setup_files
.contains(&PathBuf::from("/project/scripts/web.ts"))
);
}
#[test]
fn web_server_package_manager_delegation_is_noop() {
let source = r#"
export default {
webServer: { command: "npm run start" }
};
"#;
let result = resolve(source);
assert!(
result.referenced_dependencies.is_empty(),
"npm run delegation must not credit a phantom dependency, got {:?}",
result.referenced_dependencies
);
assert!(result.setup_files.is_empty());
}
#[test]
fn web_server_and_global_setup_coexist() {
let source = r#"
export default {
globalSetup: "./setup.ts",
webServer: { command: "tsx scripts/e2e-server.ts" }
};
"#;
let result = resolve(source);
assert!(
result
.setup_files
.contains(&PathBuf::from("/project/setup.ts"))
);
assert!(
result
.setup_files
.contains(&PathBuf::from("/project/scripts/e2e-server.ts"))
);
}
#[test]
fn web_server_strips_leading_dot_slash_in_file_args() {
let source = r#"
export default {
webServer: { command: "tsx ./scripts/e2e-server.ts" }
};
"#;
let result = resolve(source);
assert_eq!(
result.setup_files,
vec![Path::new("/project/scripts/e2e-server.ts")]
);
}
#[test]
fn no_web_server_seeds_nothing() {
let source = r#"
export default {
globalSetup: "./setup.ts"
};
"#;
let result = resolve(source);
assert_eq!(result.setup_files, vec![Path::new("/project/setup.ts")]);
}
fn abs(logical: &str) -> PathBuf {
#[cfg(windows)]
{
PathBuf::from(format!("C:{}", logical.replace('/', "\\")))
}
#[cfg(not(windows))]
{
PathBuf::from(logical)
}
}
fn resolve_at(config_path: &str, source: &str) -> PluginResult {
PlaywrightPlugin.resolve_config(&abs(config_path), source, &abs("/project"))
}
#[test]
fn web_server_file_args_resolve_from_nested_config_dir_not_root() {
let source = r#"
export default {
webServer: { command: "tsx scripts/e2e-server.ts" }
};
"#;
let result = resolve_at("/project/apps/web/playwright.config.ts", source);
assert_eq!(
result.setup_files,
vec![abs("/project/apps/web/scripts/e2e-server.ts")],
"nested-config file args must resolve under the config directory, not the project root"
);
}
#[test]
fn web_server_nested_config_cwd_resolves_relative_to_config_dir() {
let source = r#"
export default {
webServer: { command: "tsx scripts/server.ts", cwd: "api" }
};
"#;
let result = resolve_at("/project/apps/web/playwright.config.ts", source);
assert_eq!(
result.setup_files,
vec![abs("/project/apps/web/api/scripts/server.ts")],
"cwd must resolve relative to the config directory"
);
}
fn entry_patterns(result: &PluginResult) -> Vec<&str> {
result
.entry_patterns
.iter()
.map(|rule| rule.pattern.as_str())
.collect()
}
fn default_patterns() -> Vec<&'static str> {
DEFAULT_TEST_ENTRY_PATTERNS.to_vec()
}
fn test_dir_patterns(result: &PluginResult) -> Vec<&str> {
let patterns = entry_patterns(result);
let (file_names, test_dir) = patterns.split_at(TEST_FILE_NAME_PATTERNS.len());
assert_eq!(file_names, TEST_FILE_NAME_PATTERNS);
test_dir.to_vec()
}
#[test]
fn test_dir_replaces_default_test_directories() {
let result = resolve(r"export default defineConfig({ testDir: './ui' });");
assert!(result.replace_entry_patterns);
let patterns = entry_patterns(&result);
assert!(
!patterns.contains(&"tests/**/*.{ts,tsx,js,jsx}")
&& !patterns.contains(&"e2e/**/*.{ts,tsx,js,jsx}"),
"a static testDir drops the default test directories, got {patterns:?}"
);
assert_eq!(
test_dir_patterns(&result),
vec![
"ui/**/*.{[sS][pP][eE][cC],[tT][eE][sS][tT]}.{[tT][sS],[tT][sS][xX],[jJ][sS],[jJ][sS][xX],[mM][tT][sS],[cC][tT][sS],[mM][jJ][sS],[cC][jJ][sS]}"
]
);
}
#[test]
fn test_dir_resolves_from_nested_config_dir() {
let result = resolve_at(
"/project/e2e/playwright.config.ts",
r"export default defineConfig({ testDir: './ui' });",
);
assert_eq!(
test_dir_patterns(&result),
vec![
"e2e/ui/**/*.{[sS][pP][eE][cC],[tT][eE][sS][tT]}.{[tT][sS],[tT][sS][xX],[jJ][sS],[jJ][sS][xX],[mM][tT][sS],[cC][tT][sS],[mM][jJ][sS],[cC][jJ][sS]}"
]
);
}
#[test]
fn test_dir_dot_selects_the_config_dir() {
let result = resolve_at(
"/project/e2e/playwright.config.ts",
r"export default { testDir: '.' };",
);
assert_eq!(
test_dir_patterns(&result),
vec![
"e2e/**/*.{[sS][pP][eE][cC],[tT][eE][sS][tT]}.{[tT][sS],[tT][sS][xX],[jJ][sS],[jJ][sS][xX],[mM][tT][sS],[cC][tT][sS],[mM][jJ][sS],[cC][jJ][sS]}"
]
);
}
#[test]
fn test_dir_with_dirname_join() {
let result = resolve(
r"
import path from 'node:path';
export default { testDir: path.join(__dirname, 'specs') };
",
);
assert_eq!(
test_dir_patterns(&result),
vec![
"specs/**/*.{[sS][pP][eE][cC],[tT][eE][sS][tT]}.{[tT][sS],[tT][sS][xX],[jJ][sS],[jJ][sS][xX],[mM][tT][sS],[cC][tT][sS],[mM][jJ][sS],[cC][jJ][sS]}"
]
);
}
#[test]
fn project_test_dirs_each_give_entries() {
let result = resolve(
r"export default defineConfig({
projects: [{ testDir: './a' }, { testDir: './b' }],
});",
);
assert_eq!(
test_dir_patterns(&result),
vec![
"a/**/*.{[sS][pP][eE][cC],[tT][eE][sS][tT]}.{[tT][sS],[tT][sS][xX],[jJ][sS],[jJ][sS][xX],[mM][tT][sS],[cC][tT][sS],[mM][jJ][sS],[cC][jJ][sS]}",
"b/**/*.{[sS][pP][eE][cC],[tT][eE][sS][tT]}.{[tT][sS],[tT][sS][xX],[jJ][sS],[jJ][sS][xX],[mM][tT][sS],[cC][tT][sS],[mM][jJ][sS],[cC][jJ][sS]}",
]
);
}
#[test]
fn project_inherits_top_level_test_dir_and_test_match() {
let result = resolve(
r"export default defineConfig({
testDir: './tests',
testMatch: '*.e2e.ts',
projects: [{ name: 'chromium' }, { testDir: './smoke' }],
});",
);
assert_eq!(
test_dir_patterns(&result),
vec![
"tests/**/*.[eE]2[eE].[tT][sS]",
"smoke/**/*.[eE]2[eE].[tT][sS]"
]
);
}
#[test]
fn glob_test_match_applies_below_test_dir() {
let result = resolve(
r"export default {
testDir: './ui',
testMatch: ['**/*.flow.ts', '*.check.ts'],
};",
);
assert_eq!(
test_dir_patterns(&result),
vec![
"ui/**/*.[fF][lL][oO][wW].[tT][sS]",
"ui/**/*.[cC][hH][eE][cC][kK].[tT][sS]"
]
);
}
#[test]
fn test_match_with_test_dir_name_keeps_files_in_test_dir() {
let result = resolve(
r"export default {
testDir: './tests',
testMatch: 'tests/**/*.e2e.ts',
};",
);
let patterns = test_dir_patterns(&result);
assert_eq!(patterns, vec!["tests/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"]);
let matcher = globset::Glob::new(patterns[0]).unwrap().compile_matcher();
assert!(matcher.is_match("tests/login.e2e.ts"));
}
#[test]
fn test_match_with_directory_part_keeps_every_script_below_test_dir() {
let result = resolve(
r"export default {
testDir: './e2e',
testMatch: ['**/*.flow.ts', 'e2e/*.pw.ts'],
};",
);
assert_eq!(
test_dir_patterns(&result),
vec!["e2e/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"]
);
}
#[test]
fn test_match_accepts_every_extglob_form() {
let result = resolve(
r"export default {
testDir: './e2e',
testMatch: [
'**/*.@(e2e|smoke).ts',
'**/!(*.skip).flow.ts',
'**/*.check.?(m)js',
'**/*.+(a|b).run.ts',
'**/x*(y).run.ts',
],
};",
);
let matches = |path: &str| super::super::entry_rules_match(&result, path);
assert!(matches("e2e/a.e2e.ts"));
assert!(matches("e2e/deep/a.SMOKE.ts"));
assert!(!matches("e2e/a.unit.ts"));
assert!(matches("e2e/a.flow.ts"));
assert!(!matches("e2e/a.skip.flow.ts"));
assert!(!matches("e2e/a.SKIP.flow.ts"));
assert!(matches("e2e/a.check.js"));
assert!(matches("e2e/a.check.mjs"));
assert!(matches("e2e/c.a.run.ts"));
assert!(matches("e2e/c.ab.run.ts"));
assert!(!matches("e2e/c.run.ts"));
assert!(matches("e2e/x.run.ts"));
assert!(matches("e2e/xyy.run.ts"));
assert!(!matches("other/a.e2e.ts"));
}
#[test]
fn class_in_one_test_match_glob_keeps_every_script_below_test_dir() {
let result = resolve(
r"export default {
testDir: './e2e',
testMatch: ['**/*.flow.ts', '**/*.@(spec|test).?(c|m)[jt]s?(x)'],
};",
);
assert_eq!(
test_dir_patterns(&result),
vec!["e2e/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}"]
);
}
#[test]
fn regex_test_match_keeps_every_script_below_test_dir() {
let result = resolve(
r"export default {
testDir: './ui',
projects: [
{ name: 'setup', testMatch: /global\.setup\.ts/ },
{ name: 'chromium' },
],
};",
);
assert_eq!(
test_dir_patterns(&result),
vec![
"ui/**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs}",
"ui/**/*.{[sS][pP][eE][cC],[tT][eE][sS][tT]}.{[tT][sS],[tT][sS][xX],[jJ][sS],[jJ][sS][xX],[mM][tT][sS],[cC][tT][sS],[mM][jJ][sS],[cC][jJ][sS]}",
]
);
}
#[test]
fn missing_test_dir_restates_default_entries() {
let result = resolve(r"export default defineConfig({ timeout: 1000 });");
assert!(result.replace_entry_patterns);
assert_eq!(entry_patterns(&result), default_patterns());
}
#[test]
fn missing_test_dir_applies_test_match_below_config_dir() {
let result = resolve_at(
"/project/web/playwright.config.ts",
r"export default defineConfig({ testMatch: '*.e2e.ts' });",
);
assert_eq!(
test_dir_patterns(&result),
[
DEFAULT_TEST_ENTRY_PATTERNS[TEST_FILE_NAME_PATTERNS.len()..].to_vec(),
vec!["web/**/*.[eE]2[eE].[tT][sS]"],
]
.concat()
);
}
#[test]
fn define_config_arguments_merge_with_last_wins() {
for (source, selected, rejected) in [
(
"export default defineConfig({ testDir: './a' }, { testDir: './ui', testMatch: '*.pw.ts' });",
"ui/live.pw.ts",
"a/live.pw.ts",
),
(
"export default defineConfig({ testDir: './ui', testMatch: '*.pw.ts' }, { timeout: 1000 });",
"ui/live.pw.ts",
"outside/live.pw.ts",
),
(
"const base = { testMatch: '*.pw.ts' }; export default defineConfig(base, { testDir: './ui' });",
"ui/live.pw.ts",
"ui/live.other.ts",
),
(
"import base from './base'; export default defineConfig(base, { testDir: './ui' });",
"ui/live.other.ts",
"outside/live.other.ts",
),
(
"import base from './base'; export default defineConfig({ testDir: './wrong' }, base);",
"tests/live.pw.ts",
"outside/live.other.ts",
),
(
"const config = defineConfig(shared, { testDir: './ui' }); export default config;",
"ui/live.other.ts",
"outside/live.other.ts",
),
(
"export default defineConfig({ testDir: './ui', projects: [{ testDir: './a' }] }, { projects: [{ testDir: './b' }] });",
"ui/live.other.ts",
"outside/live.other.ts",
),
] {
let result = resolve(source);
let matchers: Vec<_> = result
.entry_patterns
.iter()
.map(|rule| {
globset::GlobBuilder::new(&rule.pattern)
.literal_separator(true)
.build()
.unwrap()
.compile_matcher()
})
.collect();
assert!(
matchers.iter().any(|matcher| matcher.is_match(selected)),
"the config selects {selected}: {source}"
);
assert!(
!matchers.iter().any(|matcher| matcher.is_match(rejected)),
"the config does not select {rejected}: {source}"
);
}
}
#[test]
fn project_without_test_dir_restates_default_entries() {
let result = resolve(
r"export default defineConfig({
projects: [{ testDir: './a' }, { name: 'chromium' }],
});",
);
assert_eq!(
test_dir_patterns(&result),
[
vec!["a/**/*.{[sS][pP][eE][cC],[tT][eE][sS][tT]}.{[tT][sS],[tT][sS][xX],[jJ][sS],[jJ][sS][xX],[mM][tT][sS],[cC][tT][sS],[mM][jJ][sS],[cC][jJ][sS]}"],
DEFAULT_TEST_ENTRY_PATTERNS[TEST_FILE_NAME_PATTERNS.len()..].to_vec(),
]
.concat()
);
}
#[test]
fn dynamic_test_dir_restates_default_entries() {
let result = resolve(r"export default { testDir: process.env.TEST_DIR };");
assert_eq!(entry_patterns(&result), default_patterns());
}
#[test]
fn test_dir_outside_root_restates_default_entries() {
let result = resolve(r"export default { testDir: '../outside' };");
assert_eq!(entry_patterns(&result), default_patterns());
}
#[test]
fn config_overrides_keep_the_selected_files() {
for (source, selected, rejected) in [
(
"export default { testDir: './wrong', testDir: './ui', testMatch: '*.pw.ts' };",
"ui/live.pw.ts",
"wrong/live.pw.ts",
),
(
"export default { testDir: './ui', testMatch: '*.wrong.ts', testMatch: '*.pw.ts' };",
"ui/live.pw.ts",
"ui/live.wrong.ts",
),
(
"export default { projects: [{testDir:'./wrong'}], projects: [{testDir:'./ui', testMatch:'*.pw.ts'}] };",
"ui/live.pw.ts",
"wrong/live.pw.ts",
),
(
"export default { projects: [{...shared, testDir:'./ui'}] };",
"ui/live.pw.ts",
"outside/live.pw.ts",
),
(
"export default {...shared, testDir:'./ui', testMatch:'*.pw.ts'};",
"ui/live.pw.ts",
"outside/live.pw.ts",
),
(
"export default {testDir:'./ui', testMatch:'*.pw.ts', projects:[...sharedProjects]};",
"ui/live.pw.ts",
"outside/live.pw.ts",
),
(
"export default {testDir:'./ui',testMatch:'*.wrong.ts',projects:sharedProjects};",
"ui/live.pw.ts",
"outside/live.pw.ts",
),
(
"export default {projects:[{testDir:'./ui',testMatch:'*.pw.ts'},...sharedProjects]};",
"ui/live.pw.ts",
"outside/live.pw.ts",
),
(
"export default {testDir:'./ui',testMatch:'*.pw.ts',projects:[{name:'chromium',...devices['Desktop Chrome']}]};",
"ui/live.pw.ts",
"outside/live.pw.ts",
),
(
"export default {testDir:'./ui',testMatch:'*.wrong.ts',projects:[{...shared}]};",
"ui/live.pw.ts",
"outside/live.pw.ts",
),
(
"export default { testDir:'./ui', [key]:'./tests', testMatch:'*.pw.ts', projects:[{name:'chromium'}] };",
"tests/live.pw.ts",
"outside/live.pw.ts",
),
] {
let result = resolve(source);
let matchers: Vec<_> = result
.entry_patterns
.iter()
.map(|rule| {
globset::GlobBuilder::new(&rule.pattern)
.literal_separator(true)
.build()
.unwrap()
.compile_matcher()
})
.collect();
assert!(
matchers.iter().any(|matcher| matcher.is_match(selected)),
"the config selects {selected}: {source}"
);
assert!(
!matchers.iter().any(|matcher| matcher.is_match(rejected)),
"the config does not select {rejected}: {source}"
);
}
}
#[test]
fn global_setup_resolves_from_nested_config_dir() {
let source = r#"
export default {
globalSetup: "./setup.ts"
};
"#;
let result = resolve_at("/project/apps/web/playwright.config.ts", source);
assert_eq!(
result.setup_files,
vec![abs("/project/apps/web/setup.ts")],
"globalSetup must resolve under the config directory"
);
}
}