Skip to main content

gdtest_analysis/
lib.rs

1//! Discovery helpers for gdtest.
2//!
3//! Two layers:
4//! 1. [`resolve`] turns user-supplied paths (files, directories, `res://…`)
5//!    into a flat list of `res://…` paths, filtered by discovery include/exclude globs.
6//! 2. [`list_tests_in`] parses one GDScript file with tree-sitter-gdscript
7//!    and returns every `func test_*()` name in source order.
8//!
9//! Default patterns:
10//!   - `test/**/*.gd`  — any `.gd` file inside a `test/` directory tree
11//!   - `**/*.test.gd`  — co-located test files anywhere in the project
12//!
13//! Users can override via `gdtest.toml`:  `include = ["test/**/*.gd", "**/*.test.gd"]`
14//!
15//! Target: discovering 500 tests across 50 files in <100 ms on a warm cache
16//!.
17
18/// Default discovery globs used when no `include` key is in `gdtest.toml`.
19pub const DEFAULT_PATTERNS: &[&str] = &["test/**/*.gd", "**/*.test.gd"];
20
21pub mod coverage;
22pub mod deps;
23pub mod gdunit4_migrate;
24pub mod gut_migrate;
25pub mod mutate;
26pub mod rewrite;
27pub use gdunit4_migrate::{
28    GdUnit4MigrationAction, GdUnit4MigrationIssue, GdUnit4MigrationOutput, GdUnit4MigrationStatus,
29    is_gdunit4_test_file, is_gdunit4_test_source, migrate_gdunit4_file, migrate_gdunit4_source,
30};
31pub use gut_migrate::{
32    GutMigrationAction, GutMigrationIssue, GutMigrationOutput, GutMigrationStatus,
33    is_gut_test_file, is_gut_test_source, migrate_gut_file, migrate_gut_source,
34};
35pub use rewrite::{RewriteOutput, rewrite_file, rewrite_source};
36
37use std::path::{Path, PathBuf};
38
39use anyhow::{Context, Result, anyhow};
40use tree_sitter::Parser;
41use walkdir::WalkDir;
42
43/// Normalise every input path into a list of `res://…` test-file paths.
44///
45/// `patterns` is a slice of glob strings (relative to project root) used when
46/// walking directories. `exclude_patterns` is a slice of discovery-exclude
47/// globs, also relative to project root. Pass [`DEFAULT_PATTERNS`] and `[]`
48/// when no custom config is set.
49pub fn resolve(
50    inputs: &[String],
51    project_path: &Path,
52    patterns: &[String],
53    exclude_patterns: &[String],
54) -> Result<Vec<String>> {
55    let project_abs = project_path
56        .canonicalize()
57        .with_context(|| format!("project path does not exist: {}", project_path.display()))?;
58    let mut out: Vec<String> = Vec::new();
59    for input in inputs {
60        if let Some(res) = input.strip_prefix("res://") {
61            out.push(format!("res://{res}"));
62            continue;
63        }
64        let path = PathBuf::from(input);
65        let abs = if path.is_absolute() {
66            path.clone()
67        } else {
68            project_abs.join(&path)
69        };
70        // Canonicalize so the \\?\ UNC prefix (Windows) matches project_abs.
71        let abs = abs.canonicalize().unwrap_or(abs);
72        if abs.is_file() {
73            if abs.extension().and_then(|e| e.to_str()) != Some("gd") {
74                return Err(anyhow!("not a .gd file: {}", abs.display()));
75            }
76            out.push(to_res_path(&abs, &project_abs)?);
77        } else if abs.is_dir() {
78            for entry in WalkDir::new(&abs).follow_links(false) {
79                let entry = entry.with_context(|| format!("walking {}", abs.display()))?;
80                if !entry.file_type().is_file() {
81                    continue;
82                }
83                if entry.path().extension().and_then(|e| e.to_str()) != Some("gd") {
84                    continue;
85                }
86                let ep = entry
87                    .path()
88                    .canonicalize()
89                    .unwrap_or_else(|_| entry.path().to_path_buf());
90                // Match against project-root-relative path with forward slashes.
91                let rel = ep.strip_prefix(&project_abs).unwrap_or(&ep);
92                let rel_str = rel.to_string_lossy().replace('\\', "/");
93                if !patterns.iter().any(|p| glob_match(p, &rel_str)) {
94                    continue;
95                }
96                if exclude_patterns.iter().any(|p| glob_match(p, &rel_str)) {
97                    continue;
98                }
99                out.push(to_res_path(&ep, &project_abs)?);
100            }
101        } else {
102            return Err(anyhow!("path not found: {}", abs.display()));
103        }
104    }
105    out.sort();
106    out.dedup();
107    Ok(out)
108}
109
110/// Match a relative path (forward-slash separated) against a simple glob pattern.
111/// Supports `**` (zero or more path segments) and `*` (any chars within one segment).
112pub fn glob_match(pattern: &str, path: &str) -> bool {
113    let pat: Vec<&str> = pattern.split('/').collect();
114    let seg: Vec<&str> = path.split('/').collect();
115    glob_seg(&pat, &seg)
116}
117
118fn glob_seg(pat: &[&str], seg: &[&str]) -> bool {
119    if pat.is_empty() {
120        return seg.is_empty();
121    }
122    if pat[0] == "**" {
123        for i in 0..=seg.len() {
124            if glob_seg(&pat[1..], &seg[i..]) {
125                return true;
126            }
127        }
128        return false;
129    }
130    if seg.is_empty() {
131        return false;
132    }
133    if seg_matches(pat[0], seg[0]) {
134        return glob_seg(&pat[1..], &seg[1..]);
135    }
136    false
137}
138
139/// Match a single path segment against a pattern that may contain `*` wildcards.
140fn seg_matches(pattern: &str, s: &str) -> bool {
141    if !pattern.contains('*') {
142        return pattern == s;
143    }
144    let parts: Vec<&str> = pattern.split('*').collect();
145    let mut pos = 0usize;
146    for (i, part) in parts.iter().enumerate() {
147        if i == 0 {
148            if !s.starts_with(part) {
149                return false;
150            }
151            pos += part.len();
152        } else if i == parts.len() - 1 {
153            return s[pos..].ends_with(part);
154        } else {
155            match s[pos..].find(part) {
156                None => return false,
157                Some(idx) => pos += idx + part.len(),
158            }
159        }
160    }
161    true
162}
163
164/// If `path` contains a component named `describe_<Name>`, return that component name.
165/// Used to prefix test function names for directory-based sub-suites.
166pub fn extract_describe_dir(path: &Path) -> Option<String> {
167    path.components()
168        .filter_map(|c| match c {
169            std::path::Component::Normal(s) => s.to_str(),
170            _ => None,
171        })
172        .rfind(|s| s.starts_with("describe_"))
173        .map(|s| s.to_string())
174}
175
176/// Classification of a discovered test function.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub enum TestKind {
179    /// `test_*` — runs normally.
180    Normal,
181    /// `bench_*` — benchmark; only runs when `--bench` is passed.
182    Bench,
183}
184
185/// Per-test isolation override discovered from source comments.
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum IsolationDirective {
188    File,
189    Test,
190}
191
192/// A discovered test function with its run classification.
193#[derive(Debug, Clone)]
194pub struct TestEntry {
195    pub name: String,
196    pub kind: TestKind,
197    /// Tags collected from `## @tag <name>` doc comments immediately preceding the function.
198    pub tags: Vec<String>,
199    /// Isolation override from source comments.
200    pub isolation: Option<IsolationDirective>,
201}
202
203/// Return every `func test_*()` and `func bench_*()` in the given GDScript file, in source order.
204///
205/// If the file lives inside a `describe_<Name>/` directory, all returned
206/// entry names are prefixed with `describe_<Name>/` to match the reporting
207/// format of inner-class describe blocks.
208pub fn list_test_entries_in(script_path: &Path) -> Result<Vec<TestEntry>> {
209    let source =
210        std::fs::read(script_path).with_context(|| format!("reading {}", script_path.display()))?;
211    let mut entries = list_test_entries_in_source(&source)?;
212    if let Some(prefix) = extract_describe_dir(script_path) {
213        for entry in &mut entries {
214            entry.name = format!("{prefix}/{}", entry.name);
215        }
216    }
217    Ok(entries)
218}
219
220pub fn list_test_entries_in_source(source: &[u8]) -> Result<Vec<TestEntry>> {
221    let mut parser = Parser::new();
222    parser
223        .set_language(&tree_sitter_gdscript::LANGUAGE.into())
224        .context("failed to set GDScript language")?;
225    let Some(tree) = parser.parse(source, None) else {
226        return Ok(Vec::new());
227    };
228    let file_isolation = extract_file_isolation(source);
229    Ok(collect_test_entries(
230        tree.root_node(),
231        source,
232        None,
233        file_isolation,
234    ))
235}
236
237/// Parse a `## @tag <name>` doc comment. Returns the tag name or `None`.
238fn parse_tag_comment(text: &str) -> Option<String> {
239    let body = text.trim().strip_prefix("##")?.trim_start();
240    let name = body.strip_prefix("@tag")?.trim();
241    if name.is_empty() || name.contains(char::is_whitespace) {
242        return None;
243    }
244    Some(name.to_string())
245}
246
247fn parse_isolation_comment(text: &str) -> Option<IsolationDirective> {
248    let trimmed = text.trim();
249    let doc_body = trimmed.strip_prefix("##").map(str::trim_start);
250    let plain_body = trimmed.strip_prefix('#').map(str::trim_start);
251
252    if let Some(body) = doc_body
253        && let Some(value) = body.strip_prefix("@isolation").map(str::trim)
254    {
255        return match value {
256            "file" => Some(IsolationDirective::File),
257            "test" => Some(IsolationDirective::Test),
258            _ => None,
259        };
260    }
261
262    if let Some(body) = plain_body
263        && let Some(value) = body
264            .strip_prefix("gdtest:")
265            .map(str::trim_start)
266            .and_then(|rest| rest.strip_prefix("isolation="))
267            .map(str::trim)
268    {
269        return match value {
270            "file" => Some(IsolationDirective::File),
271            "test" => Some(IsolationDirective::Test),
272            _ => None,
273        };
274    }
275
276    None
277}
278
279/// Scan source lines immediately before `func_row` and collect `## @tag` names.
280/// Stops at a blank line or any line that is neither a `## @tag` nor a plain `#` comment.
281fn extract_tags_preceding(source: &[u8], func_row: usize) -> Vec<String> {
282    let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
283    let mut tags = Vec::new();
284    let mut row = func_row;
285    while row > 0 {
286        row -= 1;
287        let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
288        if line.is_empty() {
289            break;
290        }
291        if let Some(tag) = parse_tag_comment(line) {
292            tags.push(tag);
293        } else if line.starts_with('#') {
294            // plain comment — keep scanning upward
295        } else {
296            break;
297        }
298    }
299    tags.reverse();
300    tags
301}
302
303fn extract_isolation_preceding(source: &[u8], func_row: usize) -> Option<IsolationDirective> {
304    let lines: Vec<&[u8]> = source.split(|&b| b == b'\n').collect();
305    let mut row = func_row;
306    while row > 0 {
307        row -= 1;
308        let line = std::str::from_utf8(lines[row]).unwrap_or("").trim();
309        if line.is_empty() {
310            break;
311        }
312        if let Some(isolation) = parse_isolation_comment(line) {
313            return Some(isolation);
314        }
315        if line.starts_with('#') {
316            continue;
317        }
318        break;
319    }
320    None
321}
322
323fn extract_file_isolation(source: &[u8]) -> Option<IsolationDirective> {
324    for raw_line in source.split(|&b| b == b'\n') {
325        let line = std::str::from_utf8(raw_line).unwrap_or("").trim();
326        if line.is_empty() {
327            continue;
328        }
329        if let Some(isolation) = parse_isolation_comment(line) {
330            return Some(isolation);
331        }
332        if line.starts_with('#') {
333            continue;
334        }
335        break;
336    }
337    None
338}
339
340/// Classify a function name into its TestKind, or `None` if it's not a test.
341fn classify_func(name: &str) -> Option<TestKind> {
342    if name.starts_with("bench_") {
343        Some(TestKind::Bench)
344    } else if name.starts_with("test_") {
345        Some(TestKind::Normal)
346    } else {
347        None
348    }
349}
350
351/// Recursively walk the AST to collect test entries.
352/// `describe` is the name of the enclosing `describe_*` inner class, if any.
353fn collect_test_entries<'a>(
354    node: tree_sitter::Node<'a>,
355    source: &[u8],
356    describe: Option<&str>,
357    file_isolation: Option<IsolationDirective>,
358) -> Vec<TestEntry> {
359    let mut out = Vec::new();
360    for i in 0..node.child_count() {
361        let Some(child) = node.child(i as u32) else {
362            continue;
363        };
364        match child.kind() {
365            "function_definition" => {
366                if let Some(name_node) = child.child_by_field_name("name")
367                    && let Ok(fname) = name_node.utf8_text(source)
368                    && let Some(kind) = classify_func(fname)
369                {
370                    let name = match describe {
371                        Some(cls) => format!("{cls}/{fname}"),
372                        None => fname.to_string(),
373                    };
374                    let tags = extract_tags_preceding(source, child.start_position().row);
375                    let isolation = extract_isolation_preceding(source, child.start_position().row)
376                        .or(file_isolation);
377                    out.push(TestEntry {
378                        name,
379                        kind,
380                        tags,
381                        isolation,
382                    });
383                }
384                // Never descend into function bodies — no nested functions in GDScript.
385            }
386            "class_definition" if describe.is_none() => {
387                // Only look inside top-level `describe_*` inner classes.
388                if let Some(name_node) = child.child_by_field_name("name")
389                    && let Ok(cname) = name_node.utf8_text(source)
390                    && cname.starts_with("describe_")
391                {
392                    out.extend(collect_test_entries(
393                        child,
394                        source,
395                        Some(cname),
396                        file_isolation,
397                    ));
398                }
399                // Non-describe inner classes are ignored entirely.
400            }
401            _ => {
402                out.extend(collect_test_entries(
403                    child,
404                    source,
405                    describe,
406                    file_isolation,
407                ));
408            }
409        }
410    }
411    out
412}
413
414/// Convenience wrapper: returns the names of tests that would actually run
415/// (excludes `bench_*`).
416pub fn list_tests_in(script_path: &Path) -> Result<Vec<String>> {
417    let entries = list_test_entries_in(script_path)?;
418    Ok(entries
419        .into_iter()
420        .filter(|e| e.kind == TestKind::Normal)
421        .map(|e| e.name)
422        .collect())
423}
424
425/// Translate an absolute on-disk path to the res:// form used by the
426/// worker. Caller guarantees `file` is inside `project_abs`.
427pub fn to_res_path(file: &Path, project_abs: &Path) -> Result<String> {
428    let rel = file.strip_prefix(project_abs).with_context(|| {
429        format!(
430            "{} is outside project {}",
431            file.display(),
432            project_abs.display()
433        )
434    })?;
435    let s = rel.to_string_lossy().replace('\\', "/");
436    Ok(format!("res://{s}"))
437}
438
439/// Translate a res:// path to its on-disk absolute form.
440pub fn res_to_abs(res_path: &str, project_abs: &Path) -> Result<PathBuf> {
441    let rel = res_path
442        .strip_prefix("res://")
443        .ok_or_else(|| anyhow!("expected res:// prefix: {res_path}"))?;
444    Ok(project_abs.join(rel))
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use std::fs;
451    use std::path::PathBuf;
452    use std::time::{SystemTime, UNIX_EPOCH};
453
454    fn make_temp_project(prefix: &str) -> PathBuf {
455        let nonce = SystemTime::now()
456            .duration_since(UNIX_EPOCH)
457            .unwrap()
458            .as_nanos();
459        let dir = std::env::temp_dir().join(format!("gdtest-{prefix}-{nonce}"));
460        fs::create_dir_all(&dir).unwrap();
461        dir
462    }
463
464    #[test]
465    fn extracts_test_functions() {
466        let src = br#"
467extends RefCounted
468
469func _helper() -> void:
470    pass
471
472func test_alpha() -> void:
473    pass
474
475func test_beta(arg: int) -> void:
476    # func test_inside_comment() should NOT be picked up
477    pass
478
479func not_a_test() -> void:
480    pass
481
482# Async test
483func test_gamma() -> void:
484    await physics_frame
485"#;
486        let entries = list_test_entries_in_source(src).unwrap();
487        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
488        assert_eq!(names, vec!["test_alpha", "test_beta", "test_gamma"]);
489        assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
490    }
491
492    #[test]
493    fn unknown_prefixes_are_not_tests() {
494        let src = br#"
495func test_normal() -> void:
496    pass
497func xtest_skipped() -> void:
498    pass
499func ftest_focused() -> void:
500    pass
501func helper() -> void:
502    pass
503"#;
504        let entries = list_test_entries_in_source(src).unwrap();
505        // xtest_* and ftest_* are not recognized — only test_* is.
506        assert_eq!(entries.len(), 1);
507        assert_eq!(entries[0].name, "test_normal");
508        assert_eq!(entries[0].kind, TestKind::Normal);
509    }
510
511    #[test]
512    fn parses_tag_comments() {
513        let src = br#"
514extends GdTest
515
516## @tag slow
517## @tag integration
518func test_heavy() -> void:
519    pass
520
521## regular comment, not a tag
522func test_untagged() -> void:
523    pass
524
525func test_also_untagged() -> void:
526    pass
527"#;
528        let entries = list_test_entries_in_source(src).unwrap();
529        assert_eq!(entries.len(), 3);
530        assert_eq!(entries[0].name, "test_heavy");
531        assert_eq!(entries[0].tags, vec!["slow", "integration"]);
532        assert!(entries[1].tags.is_empty());
533        assert!(entries[2].tags.is_empty());
534    }
535
536    #[test]
537    fn parses_file_header_isolation_directive() {
538        let src = br#"
539# gdtest: isolation=test
540extends GdTest
541
542func test_one() -> void:
543    pass
544
545func test_two() -> void:
546    pass
547"#;
548        let entries = list_test_entries_in_source(src).unwrap();
549        assert_eq!(entries.len(), 2);
550        assert_eq!(entries[0].isolation, Some(IsolationDirective::Test));
551        assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
552    }
553
554    #[test]
555    fn function_isolation_overrides_file_header() {
556        let src = br#"
557## @isolation test
558extends GdTest
559
560## @isolation file
561func test_one() -> void:
562    pass
563
564func test_two() -> void:
565    pass
566"#;
567        let entries = list_test_entries_in_source(src).unwrap();
568        assert_eq!(entries.len(), 2);
569        assert_eq!(entries[0].isolation, Some(IsolationDirective::File));
570        assert_eq!(entries[1].isolation, Some(IsolationDirective::Test));
571    }
572
573    #[test]
574    fn classifies_bench_funcs() {
575        let src = br#"
576func test_normal() -> void:
577    pass
578func bench_pathfinding() -> void:
579    pass
580"#;
581        let entries = list_test_entries_in_source(src).unwrap();
582        assert_eq!(entries.len(), 2);
583        assert_eq!(entries[0].kind, TestKind::Normal);
584        assert_eq!(entries[1].kind, TestKind::Bench);
585        // bench_* excluded from list_tests_in
586        let runnable: Vec<String> = entries
587            .into_iter()
588            .filter(|e| e.kind == TestKind::Normal)
589            .map(|e| e.name)
590            .collect();
591        assert_eq!(runnable, vec!["test_normal"]);
592    }
593
594    #[test]
595    fn empty_on_parse_error_tolerates_partial() {
596        let src = br#"func test_recoverable(): pass
597!@#$%garbage that breaks parsing"#;
598        let _entries = list_test_entries_in_source(src).unwrap();
599    }
600
601    #[test]
602    fn discovers_describe_class_tests() {
603        let src = br#"
604extends GdTest
605
606func test_top_level() -> void:
607    pass
608
609class describe_Movement:
610    func test_moves_right() -> void:
611        pass
612    func test_moves_left() -> void:
613        pass
614    func helper_not_a_test() -> void:
615        pass
616
617class IgnoredClass:
618    func test_inside_ignored() -> void:
619        pass
620"#;
621        let entries = list_test_entries_in_source(src).unwrap();
622        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
623        assert_eq!(
624            names,
625            vec![
626                "test_top_level",
627                "describe_Movement/test_moves_right",
628                "describe_Movement/test_moves_left",
629            ]
630        );
631        assert!(entries.iter().all(|e| e.kind == TestKind::Normal));
632    }
633
634    // ── glob_match tests ──────────────────────────────────────────────────────
635
636    #[test]
637    fn glob_match_test_dir_pattern() {
638        assert!(glob_match("test/**/*.gd", "test/battle.gd"));
639        assert!(glob_match("test/**/*.gd", "test/docs/test_foo.gd"));
640        assert!(glob_match("test/**/*.gd", "test/docs/dungeon/battle.gd"));
641        assert!(!glob_match("test/**/*.gd", "src/battle.gd"));
642        assert!(!glob_match("test/**/*.gd", "test/battle.tscn"));
643    }
644
645    #[test]
646    fn glob_match_colocated_pattern() {
647        assert!(glob_match("**/*.test.gd", "battle.test.gd"));
648        assert!(glob_match("**/*.test.gd", "src/battle/battle.test.gd"));
649        assert!(glob_match("**/*.test.gd", "deep/nested/dir/foo.test.gd"));
650        assert!(!glob_match("**/*.test.gd", "test_battle.gd"));
651        assert!(!glob_match("**/*.test.gd", "battle.gd"));
652    }
653
654    #[test]
655    fn glob_match_star_prefix_legacy() {
656        // Backward-compat: users may set patterns = ["test/test_*.gd"]
657        assert!(glob_match("test/test_*.gd", "test/test_battle.gd"));
658        assert!(!glob_match("test/test_*.gd", "test/battle.gd"));
659    }
660
661    // ── extract_describe_dir tests ────────────────────────────────────────────
662
663    #[test]
664    fn extract_describe_dir_finds_prefix() {
665        use std::path::PathBuf;
666        let p = PathBuf::from("test/describe_Battle/basic_hit.gd");
667        assert_eq!(
668            extract_describe_dir(&p),
669            Some("describe_Battle".to_string())
670        );
671    }
672
673    #[test]
674    fn extract_describe_dir_none_for_normal() {
675        use std::path::PathBuf;
676        let p = PathBuf::from("test/docs/test_foo.gd");
677        assert_eq!(extract_describe_dir(&p), None);
678    }
679
680    #[test]
681    fn extract_describe_dir_returns_last() {
682        use std::path::PathBuf;
683        // Nested describe dirs: return innermost
684        let p = PathBuf::from("test/describe_Outer/describe_Inner/foo.gd");
685        assert_eq!(extract_describe_dir(&p), Some("describe_Inner".to_string()));
686    }
687
688    #[test]
689    fn resolve_excludes_discovery_globs_for_directory_walks() {
690        let project = make_temp_project("resolve-exclude-dir");
691        fs::create_dir_all(project.join("test/helpers")).unwrap();
692        fs::create_dir_all(project.join("test/cases")).unwrap();
693        fs::write(
694            project.join("test/helpers/helper.gd"),
695            "extends RefCounted\n",
696        )
697        .unwrap();
698        fs::write(
699            project.join("test/cases/test_ok.gd"),
700            "extends RefCounted\n",
701        )
702        .unwrap();
703
704        let inputs = vec!["test".to_string()];
705        let patterns = vec!["test/**/*.gd".to_string()];
706        let excludes = vec!["test/helpers/**".to_string()];
707        let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
708
709        assert_eq!(resolved, vec!["res://test/cases/test_ok.gd"]);
710
711        fs::remove_dir_all(project).ok();
712    }
713
714    #[test]
715    fn resolve_keeps_explicit_file_targets_even_if_excluded() {
716        let project = make_temp_project("resolve-explicit-file");
717        fs::create_dir_all(project.join("test/helpers")).unwrap();
718        let helper = project.join("test/helpers/helper.gd");
719        fs::write(&helper, "extends RefCounted\n").unwrap();
720
721        let inputs = vec![helper.to_string_lossy().into_owned()];
722        let patterns = vec!["test/**/*.gd".to_string()];
723        let excludes = vec!["test/helpers/**".to_string()];
724        let resolved = resolve(&inputs, &project, &patterns, &excludes).unwrap();
725
726        assert_eq!(resolved, vec!["res://test/helpers/helper.gd"]);
727
728        fs::remove_dir_all(project).ok();
729    }
730}