Skip to content
Merged
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ Every test command takes a subcommand; `t` is a shorthand for `test`.
| `lint [path]` | Lint a folder (respects `JuliaLint.toml`) |
| `format [path]` | Format a file or folder in place |

Run flags: `--name=`, `--tags=`, `--workers=`, `--timeout=`, `--coverage`, `--bg`, and `+channel` for a Juliaup channel.
Run flags: `--name=`, `--tags=`, `--packages=`, `--exclude-packages=`, `--workers=`, `--timeout=`, `--coverage`, `--bg`, and `+channel` for a Juliaup channel.

Discovery is a folder walk, so `test` at the root of a monorepo or a Pkg `[workspace]` runs every package below it, each in its own test environment. `--packages=A,B` narrows that to the members you are working on, which also avoids activating and precompiling the rest.

## License

Expand Down
71 changes: 68 additions & 3 deletions src/DevREPL.jl
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,49 @@ function parse_args(parts)
return positional, kwargs, flags
end

# One `--packages`-style value: comma separated names. An empty entry is rejected rather
# than ignored, because it is always a typo and dropping it would widen the selection.
function _package_names(kwargs, key::Symbol)
haskey(kwargs, key) || return nothing
value = kwargs[key]
names = String[strip(p) for p in split(value, ',')]
any(isempty, names) && throw(ArgumentError("invalid value for --$key: $value (empty package name)"))
return Set(names)
end

"""
_package_filter(kwargs) -> (predicate, description)

The `--packages` / `--exclude-packages` selection as an `item -> Bool` predicate plus a
short description of it, or `(nothing, nothing)` when neither was given. The predicate only
reads `package_name`, so it works on a `TestItem` and on the NamedTuple a run filter sees
alike.

Names match exactly and case sensitively, as Julia package names are, and `--packages` is
applied before `--exclude-packages`. A test item that belongs to no package has an empty
`package_name` and so is never selected by `--packages` — asking for a named package must
not drag in items that have none.

This is the knob for a monorepo or a Pkg `[workspace]`: discovery is a folder walk, so a run
rooted at the workspace covers every package below it, each in its own test environment, and
narrowing to one member is what avoids activating and precompiling the rest.
"""
function _package_filter(kwargs)
included = _package_names(kwargs, :packages)
excluded = _package_names(kwargs, Symbol("exclude-packages"))
included === nothing && excluded === nothing && return nothing, nothing

predicate = function (item)
included === nothing || item.package_name in included || return false
return excluded === nothing || !(item.package_name in excluded)
end

parts = String[]
included === nothing || push!(parts, "packages $(join(sort!(collect(included)), ", "))")
excluded === nothing || push!(parts, "not packages $(join(sort!(collect(excluded)), ", "))")
return predicate, join(parts, " and ")
end

# ── Commands ──────────────────────────────────────────────────────────

"""
Expand Down Expand Up @@ -712,6 +755,8 @@ function _print_test_commands()
printstyled("\n Run flags:\n"; bold=true)
println(" --name=<pattern> Filter by test item name (substring, case-insensitive)")
println(" --tags=t1,t2 Filter by tags")
println(" --packages=A,B Only test items of these packages (monorepo/workspace)")
println(" --exclude-packages=C Skip these packages' test items")
println(" --workers=N Max parallel workers (default: shared across active runs)")
println(" --timeout=S|none Per-test-item timeout in seconds (default: none)")
println(" --coverage Enable coverage")
Expand Down Expand Up @@ -755,11 +800,24 @@ function cmd_list(args)
nothing
end

# Same selection as `test run`, so listing and running agree about what `--packages`
# means in a monorepo.
pkg_filter = try
first(_package_filter(kwargs))
catch e
printstyled("Error: "; color=:red, bold=true)
println(e.msg)
return nothing
end

count = 0
for item in d
if tag_filter !== nothing && isempty(intersect(Set(item.tags), tag_filter))
continue
end
if pkg_filter !== nothing && !pkg_filter(item)
continue
end
tags_str = isempty(item.tags) ? "" : " [$(join(item.tags, ", "))]"
printstyled(" $(item.name)"; bold=true)
print(" $(item.filename):$(item.line)")
Expand Down Expand Up @@ -1110,19 +1168,25 @@ function _build_run_kwargs(args; return_results=false, juliaup_channel::Union{No
nothing
end

if tag_filter !== nothing || name_filter !== nothing
pkg_filter, pkg_description = _package_filter(kwargs)

if tag_filter !== nothing || name_filter !== nothing || pkg_filter !== nothing
run_kwargs[:filter] = function(info)
if name_filter !== nothing && !contains(lowercase(string(info.name)), lowercase(name_filter))
return false
end
if tag_filter !== nothing && isempty(intersect(Set(info.tags), tag_filter))
return false
end
if pkg_filter !== nothing && !pkg_filter(info)
return false
end
return true
end
parts = String[]
name_filter !== nothing && push!(parts, "name \"$name_filter\"")
tag_filter !== nothing && push!(parts, "tags $(join(sort!(string.(collect(tag_filter))), ", "))")
pkg_description === nothing || push!(parts, pkg_description)
run_kwargs[:filter_description] = join(parts, " and ")
end

Expand Down Expand Up @@ -2198,7 +2262,8 @@ struct DevREPLCompletionProvider <: REPL.LineEdit.CompletionProvider end
const _TOP_COMMANDS = ["test", "lint", "format", "help"]
const _TEST_SUBCOMMANDS = ["run", "pick", "failed", "repeat", "list", "results",
"failures", "history", "status", "attach", "cancel", "procs", "kill", "log"]
const _TEST_RUN_FLAGS = ["--name=", "--tags=", "--workers=", "--timeout=", "--coverage", "--bg"]
const _TEST_RUN_FLAGS = ["--name=", "--tags=", "--packages=", "--exclude-packages=",
"--workers=", "--timeout=", "--coverage", "--bg"]
const _RESULTS_FLAGS = ["--name=", "--verbose", "--output"]

function _juliaup_channel_names()
Expand Down Expand Up @@ -2257,7 +2322,7 @@ function _devrepl_completions(partial::AbstractString)
elseif sub == "history"
cands = ["--active"]
elseif sub in ("list", "ls", "pick")
cands = startswith(cur, "--") ? ["--tags="] : _complete_dirs(cur)
cands = startswith(cur, "--") ? ["--tags=", "--packages=", "--exclude-packages="] : _complete_dirs(cur)
else
cands = String[]
end
Expand Down
6 changes: 5 additions & 1 deletion test/test_completion.jl
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ end

@testitem "flag completions" begin
m, _ = DevREPL._devrepl_completions("test run --")
@test Set(m) == Set(["--name=", "--tags=", "--workers=", "--timeout=", "--coverage", "--bg"])
@test Set(m) == Set(["--name=", "--tags=", "--packages=", "--exclude-packages=",
"--workers=", "--timeout=", "--coverage", "--bg"])

m, _ = DevREPL._devrepl_completions("test list --")
@test Set(m) == Set(["--tags=", "--packages=", "--exclude-packages="])

m, _ = DevREPL._devrepl_completions("test results --")
@test Set(m) == Set(["--name=", "--verbose", "--output"])
Expand Down
79 changes: 79 additions & 0 deletions test/test_parser.jl
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,85 @@ end
@test occursin("1 tests ran", out)
end

# Discovery is a folder walk, so `test` at the root of a monorepo or a Pkg `[workspace]`
# covers every package below it. `--packages` is how a run is narrowed back to one member
# without paying to activate and precompile the others.
@testitem "--packages and --exclude-packages select by package" begin
item(pkg) = (filename="f.jl", name="n", tags=Symbol[], package_name=pkg)

@test DevREPL._package_filter(Dict{Symbol,String}()) == (nothing, nothing)

include_only, description = DevREPL._package_filter(Dict(:packages => "A,B"))
@test include_only(item("A"))
@test include_only(item("B"))
@test !include_only(item("C"))
# Package names are case sensitive, and an item belonging to no package is not "A".
@test !include_only(item("a"))
@test !include_only(item(""))
@test occursin("packages A, B", description)

exclude_only, description = DevREPL._package_filter(Dict(Symbol("exclude-packages") => "C"))
@test exclude_only(item("A"))
@test !exclude_only(item("C"))
# Without --packages, an item belonging to no package still runs.
@test exclude_only(item(""))
@test occursin("not packages C", description)

# Include first, then exclude.
both, _ = DevREPL._package_filter(Dict(:packages => "A,B", Symbol("exclude-packages") => "B"))
@test both(item("A"))
@test !both(item("B"))
@test !both(item("C"))

# An empty entry is a typo, not "no package".
@test_throws ArgumentError DevREPL._package_filter(Dict(:packages => "A,,B"))
@test_throws ArgumentError DevREPL._package_filter(Dict(:packages => ""))
@test_throws ArgumentError DevREPL._package_filter(Dict(Symbol("exclude-packages") => "A,"))
end

@testitem "_build_run_kwargs composes the package selection with the other filters" begin
item(; name="n", pkg="A", tags=Symbol[]) = (filename="f.jl", name=name, tags=tags, package_name=pkg)

_, run_kwargs = DevREPL._build_run_kwargs(String["--packages=A"])
@test run_kwargs[:filter](item(pkg="A"))
@test !run_kwargs[:filter](item(pkg="B"))
@test occursin("packages A", run_kwargs[:filter_description])

# Every given criterion has to hold, and the description names all of them.
_, run_kwargs = DevREPL._build_run_kwargs(String["--packages=A", "--name=slow"])
@test run_kwargs[:filter](item(name="slow one", pkg="A"))
@test !run_kwargs[:filter](item(name="slow one", pkg="B"))
@test !run_kwargs[:filter](item(name="fast one", pkg="A"))
@test occursin("name \"slow\"", run_kwargs[:filter_description])
@test occursin("packages A", run_kwargs[:filter_description])

# Absent the flags, no filter is installed at all.
_, run_kwargs = DevREPL._build_run_kwargs(String[])
@test !haskey(run_kwargs, :filter)
end

@testitem "--packages reaches a real run" setup=[ReplHelper] begin
out = ReplHelper.run_command("test run $(ReplHelper.PRECOMPILEDATA) --packages=PrecompileData --name=pass")
@test occursin("1 tests ran", out)

# A package that is not there selects nothing, and says so rather than looking like a
# clean run of an empty suite.
out = ReplHelper.run_command("test run $(ReplHelper.PRECOMPILEDATA) --packages=NoSuchPackage")
@test occursin("No test item matched the filter", out)
@test occursin("packages NoSuchPackage", out)
end

@testitem "test list honours --packages" setup=[ReplHelper] begin
out = ReplHelper.run_command("test list $(ReplHelper.PRECOMPILEDATA) --packages=PrecompileData")
@test occursin("precompile pass", out)

out = ReplHelper.run_command("test list $(ReplHelper.PRECOMPILEDATA) --packages=NoSuchPackage")
@test occursin("No test items found", out)

out = ReplHelper.run_command("test list $(ReplHelper.PRECOMPILEDATA) --packages=A,,B")
@test occursin("empty package name", out)
end

# The timeout is opt-in: how long a test item legitimately takes is not something
# DevREPL can know, and a fired timeout kills the test process and errors the item.
@testitem "--timeout is opt-in and accepts an opt-out spelling" begin
Expand Down
Loading