feat(run): pytest run configuration type (#274)

Add Configuration had no pytest template although detected and implicit pytest configurations already used the pytest kind. A pytest type now offers Command, Runner arguments, Target and pytest options, and opens existing pytest configurations in that form: pytest itself, `uv/poetry run pytest`, `python -m pytest` (also behind an interpreter prefix such as `uv run python -m pytest`), venv `bin/pytest` and Windows `Scripts\\pytest.exe`. Fields are translated.

Fixes #266

Co-authored-by: a-rookie-of-C-language <[email protected]>
This commit is contained in:
arookieofcanda-rookie-of-C-language authored and GitHub committed 2026-10-29 23:25:07 +08:00
1 parent 5217f8570b
commit 1602e4b530
4 files changed
+164 -1

No files matched your search

+82
View File
@@ -16,6 +16,7 @@ import { typeRows } from "./AddConfigPopup";
import { runBeforeLaunch } from "./beforeLaunch";
import { buildRows } from "./ConfigChooser";
import { compoundChildren, compoundCycle, formatEnv, parseEnv, validateConfig } from "./configs";
import { contextConfig } from "./context";
import { EditConfigsOverlay, moveConfig, treeRows } from "./EditConfigsDialog";
import { pasteRows } from "./EnvDialog";
import { CURRENT_FILE, resetRuns, selectConfig, sessionsOf, startCompound, startRun, useRun } from "./store";
@@ -101,6 +102,87 @@ describe("configuration types", () => {
expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull();
});
it("pytest round-trips commandLine / paramsFrom and opens implicit context configs in the form", () => {
const pytest = typeFor("pytest")!;
expect(pytest.debugAdapter).toBe("debugpy");
expect(newConfigOfType(pytest, [])).toMatchObject({
kind: "pytest",
command: "pytest",
args: [],
debugAdapter: "debugpy",
params: { command: "pytest" },
});
const cases: Array<{
command: string;
args: string[];
params: { command: string; runnerArgs: string; target: string; options: string };
}> = [
{
command: "pytest",
args: ["tests", "-x"],
params: { command: "pytest", runnerArgs: "", target: "tests", options: "-x" },
},
{
command: "uv",
args: ["run", "pytest", "tests/test_a.py::test_x", "-q"],
params: { command: "uv", runnerArgs: "run pytest", target: "tests/test_a.py::test_x", options: "-q" },
},
{
command: "python",
args: ["-m", "pytest"],
params: { command: "python", runnerArgs: "-m pytest", target: "", options: "" },
},
{
command: ".venv/bin/pytest",
args: [],
params: { command: ".venv/bin/pytest", runnerArgs: "", target: "", options: "" },
},
{
command: ".venv\\Scripts\\pytest.exe",
args: [],
params: { command: ".venv\\Scripts\\pytest.exe", runnerArgs: "", target: "", options: "" },
},
];
for (const { command, args, params } of cases) {
const c = cfg("pytest", { kind: "pytest", command, args, detected: true });
expect(pytest.paramsFrom!(c)).toEqual(params);
expect(formParams(c)).toEqual(params);
expect(pytest.commandLine(params)).toEqual({ command, args });
expect(withParams(c, params)).toMatchObject({ command, args });
}
const implicit = contextConfig("worker/test_worker.py", [])!;
expect(implicit).toMatchObject({
kind: "pytest",
command: "python3",
args: ["-m", "pytest", "worker/test_worker.py"],
});
expect(formParams(implicit)).toEqual({
command: "python3",
runnerArgs: "-m pytest",
target: "worker/test_worker.py",
options: "",
});
const uvPy = [cfg("py", { kind: "python", command: "uv", args: ["run", "python", "-m", "app"] })];
const uvImplicit = contextConfig("worker/test_worker.py", uvPy)!;
expect(uvImplicit).toMatchObject({
kind: "pytest",
command: "uv",
args: ["run", "python", "-m", "pytest", "worker/test_worker.py"],
});
expect(formParams(uvImplicit)).toEqual({
command: "uv",
runnerArgs: "run python -m pytest",
target: "worker/test_worker.py",
options: "",
});
expect(pytest.paramsFrom!(cfg("pytest", { kind: "pytest", command: "tox", args: [] }))).toBeNull();
});
it("shell script files and text derive the command line", () => {
const c = cfg("s");
expect(
+70
View File
@@ -296,6 +296,75 @@ const compound: RunConfigTypeContribution = {
commandLine: () => null,
};
/**
* pytest: what comes after the `pytest` invocation — a target (directory, file,
* test id) and options. The command itself is one word (`pytest`, `uv`,
* `.venv/bin/pytest`, `python`), the runner args its leading arguments
* (`run pytest`, `-m pytest`, none).
*/
const pytest: RunConfigTypeContribution = {
id: "pytest",
label: "pytest",
icon: "file-python",
description: "Run pytest tests",
defaults: { command: "pytest" },
debugAdapter: "debugpy",
fields: [
{
key: "command",
label: "Command",
mono: true,
placeholder: "pytest",
required: true,
hint: "e.g. pytest, uv, poetry, .venv/bin/pytest, python",
},
{
key: "runnerArgs",
label: "Runner arguments",
mono: true,
placeholder: "run pytest",
hint: "e.g. `run pytest` for uv/poetry, `-m pytest` for python; empty calls the command itself",
},
{
key: "target",
label: "Target",
mono: true,
placeholder: "tests",
hint: "Directory, file or test id; empty runs the whole suite",
},
{ key: "options", label: "pytest options", mono: true, placeholder: "e.g. -x -q --tb=short" },
],
commandLine(p) {
const args = parseArgs(p.runnerArgs ?? "");
if (p.target?.trim()) args.push(...parseArgs(p.target.trim()));
args.push(...parseArgs(p.options ?? ""));
return { command: p.command?.trim() || "pytest", args };
},
paramsFrom(c) {
if (c.kind !== "pytest") return null;
// The runner part (`run pytest`, `-m pytest`, none when the command is
// pytest itself); what follows is the target and pytest options.
const m = c.args.indexOf("-m");
const runner =
c.args[0] === "run" && c.args[1] === "pytest"
? 2
: m >= 0 && c.args[m + 1] === "pytest"
? m + 2
: baseName(c.command).replace(/\.exe$/i, "") === "pytest"
? 0
: null;
if (runner === null) return null;
const rest = c.args.slice(runner);
const [target, ...options] = rest;
return {
command: c.command,
runnerArgs: formatArgs(c.args.slice(0, runner)),
target: target && !target.startsWith("-") ? formatArgs([target]) : "",
options: formatArgs(target && !target.startsWith("-") ? options : rest),
};
},
};
export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [
shell,
program,
@@ -303,6 +372,7 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [
gradle,
maven,
godot,
pytest,
applicationType,
springBootType,
compound,
+9 -1
View File
@@ -4747,5 +4747,13 @@
"Cannot delete agent tasks": "无法删除 Agent 任务",
"Cannot archive agent tasks": "无法归档 Agent 任务",
"Archive ({count})": "归档({count})",
"Delete ({count})…": "删除({count})…"
"Delete ({count})…": "删除({count})…",
"Run pytest tests": "运行 pytest 测试",
"e.g. pytest, uv, poetry, .venv/bin/pytest, python": "例如 pytest、uv、poetry、.venv/bin/pytest、python",
"Runner arguments": "运行器实参",
"e.g. `run pytest` for uv/poetry, `-m pytest` for python; empty calls the command itself": "例如 uv/poetry 使用 `run pytest`,python 使用 `-m pytest`;留空则直接调用命令本身",
"Target": "目标",
"Directory, file or test id; empty runs the whole suite": "目录、文件或测试 ID;留空则运行整个测试套件",
"pytest options": "pytest 选项",
"e.g. -x -q --tb=short": "例如 -x -q --tb=short"
}
+3
View File
@@ -128,6 +128,7 @@ const UNTRANSLATED = new Set<string>([
"Spring Boot",
"PostgreSQL",
"Pyright",
"pytest",
"Rust",
"Rustfmt",
"SingleStore",
@@ -195,7 +196,9 @@ const UNTRANSLATED = new Set<string>([
"PascalCase",
"pyproject.toml, .git",
"ruff server",
"run pytest",
"snake_case",
"tests",
'{ "section": { "key": "value" } }',
// Identifiers that only look like text: icon names, error codes, categories, console output
"app",