diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 89040e886c252f..74e172b9e9d2cf 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -643,6 +643,12 @@ pub struct TestFlags { pub reporter: TestReporterConfig, pub junit_path: Option, pub hide_stacktraces: bool, + /// Run only test modules affected by files changed in git. + /// `None` when `--changed` is absent, `Some(None)` for `--changed` with no + /// value (uncommitted changes), `Some(Some(ref))` for `--changed=`. + pub changed: Option>, + /// Run only test modules that depend on the given source files (`--related`). + pub related: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Default)] @@ -4914,6 +4920,29 @@ or **/__tests__/**: .action(ArgAction::Append) .value_hint(ValueHint::AnyPath), ) + .arg( + Arg::new("changed") + .long("changed") + .help(cstr!("Run only test modules affected by files changed in git. + With no value, uses uncommitted changes (staged, unstaged and untracked). + Pass a git ref to compare against, e.g. --changed=main or --changed=HEAD~1.")) + .value_name("REF") + .num_args(0..=1) + .require_equals(true) + .conflicts_with("watch") + .help_heading(TEST_HEADING), + ) + .arg( + Arg::new("related") + .long("related") + .help("Run only test modules that depend on the given source files") + .num_args(1..) + .require_equals(true) + .action(ArgAction::Append) + .value_hint(ValueHint::AnyPath) + .conflicts_with("watch") + .help_heading(TEST_HEADING), + ) .arg( watch_arg(true) .conflicts_with("no-run") @@ -8343,6 +8372,19 @@ fn test_parse( let hide_stacktraces = matches.get_flag("hide-stacktraces"); + let changed = if matches.contains_id("changed") { + Some(matches.remove_one::("changed")) + } else { + None + }; + + let related = match matches.remove_many::("related") { + Some(f) => f + .flat_map(flat_escape_split_commas) + .collect::>()?, + None => vec![], + }; + flags.subcommand = DenoSubcommand::Test(TestFlags { no_run, doc, @@ -8362,6 +8404,8 @@ fn test_parse( reporter, junit_path, hide_stacktraces, + changed, + related, }); Ok(()) } @@ -12781,6 +12825,8 @@ mod tests { reporter: Default::default(), junit_path: None, hide_stacktraces: false, + changed: None, + related: vec![], }), no_npm: true, no_remote: true, @@ -12890,6 +12936,8 @@ mod tests { reporter: Default::default(), junit_path: None, hide_stacktraces: false, + changed: None, + related: vec![], }), type_check_mode: TypeCheckMode::Local, permissions: PermissionFlags { @@ -12936,6 +12984,8 @@ mod tests { reporter: Default::default(), junit_path: None, hide_stacktraces: false, + changed: None, + related: vec![], }), permissions: PermissionFlags { no_prompt: true, @@ -12948,6 +12998,62 @@ mod tests { ); } + #[test] + fn test_changed() { + let r = flags_from_vec(svec!["deno", "test", "--changed"]); + assert_eq!( + r.unwrap().subcommand, + DenoSubcommand::Test(TestFlags { + changed: Some(None), + ..Default::default() + }) + ); + + let r = flags_from_vec(svec!["deno", "test", "--changed=origin/main"]); + assert_eq!( + r.unwrap().subcommand, + DenoSubcommand::Test(TestFlags { + changed: Some(Some("origin/main".to_string())), + ..Default::default() + }) + ); + + // space-separated value is not allowed (would be ambiguous with file args) + let r = flags_from_vec(svec!["deno", "test", "--changed", "HEAD~1"]); + assert_eq!( + r.unwrap().subcommand, + DenoSubcommand::Test(TestFlags { + changed: Some(None), + files: FileFlags { + include: vec!["HEAD~1".to_string()], + ignore: vec![], + }, + ..Default::default() + }) + ); + } + + #[test] + fn test_related() { + let r = + flags_from_vec(svec!["deno", "test", "--related=src/a.ts,src/b.ts"]); + assert_eq!( + r.unwrap().subcommand, + DenoSubcommand::Test(TestFlags { + related: svec!["src/a.ts", "src/b.ts"], + ..Default::default() + }) + ); + } + + #[test] + fn test_changed_conflicts_with_watch() { + let r = flags_from_vec(svec!["deno", "test", "--changed", "--watch"]); + assert!(r.is_err()); + let r = flags_from_vec(svec!["deno", "test", "--related=a.ts", "--watch"]); + assert!(r.is_err()); + } + #[test] fn test_reporter() { let r = flags_from_vec(svec!["deno", "test", "--reporter=pretty"]); @@ -13076,6 +13182,8 @@ mod tests { reporter: Default::default(), junit_path: None, hide_stacktraces: false, + changed: None, + related: vec![], }), permissions: PermissionFlags { no_prompt: true, @@ -13115,6 +13223,8 @@ mod tests { reporter: Default::default(), junit_path: None, hide_stacktraces: false, + changed: None, + related: vec![], }), permissions: PermissionFlags { no_prompt: true, @@ -13153,6 +13263,8 @@ mod tests { reporter: Default::default(), junit_path: None, hide_stacktraces: false, + changed: None, + related: vec![], }), permissions: PermissionFlags { no_prompt: true, @@ -13198,6 +13310,8 @@ mod tests { reporter: Default::default(), junit_path: None, hide_stacktraces: false, + changed: None, + related: vec![], }), type_check_mode: TypeCheckMode::Local, permissions: PermissionFlags { diff --git a/cli/tools/test/mod.rs b/cli/tools/test/mod.rs index 979befa94d3407..3fb6772d640aa8 100644 --- a/cli/tools/test/mod.rs +++ b/cli/tools/test/mod.rs @@ -1907,6 +1907,173 @@ async fn fetch_specifiers_with_test_mode( Ok(specifiers_with_mode) } +/// Returns whether the specifier points at a file the module graph can parse +/// (i.e. a script, not a markdown doc-test file). Mirrors the partition used in +/// watch mode. +fn is_script_specifier(specifier: &ModuleSpecifier) -> bool { + deno_path_util::url_to_file_path(specifier) + .map(|p| is_script_ext(&p)) + .unwrap_or(true) +} + +/// Run `git` in `cwd`, returning stdout on success. +fn run_git(cwd: &Path, args: &[&str]) -> Result { + let output = std::process::Command::new("git") + .current_dir(cwd) + .args(args) + .output(); + let output = match output { + Ok(output) => output, + Err(err) => { + return Err(anyhow!( + "Failed to run `git {}` for `--changed`: {err}. Is git installed and on PATH?", + args.join(" ") + )); + } + }; + if !output.status.success() { + return Err(anyhow!( + "`git {}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Collect the set of files changed in git, as absolute paths. +/// +/// Without a `base` this is the working-tree diff (staged, unstaged and +/// untracked). With a `base` git ref it additionally includes committed changes +/// since the merge-base of `base` and `HEAD` (the `base...HEAD` three-dot form), +/// matching the behavior of Vitest and Jest. +fn changed_files_from_git( + cwd: &Path, + base: Option<&str>, +) -> Result, AnyError> { + // Resolve the repository root so git's repo-relative paths can be made + // absolute. + let repo_root = run_git(cwd, &["rev-parse", "--show-toplevel"]) + .map_err(|err| anyhow!("`--changed` requires a git repository: {err}"))?; + let repo_root = PathBuf::from(repo_root.trim()); + + let mut commands: Vec> = vec![ + // staged changes + vec!["diff", "--cached", "--name-only"], + // unstaged + untracked changes (respecting .gitignore) + vec!["ls-files", "--other", "--modified", "--exclude-standard"], + ]; + let range; + if let Some(base) = base { + // committed changes since the merge-base of `base` and HEAD + range = format!("{base}...HEAD"); + commands.push(vec!["diff", "--name-only", &range]); + } + + let mut files = Vec::new(); + for args in &commands { + let stdout = run_git(cwd, args)?; + for line in stdout.lines() { + let line = line.trim(); + if !line.is_empty() { + files.push(repo_root.join(line)); + } + } + } + Ok(files) +} + +/// Resolve the set of changed/related file paths requested via `--changed` and +/// `--related`, canonicalized so they line up with the module graph's paths. +/// +/// Returns `None` when neither flag was passed (no filtering). +fn collect_changed_paths( + cli_options: &CliOptions, + changed: &Option>, + related: &[String], +) -> Result>, AnyError> { + if changed.is_none() && related.is_empty() { + return Ok(None); + } + + let cwd = cli_options.initial_cwd(); + let mut raw_paths: Vec = Vec::new(); + + if let Some(base) = changed { + raw_paths.extend(changed_files_from_git(cwd, base.as_deref())?); + } + for file in related { + raw_paths.push(cwd.join(file)); + } + + // Canonicalize so paths match the graph's canonicalized module paths. Deleted + // files can't be canonicalized and are simply dropped. + let mut result = HashSet::with_capacity(raw_paths.len()); + for path in raw_paths { + if let Ok(path) = canonicalize_path(&path) { + result.insert(path); + } + } + Ok(Some(result)) +} + +/// Filter `specifiers_with_mode` down to the test modules affected by +/// `changed_paths`, using the module graph to find dependents. +/// +/// A module is kept when it is itself a changed/related file, or when any of its +/// local dependencies changed. As an escape hatch, if a `.env` file changed +/// (which any test could read) nothing is filtered out. +async fn filter_specifiers_by_changed( + factory: &CliFactory, + cli_options: &CliOptions, + specifiers_with_mode: Vec<(ModuleSpecifier, TestMode)>, + changed_paths: HashSet, +) -> Result, AnyError> { + // If an env file changed, any test could be affected; don't filter. + let env_file_changed = cli_options + .possible_env_file_paths_for_watch() + .any(|path| changed_paths.contains(&path)); + if env_file_changed { + return Ok(specifiers_with_mode); + } + + // Build a graph over the script test modules so we can walk each test's + // dependencies. Doc-only (e.g. markdown) modules aren't part of the graph and + // are matched by path instead. + let graph_kind = cli_options.type_check_mode().as_graph_kind(); + let module_graph_creator = factory.module_graph_creator().await?; + let graph_roots = specifiers_with_mode + .iter() + .filter(|(specifier, _)| is_script_specifier(specifier)) + .map(|(specifier, _)| specifier.clone()) + .collect::>(); + let graph = module_graph_creator + .create_graph(graph_kind, graph_roots, NpmCachingStrategy::Eager) + .await?; + module_graph_creator.graph_valid(&graph)?; + + let result = specifiers_with_mode + .into_iter() + .filter(|(specifier, _)| { + // Always keep a module that is itself a changed/related file. + if let Ok(path) = deno_path_util::url_to_file_path(specifier) + && let Ok(path) = canonicalize_path(&path) + && changed_paths.contains(&path) + { + return true; + } + // Keep a script module if any of its local dependencies changed. + is_script_specifier(specifier) + && has_graph_root_local_dependent_changed( + &graph, + specifier, + &changed_paths, + ) + }) + .collect(); + Ok(result) +} + pub async fn run_tests( flags: Arc, test_flags: TestFlags, @@ -1928,11 +2095,39 @@ pub async fn run_tests( ) .await?; - if !workspace_test_options.permit_no_files && specifiers_with_mode.is_empty() + let is_changed_filter = + test_flags.changed.is_some() || !test_flags.related.is_empty(); + + if !workspace_test_options.permit_no_files + && !is_changed_filter + && specifiers_with_mode.is_empty() { return Err(anyhow!("No test modules found")); } + // Filter down to test modules affected by `--changed` / `--related`. + let specifiers_with_mode = match collect_changed_paths( + cli_options, + &test_flags.changed, + &test_flags.related, + )? { + None => specifiers_with_mode, + Some(changed_paths) => { + filter_specifiers_by_changed( + &factory, + cli_options, + specifiers_with_mode, + changed_paths, + ) + .await? + } + }; + + if is_changed_filter && specifiers_with_mode.is_empty() { + log::info!("No test modules were affected by the given changes"); + return Ok(()); + } + let doc_tests = get_doc_tests(&specifiers_with_mode, file_fetcher).await?; let specifiers_for_typecheck_and_test = get_target_specifiers(specifiers_with_mode, &doc_tests); diff --git a/test-affected-research.md b/test-affected-research.md new file mode 100644 index 00000000000000..416416b5f1d5e7 --- /dev/null +++ b/test-affected-research.md @@ -0,0 +1,242 @@ +# Research: `deno test --changed` / `--affected` (issue #28182) + +Dependency-aware, git-driven test selection for `deno test`, working across +workspace members. This document captures what the feature actually needs to be, +prior art in other runners, what already exists in the Deno codebase, a +recommended design, and the concrete edge cases. + +Tracking issue: + +## 1. What is actually being asked for + +The issue title says "git-based `deno test --changed`", but the discussion +narrows it considerably: + +- A maintainer (marvinhagemeister) pointed out that `deno test --watch` **already** + re-runs only the tests that import a changed module. +- The requester clarified: `--watch` is *continuous*, which is the wrong shape. + They want a **single, on-demand, non-continuous** run that is dependency-aware: + > "A command like `deno test --affected ` would be ideal. It + > would analyze dependencies ... and execute only the truly relevant tests + > just once." + +So the ask is: **bring the affected-test selection that already happens inside +watch mode to a one-shot run, sourcing the "changed files" from git (or an +explicit set) instead of from the file-watcher.** It should also work across a +workspace, where one member's tests depend on another member's source. + +The existing third-party workaround (`@staytuned/deno-dag-test`) builds the +import DAG and walks it the same way Deno already does internally. + +## 2. Prior art + +| Tool | Flag(s) | Granularity | Change source | Graph mapping | +|------|---------|-------------|---------------|---------------| +| **Vitest** | `--changed [ref]`, `--related ` | file | no ref → uncommitted (staged+unstaged); `--changed HEAD~1` / hash / branch → that diff | Vite module graph; runs test files that (transitively) import a changed module | +| **Jest** | `-o`/`--onlyChanged`, `--changedSince `, `--changedFilesWithAncestor`, `--lastCommit` | file | `jest-changed-files` (git or hg); uncommitted ± last commit, or since a ref | Haste module map; tests depending on changed files | +| **Nx** | `nx affected -t test --base --head ` | project | git diff base..head; default base = main branch, head = working tree | project graph: files → owning project → **dependent** projects | +| **Turborepo** | `turbo run test --filter=...[ref]`; `--affected` shorthand | package | git diff vs ref/range; `--affected` ≈ `...[main...HEAD]` (overridable via `TURBO_SCM_BASE`/`TURBO_SCM_HEAD`) | package graph: `[ref]` = changed only, `...[ref]` = changed + dependents, `[ref]...` = changed + deps | +| **Bazel** | (none native) `bazel-diff`, `target-determinator` | target | content/merkle hash of action graph between commits | transitive action-graph; remote cache skips unchanged test actions | +| **Pants** | `--changed-since=`, `--changed-dependents` | target | git diff since ref | dependee graph | +| **Gradle** | build cache + up-to-date checks | task | input fingerprints | test task skipped when inputs unchanged | + +Sources: Vitest [CLI](https://vitest.dev/guide/cli) · +[`--changed` discussion](https://github.com/vitest-dev/vitest/discussions/6734) · +[`forceRerunTriggers`](https://vitest.dev/config/forcereruntriggers); +Jest [CLI](https://jestjs.io/docs/cli) · +[`jest-changed-files`](https://www.npmjs.com/package/jest-changed-files); +Nx [Affected](https://nx.dev/ci/features/affected) · +[nx-set-shas](https://github.com/nrwl/nx-set-shas); +Turborepo [filtering rules](https://github.com/vercel/turborepo/blob/main/skills/turborepo/references/filtering/RULE.md) · +[`--affected` discussion](https://github.com/vercel/turborepo/discussions/9076). + +### Common patterns + +1. **Two inputs**: a set of changed files (from git) + a dependency graph. +2. **Granularity split**: monorepo *task* runners (Nx, Turbo) select at the + project/package level; *test* runners (Jest, Vitest) select at the file level. + `deno test` is a file-level test runner, so file-level selection is the right + fit (and is what watch mode already does). +3. **Direction**: from a changed file, find the *dependents* (the tests that + transitively import it). Two ways to implement: a reverse graph, or — as Deno + already does — forward-walk each test root and check whether any of its deps + is in the changed set. +4. **Git ref convention**: default to the **working-tree / uncommitted** diff for + local dev; accept an explicit ref or `base...head` range for CI, typically + resolved through a merge-base. +5. **Escape hatches are mandatory**: not every dependency is an import edge + (config files, JSON/data fixtures read at runtime, env files, the test runner + config itself). Every tool has a "force rerun everything" trigger + (`forceRerunTriggers`, Nx `namedInputs`/implicit deps + global files, Turbo + `globalDependencies`). Vitest reruns the whole suite when the config or + `package.json` changes. +6. **Caching is a separate, complementary layer** (Turbo/Bazel/Gradle hash task + inputs and skip cached results). The Deno issue is purely about *selection*, + not result caching, so that layer is out of scope here. + +## 3. What already exists in the Deno codebase + +The machinery is essentially all there — it's wired to the file watcher rather +than to git. + +- **Graph-walk selection primitive** — + `cli/graph_util.rs:1137` `has_graph_root_local_dependent_changed(graph, root, + changed_paths) -> bool`. Walks a test root's dependencies + (`follow_dynamic: true`, skipping remote modules) and returns true if any + local dependency is in the changed set. This is exactly the affected check. + +- **Watch already does the selection** — `cli/tools/test/mod.rs:2131-2172`. + In `run_tests_with_watch`, after building the graph it takes `changed_paths` + from the watcher and filters test modules: + - env-file change → reload everything (`mod.rs:2136`, an existing escape hatch); + - otherwise keep each test module where `has_graph_root_local_dependent_changed` + is true; doc-only (`.md`) modules are matched by path. + This is the precise behavior to reuse — only the source of `changed_paths` + differs. + +- **One-shot entry point** — `cli/tools/test/mod.rs:1910` `run_tests()`. Collects + specifiers via `fetch_specifiers_with_test_mode` (`mod.rs:1923`), typechecks, + then `test_specifiers`. The graph is currently built inside the typecheck + container; an affected filter would need a graph *before* run (build it with + `module_graph_creator.create_graph`, as the watch path already does). + +- **Workspace enumeration** — `cli/args/mod.rs` `resolve_test_options_for_members` + (used at `mod.rs:1921`) already enumerates every workspace member's test files, + and the module graph spans the whole workspace. So a change in member B's source + that member A's test imports is just an edge in the same graph — cross-workspace + affected selection is essentially **free** once changed paths are known. This is + the key "works across workspaces" property the issue asks for. + +- **Git plumbing precedent** — there is **no** git change-detection today, but + `cli/tools/bump_version.rs:951` already shells out via `run_git(cwd, args)` + (`Command::new("git")`), incl. `rev-parse --show-toplevel`, `rev-parse + --abbrev-ref HEAD`, `show `. Same pattern works for `diff --name-only`. + +- **No existing `--changed`/`--affected` flag.** `TestFlags` + (`cli/args/flags.rs:627`) has `watch`, `filter`, `files`, etc., but nothing for + change-based selection. Confirmed by grep + PR search. + +## 4. Recommended design + +### CLI surface + +- `deno test --changed[=]` as the primary flag (matches the issue title and + the Vitest/Jest mental model for a *test runner*). Semantics mirror Vitest: + - no value → diff of the working tree (staged + unstaged + untracked) vs `HEAD`; + - `=` (branch, tag, or commit) → `git diff --name-only ...` + plus the working-tree diff, so local edits on top of a branch are included. +- Accept an explicit file list too (the requester literally wrote + `--affected `): allow positional/`--related`-style files to seed the + changed set without touching git. Useful in CI that already knows the diff. + (Prior art: Vitest `--related `, Jest `--findRelatedTests `, Nx + `--files=` all provide this git-free seed; Nx also has `--uncommitted` / + `--untracked` toggles worth considering.) +- Compose with existing flags: `--changed` + `--watch` (initial filter, then + normal watch), `--filter`, `--coverage`, workspace runs. +- Consider mirroring to `deno bench` later; keep this PR scoped to `test`. + +`TestFlags`: add `pub changed: Option` where `ChangedSpec` captures +"working tree" vs "since ref" vs "explicit files". (Using `Option>` +also works but an enum reads better.) + +### Granularity & graph + +File-level, reusing `has_graph_root_local_dependent_changed`. No reverse graph +needed. + +### Flow (one-shot, in `run_tests`) + +1. Collect candidate specifiers across all members (existing + `fetch_specifiers_with_test_mode`). +2. If `--changed` is set: + a. Resolve the changed-file set: from git (`git diff --name-only` + + `git status --porcelain` for untracked, merge-base for a ref) or from the + explicit file list; canonicalize paths. + b. Build the graph over the candidate roots + (`module_graph_creator.create_graph`, as watch does at `mod.rs:2125`). + c. Keep a candidate if it *is* a changed file, or + `has_graph_root_local_dependent_changed(graph, specifier, changed)` is true; + match doc-only modules by path (same split as `mod.rs:2118`). + d. Apply escape hatches → fall back to running everything (see §5). +3. Typecheck + run the filtered set. If empty, respect `permit_no_files` + (exit 0 with a clear "no affected test files" message rather than erroring). + +### Where the code lands + +1. `cli/args/flags.rs` — add the flag to the `test` subcommand + `TestFlags` + field + parsing. +2. New helper, e.g. `cli/util/git.rs` (or `cli/tools/test/changed.rs`): + `changed_files(cwd, base: Option<&str>) -> Result>`, reusing + the `run_git` pattern. Resolve repo root, merge-base, name-only diff, untracked. + + The git commands can be lifted almost verbatim from Vitest/Jest (both + verified against their current source) — note the **three-dot** range, which + diffs against the *merge-base* so local edits on top of a branch are included + and upstream commits on the ref are not: + + ```text + # repo root + git rev-parse --show-cdup # (or --show-toplevel, as bump_version.rs uses) + + # bare --changed → working-tree (staged + unstaged + untracked) + git diff --cached --name-only + git ls-files --other --modified --exclude-standard # --other adds untracked; respects .gitignore + + # --changed= → above, plus committed changes since the merge-base + git diff --name-only ...HEAD # three-dot = since merge-base(, HEAD) + ``` + + Mirror Vitest/Jest exactly here: bare flag = uncommitted working tree, + `=` adds the merge-base diff. `--exclude-standard` keeps ignored files + out. +3. `cli/tools/test/mod.rs` — build graph + filter in `run_tests` before + typecheck; apply the same seeding to `run_tests_with_watch` so `--changed` + composes with `--watch`. +4. Spec tests under `tests/specs/test/` (a git fixture repo with members) + + docs in `cli/args/flags.rs` help text. + +## 5. Edge cases & decisions + +- **Non-import dependencies** (deno.json(c) / import map, JSON & data fixtures + read via `Deno.readTextFile`, `.env`): invisible to the import graph. This is + the single biggest design decision, and prior art splits two ways: + - **Jest selects *nothing*** for an unmappable change (no run-everything + fallback) — a well-known CI footgun where editing `jest.config.js` runs zero + tests. + - **Vitest reruns *everything*** when a change matches `forceRerunTriggers` + (default globs: `**/package.json`, `**/{vitest,vite}.config.*`). + Recommendation: follow Vitest. Mirror the existing env-file escape hatch + (`mod.rs:2136`): a change to `deno.json`/`deno.jsonc` / import map / `deno.lock` + / `.env` → run the whole (workspace) suite. Optionally expose a Vitest-style + `forceRerunTriggers` glob in `deno.json` for project-specific fixtures. +- **A changed file that is itself a test file** → always select it. +- **Newly added test file** → no prior graph; treat "is in changed set" as select. +- **Deleted file** → cannot be a graph node. Two options: drop it from the + changed set (its former dependents fail typecheck anyway, surfacing the + breakage — simplest), or, like Nx (which assumes *all* projects affected when a + project is deleted), fall back to running everything. Recommend the drop + approach for files; reserve the run-all fallback for the escape-hatch configs. +- **Dynamic / non-analyzable imports** → graph may miss them; `follow_dynamic: + true` is already set, but document that fully dynamic specifiers can be missed. +- **Remote (npm:/jsr:/https:) changes** → only local `file:` changes matter; the + helper already skips remote subtrees. +- **Not a git repo / git missing / shallow clone** → Jest is famously a "courier + for git errors" here. Fail with an actionable message (and a hint about CI + shallow-clone / fetch-depth), or allow `--changed=` to bypass + git entirely. +- **Default base for CI** → unlike Nx/Turbo we should *not* silently default to + `main`; default to the working tree (local-dev shape the requester wants) and + require an explicit `--changed=` for base..head CI comparisons. Document + the `origin/main` / merge-base recipe. +- **Listing** → consider letting `--no-run` (or a `list`-like mode) print the + selected files, paralleling `vitest list --changed`, for debuggability. + +## 6. Effort estimate + +Small-to-medium. The selection algorithm, graph construction, workspace +enumeration, and doc-module handling already exist and are battle-tested in watch +mode. The genuinely new work is: (1) flag plumbing, (2) a ~50-line git +change-detection helper, (3) lifting the watch-mode filter into the one-shot +`run_tests` path, and (4) the escape-hatch policy for non-import dependencies. +The cross-workspace requirement needs no special handling — it falls out of the +single workspace-wide module graph. diff --git a/tests/specs/test/changed/__test__.jsonc b/tests/specs/test/changed/__test__.jsonc new file mode 100644 index 00000000000000..807f2613655271 --- /dev/null +++ b/tests/specs/test/changed/__test__.jsonc @@ -0,0 +1,44 @@ +{ + "tempDir": true, + "steps": [ + { + "commandName": "git", + "args": "init", + "output": "[WILDCARD]" + }, + { + "commandName": "git", + "args": "config user.email test@example.com", + "output": "[WILDCARD]" + }, + { + "commandName": "git", + "args": "config user.name test", + "output": "[WILDCARD]" + }, + { + "commandName": "git", + "args": "config commit.gpgsign false", + "output": "[WILDCARD]" + }, + { + "commandName": "git", + "args": "add .", + "output": "[WILDCARD]" + }, + { + "commandName": "git", + "args": "commit -m init", + "output": "[WILDCARD]" + }, + { + "args": "run --allow-read --allow-write touch.ts", + "output": "[WILDCARD]" + }, + { + "args": "test --no-check --changed", + "output": "changed.out", + "exitCode": 0 + } + ] +} diff --git a/tests/specs/test/changed/changed.out b/tests/specs/test/changed/changed.out new file mode 100644 index 00000000000000..50dc33de5194d3 --- /dev/null +++ b/tests/specs/test/changed/changed.out @@ -0,0 +1,4 @@ +running 1 test from ./math_test.ts +add ... ok ([WILDCARD]) + +ok | 1 passed | 0 failed ([WILDCARD]) diff --git a/tests/specs/test/changed/math.ts b/tests/specs/test/changed/math.ts new file mode 100644 index 00000000000000..da7ff17a4d7f77 --- /dev/null +++ b/tests/specs/test/changed/math.ts @@ -0,0 +1 @@ +export const add = (a: number, b: number): number => a + b; diff --git a/tests/specs/test/changed/math_test.ts b/tests/specs/test/changed/math_test.ts new file mode 100644 index 00000000000000..68c857d2572569 --- /dev/null +++ b/tests/specs/test/changed/math_test.ts @@ -0,0 +1,5 @@ +import { add } from "./math.ts"; + +Deno.test("add", () => { + if (add(1, 2) !== 3) throw new Error("fail"); +}); diff --git a/tests/specs/test/changed/touch.ts b/tests/specs/test/changed/touch.ts new file mode 100644 index 00000000000000..b44d096c78d365 --- /dev/null +++ b/tests/specs/test/changed/touch.ts @@ -0,0 +1,4 @@ +Deno.writeTextFileSync( + "math.ts", + Deno.readTextFileSync("math.ts") + "\n// changed\n", +); diff --git a/tests/specs/test/changed/util.ts b/tests/specs/test/changed/util.ts new file mode 100644 index 00000000000000..91ef11e6e37634 --- /dev/null +++ b/tests/specs/test/changed/util.ts @@ -0,0 +1 @@ +export const id = (x: T): T => x; diff --git a/tests/specs/test/changed/util_test.ts b/tests/specs/test/changed/util_test.ts new file mode 100644 index 00000000000000..5f096b63b077b3 --- /dev/null +++ b/tests/specs/test/changed/util_test.ts @@ -0,0 +1,5 @@ +import { id } from "./util.ts"; + +Deno.test("id", () => { + if (id(5) !== 5) throw new Error("fail"); +}); diff --git a/tests/specs/test/related/__test__.jsonc b/tests/specs/test/related/__test__.jsonc new file mode 100644 index 00000000000000..f1e17f20186a4c --- /dev/null +++ b/tests/specs/test/related/__test__.jsonc @@ -0,0 +1,5 @@ +{ + "args": "test --no-check --related=math.ts", + "output": "related.out", + "exitCode": 0 +} diff --git a/tests/specs/test/related/math.ts b/tests/specs/test/related/math.ts new file mode 100644 index 00000000000000..da7ff17a4d7f77 --- /dev/null +++ b/tests/specs/test/related/math.ts @@ -0,0 +1 @@ +export const add = (a: number, b: number): number => a + b; diff --git a/tests/specs/test/related/math_test.ts b/tests/specs/test/related/math_test.ts new file mode 100644 index 00000000000000..68c857d2572569 --- /dev/null +++ b/tests/specs/test/related/math_test.ts @@ -0,0 +1,5 @@ +import { add } from "./math.ts"; + +Deno.test("add", () => { + if (add(1, 2) !== 3) throw new Error("fail"); +}); diff --git a/tests/specs/test/related/related.out b/tests/specs/test/related/related.out new file mode 100644 index 00000000000000..50dc33de5194d3 --- /dev/null +++ b/tests/specs/test/related/related.out @@ -0,0 +1,4 @@ +running 1 test from ./math_test.ts +add ... ok ([WILDCARD]) + +ok | 1 passed | 0 failed ([WILDCARD]) diff --git a/tests/specs/test/related/util.ts b/tests/specs/test/related/util.ts new file mode 100644 index 00000000000000..91ef11e6e37634 --- /dev/null +++ b/tests/specs/test/related/util.ts @@ -0,0 +1 @@ +export const id = (x: T): T => x; diff --git a/tests/specs/test/related/util_test.ts b/tests/specs/test/related/util_test.ts new file mode 100644 index 00000000000000..5f096b63b077b3 --- /dev/null +++ b/tests/specs/test/related/util_test.ts @@ -0,0 +1,5 @@ +import { id } from "./util.ts"; + +Deno.test("id", () => { + if (id(5) !== 5) throw new Error("fail"); +});