diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index 0531328d8..3b1a2130a 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -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( diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 98527960d..db5b01108 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -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, diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 1569f5aca..e45329350 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index 9daa99dc1..10c0ea509 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -128,6 +128,7 @@ const UNTRANSLATED = new Set([ "Spring Boot", "PostgreSQL", "Pyright", + "pytest", "Rust", "Rustfmt", "SingleStore", @@ -195,7 +196,9 @@ const UNTRANSLATED = new Set([ "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",