Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
run: |
for c in testless-core testless-lang-ts testless-lang-go testless-lang-rust testless; do
for c in testless-core testless-lang-ts testless-lang-go testless-lang-rust testless-lang-java testless; do
set +e
out=$(cargo publish -p "$c" --no-verify 2>&1)
status=$?
Expand Down
1 change: 1 addition & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"crates/lang-ts": "0.6.0",
"crates/lang-go": "0.6.0",
"crates/lang-rust": "0.6.0",
"crates/lang-java": "0.6.0",
"crates/cli": "0.6.0"
}
16 changes: 13 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,28 @@ All three (test, clippy, fmt) must pass clean before a PR is opened.
## Adding a language

A language plugin implements the `Language` trait (`crates/core/src/language.rs`).
Five things per language, everything else is shared:
Six things per language, everything else is shared:

1. **Grammar**: the tree-sitter `Language` for the file's extension(s).
2. **Extraction queries**: walk the tree and emit `ExtractedDef`s (functions,
methods, tests) plus `ImportRef`s.
3. **Import resolution**: turn a raw import specifier into a repo-relative
path, or `None` if it's external/unresolvable.
path, or `None` if it's external/unresolvable. Returning a *directory*
fans out to every indexed file under it (Go packages, Java wildcards).
4. **Test-ID construction**: build the dotted/segmented ID a test runner
would recognize (including subtests, e.g. Go's `t.Run` chains).
5. **Over-approximation triggers**: the specific shapes in this language
5. **Package scope** (`package_key`): return a key when files sharing it see
each other with no import statement. Default `None` means file-scoped
(TS, Rust). Go keys on the directory; Java keys on the package with the
source root stripped, so `src/main/java/com/foo` and
`src/test/java/com/foo` are one scope.
6. **Over-approximation triggers**: the specific shapes in this language
that are ambiguous enough to widen selection rather than guess narrowly.

If the language has more than one test runner (Java's Maven vs Gradle), the
mapping lives in `crates/cli/src/runner.rs`, not in the plugin: it depends on
build files, not on syntax.

`crates/lang-go/src/lib.rs` is the smallest reference implementation, read
it before starting a new one.

Expand Down
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
[workspace]
resolver = "2"
members = ["crates/core", "crates/lang-ts", "crates/lang-go", "crates/lang-rust", "crates/cli"]
members = [
"crates/core",
"crates/lang-ts",
"crates/lang-go",
"crates/lang-rust",
"crates/lang-java",
"crates/cli",
]

[workspace.package]
edition = "2021"
Expand Down
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Prebuilt binaries on [Releases](https://github.com/itaywol/testless/releases). N
```bash
testless index # build the graph (.testless/)
testless select --from origin/main # tests impacted by your changes
testless select --from origin/main --format args # runnable vitest / go test / cargo test lines
testless select --from origin/main --format args # runnable vitest / go test / cargo / gradle lines
```

JSON when piped, human text on a TTY. On fallback-to-everything, `--format args`
Expand All @@ -71,17 +71,27 @@ Optional `testless.toml` at the repo root, for the cases static inference can't
```toml
always-run = ["tests/smoke/**", "**/*.e2e.test.ts"] # always select these tests
ignore = ["**/generated/**", "*.pb.go"] # never index these files
java-runner = "gradle" # force maven|gradle, else sniffed
```

## Languages

TypeScript / JavaScript (vitest, jest `-t` patterns) · Go (`go test -run`) · Rust (`cargo test`)
| Language | Tests it reads | Commands it prints |
|---|---|---|
| TypeScript / JavaScript | vitest, jest | `vitest run <file> -t <name>` |
| Go | `testing`, `t.Run` subtests | `go test ./pkg -run '^Test$/^sub$'` |
| Rust | `#[test]`, module chains | `cargo test path::name -- --exact` |
| Java | JUnit 5 (`@Test`, `@ParameterizedTest`, `@Nested`) | `gradle test --tests C.m` / `mvn test -Dtest='C#m'` |

Java picks Maven or Gradle from the nearest `pom.xml` / `build.gradle`, per
module; override with `java-runner` in `testless.toml`. Multi-module builds are
scoped automatically (`-pl <module>`, `:module:test`).

A new language is roughly one plugin file: see [CONTRIBUTING](CONTRIBUTING.md).

## Status

Young project. The selection engine works end to end on all three languages;
Young project. The selection engine works end to end on all four languages;
precision improves release by release. Roadmap lives in
[issues](https://github.com/itaywol/testless/issues).

Expand Down
1 change: 1 addition & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ testless-core = { path = "../core", version = "0.6.0" }
testless-lang-ts = { path = "../lang-ts", version = "0.6.0" }
testless-lang-go = { path = "../lang-go", version = "0.6.0" }
testless-lang-rust = { path = "../lang-rust", version = "0.6.0" }
testless-lang-java = { path = "../lang-java", version = "0.6.0" }
clap = { version = "4.6.4", features = ["derive"] }
anyhow = "1.0.104"
serde_json = "1.0.151"
Expand Down
201 changes: 197 additions & 4 deletions crates/cli/src/format.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! `--format args`: render selected tests as ready-to-run test-runner
//! command lines (vitest / `go test` / `cargo test`). Pure string
//! generation: this module never spawns a process, it only builds the
//! strings a human or CI job would paste into a shell.
//! command lines (vitest / `go test` / `cargo test` / `mvn` / `gradle`).
//! Pure string generation: this module never spawns a process, it only
//! builds the strings a human or CI job would paste into a shell.
//!
//! One line per selected test (v1 keeps it simple: no multi-test `-t`
//! grouping), deduplicated (a `computed` entry drops its exactness flag,
Expand Down Expand Up @@ -107,10 +107,81 @@ fn cargo_line(t: &SelectedTest) -> String {
}
}

/// Split a Java `test_id` chain into its JVM class name and (when the
/// chain names one) its method. The chain is `[fqcn, nested…, method]`:
/// `["com.foo.OuterTest", "Inner", "adds"]` yields
/// `("com.foo.OuterTest$Inner", Some("adds"))`, matching how the JVM names
/// a JUnit 5 `@Nested` class.
///
/// A single-segment chain (`["com.foo.BarTest"]`, a class-level TestNG
/// `@Test`) has no method to filter on, so the whole class runs.
fn java_class_and_method(name: &[String]) -> (String, Option<&str>) {
match name.split_last() {
None => (String::new(), None),
Some((_, [])) => (name.join("$"), None),
Some((method, classes)) => (classes.join("$"), Some(method.as_str())),
}
}

/// `mvn test -pl <module> -Dtest='com.foo.BarTest#adds' -DfailIfNoTests=false`.
///
/// `-pl` is emitted only for a test inside a build module below the repo
/// root; a single-module repo gets the plain reactor. `-DfailIfNoTests=false`
/// is not optional: without it Surefire *fails* every module in a
/// multi-module reactor whose tests don't match `-Dtest`, which is every
/// module but one for a narrowed selection.
///
/// `computed` (a `@ParameterizedTest` / `@TestFactory`, whose per-invocation
/// display names aren't statically knowable) drops the `#method` filter and
/// widens to the whole class rather than printing a pattern that would
/// under-select.
fn maven_line(t: &SelectedTest) -> String {
let (class, method) = java_class_and_method(&t.name);
let selector = match method {
Some(m) if !t.computed => format!("{class}#{m}"),
_ => class,
};
let scope = match &t.module {
Some(m) => format!(" -pl {}", sh_quote(&m.display().to_string())),
None => String::new(),
};
format!(
"mvn test{scope} -Dtest={} -DfailIfNoTests=false",
sh_quote(&selector)
)
}

/// `gradle :services:billing:test --tests 'com.foo.BarTest.adds'`.
///
/// The task path is scoped to the test's own build module, so sibling
/// projects in a multi-project build aren't asked to match a filter no test
/// of theirs can satisfy (Gradle errors out on that, rather than skipping).
/// A repo-root module gets the bare `test` task.
///
/// `computed` widens to the whole class, same rationale as [`maven_line`].
fn gradle_line(t: &SelectedTest) -> String {
let (class, method) = java_class_and_method(&t.name);
let pattern = match method {
Some(m) if !t.computed => format!("{class}.{m}"),
_ => class,
};
let task = match &t.module {
Some(m) => format!(
":{}:test",
m.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join(":")
),
None => "test".to_string(),
};
format!("gradle {} --tests {}", sh_quote(&task), sh_quote(&pattern))
}

/// Render `tests` as one runner-consumable command line each, deduplicated
/// and sorted for a deterministic, script-friendly stdout stream. A
/// `runner` this module doesn't recognize (only `"unknown"` today, see
/// `runner_for_lang`) is silently skipped: there's no sensible command to
/// `crate::runner::runner_for`) is silently skipped: there's no sensible command to
/// print for it, and `select`'s `json`/`text` formats already surface the
/// `"unknown"` label for inspection.
pub fn command_lines(tests: &[SelectedTest]) -> Vec<String> {
Expand All @@ -120,6 +191,8 @@ pub fn command_lines(tests: &[SelectedTest]) -> Vec<String> {
"vitest" => Some(vitest_line(t)),
"gotest" => Some(gotest_line(t)),
"cargo" => Some(cargo_line(t)),
"maven" => Some(maven_line(t)),
"gradle" => Some(gradle_line(t)),
_ => None,
})
.collect();
Expand All @@ -139,6 +212,7 @@ mod tests {
name: name.iter().map(|s| s.to_string()).collect(),
runner,
lang: "irrelevant".to_string(),
module: crate::runner::module_dir(std::path::Path::new(file)),
computed,
}
}
Expand Down Expand Up @@ -230,6 +304,125 @@ mod tests {
assert_eq!(command_lines(&[t]), vec!["cargo test math::add_works"]);
}

#[test]
fn maven_single_module() {
let t = test(
"src/test/java/com/foo/BarTest.java",
&["com.foo.BarTest", "addsNegatives"],
"maven",
false,
);
assert_eq!(
command_lines(&[t]),
vec!["mvn test -Dtest='com.foo.BarTest#addsNegatives' -DfailIfNoTests=false"]
);
}

#[test]
fn maven_multi_module_scopes_with_pl() {
let t = test(
"services/billing/src/test/java/com/foo/BarTest.java",
&["com.foo.BarTest", "addsNegatives"],
"maven",
false,
);
assert_eq!(
command_lines(&[t]),
vec![
"mvn test -pl services/billing -Dtest='com.foo.BarTest#addsNegatives' -DfailIfNoTests=false"
]
);
}

// A JUnit 5 `@Nested` class is `Outer$Inner` on the JVM, so the chain's
// middle segments join with `$`, not `.`.
#[test]
fn maven_nested_class_uses_dollar() {
let t = test(
"src/test/java/com/foo/OuterTest.java",
&["com.foo.OuterTest", "WhenEmpty", "throws"],
"maven",
false,
);
assert_eq!(
command_lines(&[t]),
vec!["mvn test -Dtest='com.foo.OuterTest$WhenEmpty#throws' -DfailIfNoTests=false"]
);
}

#[test]
fn maven_computed_widens_to_class() {
let t = test(
"src/test/java/com/foo/BarTest.java",
&["com.foo.BarTest", "addsNegatives"],
"maven",
true,
);
assert_eq!(
command_lines(&[t]),
vec!["mvn test -Dtest=com.foo.BarTest -DfailIfNoTests=false"]
);
}

#[test]
fn gradle_single_module() {
let t = test(
"src/test/java/com/foo/BarTest.java",
&["com.foo.BarTest", "addsNegatives"],
"gradle",
false,
);
assert_eq!(
command_lines(&[t]),
vec!["gradle test --tests com.foo.BarTest.addsNegatives"]
);
}

#[test]
fn gradle_multi_module_scopes_task_path() {
let t = test(
"services/billing/src/test/java/com/foo/BarTest.java",
&["com.foo.BarTest", "addsNegatives"],
"gradle",
false,
);
assert_eq!(
command_lines(&[t]),
vec!["gradle :services:billing:test --tests com.foo.BarTest.addsNegatives"]
);
}

#[test]
fn gradle_computed_widens_to_class() {
let t = test(
"src/test/java/com/foo/BarTest.java",
&["com.foo.BarTest", "addsNegatives"],
"gradle",
true,
);
assert_eq!(
command_lines(&[t]),
vec!["gradle test --tests com.foo.BarTest"]
);
}

/// A class-level chain (TestNG's class-scoped `@Test`) has no method to
/// filter on and must run the whole class rather than treating the
/// class name as a method name.
#[test]
fn java_class_only_chain_has_no_method_filter() {
let t = test(
"src/test/java/com/foo/BarTest.java",
&["com.foo.BarTest"],
"gradle",
false,
);
assert_eq!(
command_lines(&[t]),
vec!["gradle test --tests com.foo.BarTest"]
);
}

// Test names are free-form strings: quotes, dollar signs, backticks,
// backslashes and apostrophes all legitimately appear in `it(...)` /
// `t.Run(...)` names. `sh_quote` must render them so a shell (or the
Expand Down
Loading
Loading