From d939a197470a7cead514c5aa78d41fa099e1abe0 Mon Sep 17 00:00:00 2001 From: Andrea Basile Date: Mon, 2 Nov 2026 15:49:04 +0100 Subject: [PATCH] feat(run): Django test configurations with results in the test tree (#869) Django projects get a detected `manage.py test` configuration next to runserver and a Django tests configuration type (command, runner arguments, target, settings module, options). Results stream into the test tree with failures, errors, skips and subtests, failed tests navigate to their source line, Rerun Failed Tests narrows the command to the failed labels, and single tests run from the gutter. Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ide-run/src/detect.rs | 13 + crates/ide-run/src/process.rs | 4 +- crates/ide-run/tests/detect.rs | 9 +- crates/ide-run/tests/process.rs | 7 +- crates/ide-sdk/src/apply.rs | 4 +- crates/ide-sdk/src/tests.rs | 7 + src/features/run/configs.test.ts | 24 ++ src/features/run/context.ts | 16 +- src/features/run/djangoTypes.ts | 121 +++++++ src/features/run/producers.test.ts | 50 +++ src/features/run/producers.ts | 14 +- src/features/run/runConfigTypes.test.tsx | 54 ++++ src/features/run/targets.test.ts | 39 +++ src/features/run/targets.ts | 49 ++- src/features/run/testing/index.ts | 28 +- src/features/run/testing/parsers.test.ts | 11 +- src/features/run/testing/rerun.test.ts | 45 +++ src/features/run/testing/rerun.ts | 7 + src/features/run/testing/states.test.ts | 13 + src/features/run/testing/states.ts | 13 +- src/features/run/testing/unittest.test.ts | 371 ++++++++++++++++++++++ src/features/run/testing/unittest.ts | 169 ++++++++++ src/features/run/types.ts | 4 +- src/platform/locales/text/zh.json | 10 +- src/platform/locales/textCatalog.test.ts | 3 + 25 files changed, 1049 insertions(+), 36 deletions(-) create mode 100644 src/features/run/djangoTypes.ts create mode 100644 src/features/run/testing/unittest.test.ts create mode 100644 src/features/run/testing/unittest.ts diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 82f365949..0f56ad500 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -890,6 +890,19 @@ fn detect_python(root: &Path, out: &mut Vec) { if uses_pytest(root) || env.has_tool(root, "pytest") { out.push(py_config("pytest".into(), "pytest".into(), "pytest", env.tool(root, "pytest"), &[]).test()); } + // After pytest: a project set up for pytest (pytest-django) keeps it as the default test runner. + if root.join("manage.py").is_file() { + out.push( + py_config( + "django:test".into(), + "manage.py test".into(), + "django-test", + env.python(), + &["manage.py", "test"], + ) + .test(), + ); + } } // ---------------------------------------------------------------- make / just diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 0808e9522..969114d23 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -317,7 +317,7 @@ impl ProcessManager { return Err(IdeError::invalid("input cannot be redirected from a file when emulating a terminal")); } let mut env = launch_env(root, config)?; - if matches!(config.kind.as_str(), "python" | "pytest") { + if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") { crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?; } let on_output: OutputFn = Arc::from(on_output); @@ -745,7 +745,7 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult { let paths: Vec = extras.env_files.iter().map(|f| resolve_cwd(root, Some(Path::new(f)))).collect(); crate::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))? }; - if matches!(config.kind.as_str(), "python" | "pytest") + if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") && !config.env.contains_key("PYTHONUNBUFFERED") && !vars.contains_key("PYTHONUNBUFFERED") { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 578019334..48db7c561 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -372,13 +372,19 @@ fn python_projects() { ("manage.py", ""), ]); let c = detect(dir.path()); - assert_eq!(ids(&c), ["django:runserver", "python:module:mypkg", "pytest"]); + // pytest stays ahead of `manage.py test`: the first test configuration is the default for context runs. + assert_eq!(ids(&c), ["django:runserver", "python:module:mypkg", "pytest", "django:test"]); let pytest = find(&c, "pytest"); assert_eq!(cmdline(pytest), "uv run pytest"); assert!(pytest.is_test); assert_eq!(pytest.debug_adapter.as_deref(), Some("debugpy")); assert_eq!(cmdline(find(&c, "python:module:mypkg")), "uv run python -m mypkg"); assert_eq!(cmdline(find(&c, "django:runserver")), "uv run python manage.py runserver"); + let django = find(&c, "django:test"); + assert_eq!(cmdline(django), "uv run python manage.py test"); + assert_eq!(django.kind, "django-test"); + assert!(django.is_test); + assert_eq!(django.debug_adapter.as_deref(), Some("debugpy")); let dir = project(&[("pytest.ini", "[pytest]\n"), ("poetry.lock", "")]); assert_eq!(cmdline(find(&detect(dir.path()), "pytest")), "poetry run pytest"); @@ -398,6 +404,7 @@ fn python_virtualenv() { let c = detect(dir.path()); assert_eq!(cmdline(find(&c, "pytest")), ".venv/bin/pytest"); assert_eq!(cmdline(find(&c, "django:runserver")), ".venv/bin/python manage.py runserver"); + assert_eq!(cmdline(find(&c, "django:test")), ".venv/bin/python manage.py test"); } #[cfg(unix)] diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index c4af2bcaa..176be79c9 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -447,7 +447,7 @@ fn build_tools_get_the_console_encoding_for_every_jvm() { #[test] fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { - for (kind, source_dir) in [("python", ""), ("pytest", "src")] { + for (kind, source_dir) in [("python", ""), ("pytest", "src"), ("django-test", "")] { let h = harness(); let root = h.root.path(); let source = root.join(source_dir); @@ -465,6 +465,11 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { let mut config = cfg("python-imports", "python3", &["app/main.py"]); config.kind = kind.into(); config.extras.env_files = vec![".env".into()]; + // Unbuffered, so output printed by a test stays next to its result line. + assert_eq!( + ide_run::resolved_env(root, &config).unwrap().get("PYTHONUNBUFFERED").map(String::as_str), + Some("1") + ); let options = StartOptions { python_paths: vec![root.to_owned(), source], ..Default::default() }; let chunks: Chunks = Arc::default(); let sink = chunks.clone(); diff --git a/crates/ide-sdk/src/apply.rs b/crates/ide-sdk/src/apply.rs index 8c484f9fd..ec173c954 100644 --- a/crates/ide-sdk/src/apply.rs +++ b/crates/ide-sdk/src/apply.rs @@ -629,10 +629,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings resolve_run_command(c, root, s); let mut path_dirs: Vec = Vec::new(); match c.kind.as_str() { - "python" | "pytest" => { + "python" | "pytest" | "django-test" => { if let Some(py) = &explicit_python { let runner = PY_RUNNERS.contains(&c.command.as_str()); - if c.kind == "python" && (runner || is_python_command(&c.command)) { + if (c.kind == "python" || c.kind == "django-test") && (runner || is_python_command(&c.command)) { if runner && c.args.first().map(String::as_str) == Some("run") { // `uv run python …` → ` …` let skip = if c.args.get(1).is_some_and(|a| is_python_command(a)) { 2 } else { 1 }; diff --git a/crates/ide-sdk/src/tests.rs b/crates/ide-sdk/src/tests.rs index 3e121f241..e777b10b1 100644 --- a/crates/ide-sdk/src/tests.rs +++ b/crates/ide-sdk/src/tests.rs @@ -152,6 +152,8 @@ fn run_configs_use_selected_interpreter_and_package_manager() { run("pytest", "uv", &["run", "pytest", "-x"]), run("npm", "npm", &["run", "dev"]), run("shell", "python3", &["x.py"]), + run("django-test", "uv", &["run", "python", "manage.py", "test"]), + run("django-test", ".venv/bin/python", &["manage.py", "test", "polls"]), ]; apply_to_run_configs(&mut configs, tmp.path(), &s); assert_eq!(configs[0].command, "/opt/py/bin/python3"); @@ -161,6 +163,11 @@ fn run_configs_use_selected_interpreter_and_package_manager() { assert_eq!(configs[2].args, ["-m", "pytest", "-x"]); assert_eq!(configs[3].command, "pnpm"); assert_eq!(configs[4].command, "python3", "shell configs are left alone"); + // Django tests run on the selected interpreter, like `manage.py runserver`. + assert_eq!(configs[5].command, "/opt/py/bin/python3"); + assert_eq!(configs[5].args, ["manage.py", "test"]); + assert_eq!(configs[6].command, "/opt/py/bin/python3"); + assert_eq!(configs[6].args, ["manage.py", "test", "polls"]); } fn fake_jdk(dir: &Path) { diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 0f61d66c2..ce70a443f 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -158,6 +158,9 @@ describe("context configuration", () => { expect(fileRole("src/a.test.ts")).toBe("test"); expect(fileRole("pkg/x_test.go")).toBe("test"); expect(fileRole("tests/test_api.py")).toBe("test"); + // Django's default test module (pattern `test*.py`); a script outside Django projects. + expect(fileRole("polls/tests.py", undefined, false, true)).toBe("test"); + expect(fileRole("polls/tests.py")).toBe("script"); expect(fileRole("src/test/kotlin/OrderControllerTest.kt")).toBe("test"); expect(fileRole("tests/integration.rs")).toBe("test"); expect(fileRole("src/main.rs")).toBe("main"); @@ -185,6 +188,27 @@ describe("context configuration", () => { expect(contextConfig("internal/calc/calc_test.go", configs)!.args).toEqual(["test", "./internal/calc"]); expect(contextConfig("tests/api.rs", configs)!.args).toEqual(["test", "--test", "api"]); expect(contextConfig("tests/test_math.py", configs)!.args).toEqual(["run", "pytest", "tests/test_math.py"]); + // `tests.py` is a test module in Django projects only. + expect(contextConfig("polls/tests.py", configs)).toMatchObject({ + kind: "python", + isTest: false, + command: ".venv/bin/python", + args: ["polls/tests.py"], + }); + const django = cfg({ + id: "django:test", + kind: "django-test", + command: "uv", + args: ["run", "python", "manage.py", "test"], + isTest: true, + }); + expect(contextConfig("polls/tests.py", [django])!.args).toEqual([ + "run", + "python", + "manage.py", + "test", + "polls.tests", + ]); expect(contextConfig("src/test/kotlin/OrderControllerTest.kt", configs)!.args).toEqual([ "test", "--tests", diff --git a/src/features/run/context.ts b/src/features/run/context.ts index acab57def..768e3c017 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -4,17 +4,20 @@ * direct interpreter invocation for main files. Best effort, IDEA-style. */ import type { RunConfig } from "@/platform/ipc"; +import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes"; import { goTestParts } from "./goArguments"; import { isWindows } from "./jvmTypes"; export type FileRole = "test" | "main" | "script" | null; -export function fileRole(relPath: string, content?: string, windows = isWindows()): FileRole { +/** `django`: a Django project, whose default test module is `tests.py` (elsewhere a script). */ +export function fileRole(relPath: string, content?: string, windows = isWindows(), django = false): FileRole { const name = relPath.split("/").pop() ?? relPath; if ( /\.(test|spec)\.[cm]?[jt]sx?$/.test(name) || /(^|\/)__tests__\//.test(relPath) || /^test_.*\.py$|_test\.py$/.test(name) || + (django && name === "tests.py") || /_test\.go$/.test(name) || /(Test|Tests|Spec|IT)\.(kt|java|scala|groovy)$/.test(name) || (/(^|\/)tests\/[^/]+\.rs$/.test(relPath) && name !== "mod.rs") @@ -35,7 +38,7 @@ const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, ""); /** Kinds whose test configs can run a file of this extension. */ function testKinds(relPath: string): string[] { if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"]; - if (/\.py$/.test(relPath)) return ["pytest", "python"]; + if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"]; if (/\.go$/.test(relPath)) return ["go"]; if (/\.rs$/.test(relPath)) return ["cargo"]; if (/\.(kt|java|scala|groovy)$/.test(relPath)) return ["gradle", "maven"]; @@ -61,7 +64,8 @@ function closest(configs: RunConfig[], relPath: string): RunConfig | undefined { return allClosest(configs, relPath)[0]; } -function relToCwd(relPath: string, config: RunConfig): string { +/** `relPath` relative to the working directory of `config`. */ +export function relToCwd(relPath: string, config: RunConfig): string { const cwd = (config.kind === "go" ? (config.params?.module ?? config.cwd ?? "") : (config.cwd ?? "")) .replace(/^\.\/?/, "") .replace(/\/+$/, ""); @@ -156,6 +160,10 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC case "pytest": case "python": return derived(base, relPath, { args: [...base.args, file] }); + case "django-test": { + const tests = djangoTestsOf(base, [djangoLabel(file)]); + return tests ? derived(base, relPath, tests) : null; + } case "go": { const dir = dirOf(file); const target = dir ? `./${dir}` : "."; @@ -248,7 +256,7 @@ export function contextConfig( content?: string, windows = isWindows(), ): RunConfig | null { - const role = fileRole(relPath, content, windows); + const role = fileRole(relPath, content, windows, hasDjangoTests(configs)); if (role === "test") return testConfigFor(relPath, configs); if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows); if (role === "script") return scriptConfigFor(relPath, configs, windows); diff --git a/src/features/run/djangoTypes.ts b/src/features/run/djangoTypes.ts new file mode 100644 index 000000000..f5817ead1 --- /dev/null +++ b/src/features/run/djangoTypes.ts @@ -0,0 +1,121 @@ +/** + * Django tests (PyCharm's "Django tests" configuration): `manage.py test` with a + * target (app labels, modules or dotted test paths), a settings module and + * further options. The command is the interpreter (`python3`, `.venv/bin/python`, + * `uv`), the runner args its leading arguments (`run python` for uv / poetry). + */ +import type { RunConfig } from "@/platform/ipc"; +import type { RunConfigTypeContribution } from "@/workbench/contributions"; +import { formatArgs, parseArgs } from "./configs"; + +/** A Django project: it has a Django tests configuration (detected from `manage.py`, or made in the form). */ +export const hasDjangoTests = (configs: readonly RunConfig[]) => configs.some((c) => c.kind === "django-test"); + +/** Index of `manage.py` when `test` follows it, else -1. */ +function manageTest(args: readonly string[]): number { + return args.findIndex((a, i) => /(^|[/\\])manage\.py$/.test(a) && args[i + 1] === "test"); +} + +/** Number of leading positional arguments (the test labels). */ +const labelCount = (args: readonly string[]) => { + const n = args.findIndex((a) => a.startsWith("-")); + return n < 0 ? args.length : n; +}; + +/** Dotted test label of a module file: `polls/tests.py` → `polls.tests`, `polls/tests/__init__.py` → `polls.tests`. */ +export function djangoLabel(relPath: string): string { + return relPath + .replace(/\.py$/, "") + .replace(/(^|\/)__init__$/, "") + .replace(/\//g, "."); +} + +/** + * `args` of a `manage.py test` command line running `labels` in place of its + * own target (the labels right after `test`); null for other command lines. + */ +function withDjangoLabels(args: readonly string[], labels: readonly string[]): string[] | null { + const at = manageTest(args); + if (at < 0) return null; + const rest = args.slice(at + 2); + return [...args.slice(0, at + 2), ...labels, ...rest.slice(labelCount(rest))]; +} + +/** + * The command line (and, for a configuration made in the form, the Target field) + * of `config` running `labels`; null when it doesn't run `manage.py test`. + */ +export function djangoTestsOf(config: RunConfig, labels: readonly string[]): Pick | null { + const args = withDjangoLabels(config.args, labels); + if (!args) return null; + return config.params?.target === undefined + ? { args } + : { args, params: { ...config.params, target: formatArgs([...labels]) } }; +} + +export const djangoTestType: RunConfigTypeContribution = { + id: "django-test", + label: "Django tests", + icon: "file-python", + description: "Run Django tests with manage.py test", + defaults: { command: "python3" }, + debugAdapter: "debugpy", + fields: [ + { + key: "command", + label: "Command", + mono: true, + placeholder: "python3", + required: true, + hint: "e.g. python3, .venv/bin/python, uv, poetry", + }, + { + key: "runnerArgs", + label: "Runner arguments", + mono: true, + placeholder: "run python", + hint: "e.g. `run python` for uv/poetry; empty calls the interpreter itself", + }, + { + key: "target", + label: "Target", + mono: true, + placeholder: "polls.tests", + hint: "App labels, modules or dotted test paths; empty runs every test", + }, + { + key: "settings", + label: "Settings module", + mono: true, + placeholder: "mysite.settings_test", + hint: "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py", + }, + { key: "options", label: "Options", mono: true, placeholder: "e.g. --keepdb --parallel" }, + ], + commandLine(p) { + const args = [...parseArgs(p.runnerArgs ?? ""), "manage.py", "test", ...parseArgs(p.target ?? "")]; + if (p.settings?.trim()) args.push("--settings", p.settings.trim()); + args.push(...parseArgs(p.options ?? "")); + return { command: p.command?.trim() || "python3", args }; + }, + paramsFrom(c) { + if (c.kind !== "django-test") return null; + const at = manageTest(c.args); + if (at < 0) return null; + const rest = c.args.slice(at + 2); + const n = labelCount(rest); + let options = rest.slice(n); + let settings = ""; + if (options[0] === "--settings" && options[1] !== undefined) { + settings = options[1]; + options = options.slice(2); + } + return { + command: c.command, + runnerArgs: formatArgs(c.args.slice(0, at)), + target: formatArgs(rest.slice(0, n)), + settings, + options: formatArgs(options), + }; + }, +}; diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 64a102519..2fb9cb445 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -498,6 +498,56 @@ describe("project producers", () => { expect(produceContextChoices({ ...input, configs }, ALL)).toHaveLength(1); }); + it("django: tests.py, TestCase classes and test packages run through manage.py test", () => { + const django = [ + cfg({ id: "django:runserver", kind: "python", command: ".venv/bin/python", args: ["manage.py", "runserver"] }), + cfg({ + id: "django:test", + kind: "django-test", + command: ".venv/bin/python", + args: ["manage.py", "test", "--keepdb"], + isTest: true, + }), + ]; + const src = [ + "from django.test import TestCase", + "", + "class QuestionTests(TestCase):", + " def test_recent(self):", + " pass", + ].join("\n"); + const input = { path: `${ROOT}/polls/tests.py`, root: ROOT, configs: django, content: src }; + expect(produceContext({ ...input, line: 4 }, ALL)).toMatchObject({ + name: "QuestionTests.test_recent", + config: { kind: "django-test", args: ["manage.py", "test", "polls.tests.QuestionTests.test_recent", "--keepdb"] }, + }); + expect(produceContext({ ...input, line: 2 }, ALL)!.config!.args).toEqual([ + "manage.py", + "test", + "polls.tests.QuestionTests", + "--keepdb", + ]); + expect(produceContext(input, ALL)).toMatchObject({ + name: "tests.py", + config: { args: ["manage.py", "test", "polls.tests", "--keepdb"] }, + }); + expect(produceDirectory({ path: `${ROOT}/polls/tests`, root: ROOT, configs: django })).toMatchObject({ + name: "Tests in 'tests'", + config: { args: ["manage.py", "test", "polls.tests", "--keepdb"] }, + }); + expect(produceDirectory({ path: `${ROOT}/polls/migrations`, root: ROOT, configs: django })).toBeNull(); + // Outside Django projects `tests.py` is a script: no test targets, and the file runs as one. + const plain = { ...input, configs: [configs[1]!] }; + expect(fileTargets(plain, src)).toEqual([]); + expect(produceContext(plain, ALL)).toMatchObject({ name: "tests.py", config: { kind: "python", isTest: false } }); + // pytest detected as well (pytest-django): the user chooses, pytest first. + const both = [configs[1]!, ...django]; + expect(produceContextChoices({ ...input, configs: both, line: 4 }, ALL).map((c) => c.detail)).toEqual([ + "pytest", + "django:test", + ]); + }); + it("python: test function > test class > file; __main__ runs the file", () => { const src = [ "class TestMath:", diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 6a784cad4..c0425628f 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -17,6 +17,7 @@ import { formatArgs } from "./configs"; import { goGenerateConfig } from "./goGenerate"; import { goTestParts } from "./goArguments"; import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context"; +import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes"; import { isWindows } from "./jvmTypes"; import { detectRunTargets, targetConfig, targetLanguage, type RunTarget } from "./targets"; import { tx } from "@/platform/i18n"; @@ -590,7 +591,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null } if (/\.t?scn$/i.test(rel)) return godotScene(rel, configs); // Project-aware: test files through the project's test runner, Cargo / Go / Gradle / Maven mains. - const role = fileRole(rel, input.content); + const role = fileRole(rel, input.content, undefined, hasDjangoTests(configs)); if (role === "test") { const found = testConfigFor(rel, configs); const test = found && goTestNamed(found, input); @@ -823,7 +824,7 @@ export function fileTargets(input: Pick t.kind === "main" && (!t.className || /\.(java|kt)$/.test(input.path)), ); } - return detectRunTargets(content, s.rel); + return detectRunTargets(content, s.rel, hasDjangoTests(input.configs)); } function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget): Produced | null { @@ -969,10 +970,19 @@ export function produceDirectory(input: ContextInput): Produced | null { } const js = closest(["npm", "bun", "deno"]); const py = closest(["pytest"]); + const django = closest(["django-test"]); const go = closest(["go"]); const cargo = closest(["cargo"]); if (cargo && /(^|\/)tests$/.test(rel) && !js && !py) return make(cargo, "All Tests", [...cargo.args, "--tests"]); if (py && (!js || /(^|\/)tests?$/.test(rel))) return make(py, testsIn, [...py.args, relToCwd(py)]); + // Django runs a test package by its dotted label (`polls/tests` → `polls.tests`). + if (django && !js && relToCwd(django)) { + const tests = djangoTestsOf(django, [djangoLabel(relToCwd(django))]); + if (tests) { + const produced = make(django, testsIn, tests.args); + return { ...produced, config: { ...produced.config!, ...tests } }; + } + } if (js) { const dir = relToCwd(js); if (js.kind === "deno") return make(js, testsIn, [...js.args, dir]); diff --git a/src/features/run/runConfigTypes.test.tsx b/src/features/run/runConfigTypes.test.tsx index d37509ca5..fd207b13f 100644 --- a/src/features/run/runConfigTypes.test.tsx +++ b/src/features/run/runConfigTypes.test.tsx @@ -172,6 +172,60 @@ describe("configuration types", () => { expect(godot.paramsFrom!(cfg("x", { command: "cargo" }))).toBeNull(); }); + it("Django tests round-trip commandLine / paramsFrom and always report to the test tree", () => { + const django = typeFor("django-test")!; + expect(django.debugAdapter).toBe("debugpy"); + expect(newConfigOfType(django, [])).toMatchObject({ + kind: "django-test", + command: "python3", + args: ["manage.py", "test"], + env: {}, + isTest: true, + debugAdapter: "debugpy", + }); + // No settings module: neither `--settings` nor DJANGO_SETTINGS_MODULE, manage.py's default applies. + expect(django.commandLine({ command: "python3", settings: " " })).toEqual({ + command: "python3", + args: ["manage.py", "test"], + }); + + const cases: Array<{ command: string; args: string[]; params: Record }> = [ + { + command: ".venv/bin/python", + args: ["manage.py", "test"], + params: { command: ".venv/bin/python", runnerArgs: "", target: "", settings: "", options: "" }, + }, + { + command: "uv", + args: ["run", "python", "manage.py", "test", "polls", "blog.tests.PostTests", "--settings", "mysite.test"], + params: { + command: "uv", + runnerArgs: "run python", + target: "polls blog.tests.PostTests", + settings: "mysite.test", + options: "", + }, + }, + { + command: "python3", + args: ["manage.py", "test", "--keepdb", "--parallel", "4"], + params: { command: "python3", runnerArgs: "", target: "", settings: "", options: "--keepdb --parallel 4" }, + }, + ]; + for (const { command, args, params } of cases) { + const c = cfg("django:test", { kind: "django-test", command, args, detected: true }); + expect(django.paramsFrom!(c)).toEqual(params); + expect(formParams(c)).toEqual(params); + expect(django.commandLine(params)).toEqual({ command, args }); + } + // Saved from the form, a plain command line becomes a test configuration. + expect(withParams(cfg("d", { kind: "django-test" }), cases[1]!.params).isTest).toBe(true); + // Not `manage.py test`: edited as a plain command. + expect( + django.paramsFrom!(cfg("d", { kind: "django-test", command: "python3", args: ["manage.py", "check"] })), + ).toBeNull(); + }); + it("pytest round-trips commandLine / paramsFrom and opens implicit context configs in the form", () => { const pytest = typeFor("pytest")!; expect(pytest.debugAdapter).toBe("debugpy"); diff --git a/src/features/run/targets.test.ts b/src/features/run/targets.test.ts index e0053b2e2..263b34a49 100644 --- a/src/features/run/targets.test.ts +++ b/src/features/run/targets.test.ts @@ -89,6 +89,24 @@ describe("detectRunTargets", () => { ]); }); + it("python: unittest TestCase subclasses in tests.py (Django's default test module)", () => { + const src = [ + "from django.test import TestCase", + "class QuestionTests(TestCase):", + " def test_recent(self):", + " pass", + "class Helper(Mixin):", + " def test_no(self): pass", + ].join("\n"); + expect(pick(detectRunTargets(src, "polls/tests.py", true))).toEqual([ + [1, "suite", "QuestionTests"], + [2, "test", "QuestionTests/test_recent"], + ]); + expect(pick(detectRunTargets(src, "polls/models.py", true))).toEqual([]); + // Outside Django projects `tests.py` is a script: no test targets. + expect(pick(detectRunTargets(src, "polls/tests.py"))).toEqual([]); + }); + it("go and jvm", () => { expect( pick(detectRunTargets("package x\n\nfunc TestAdd(t *testing.T) {}\nfunc helper() {}", "pkg/add_test.go")), @@ -160,6 +178,27 @@ describe("targetConfig", () => { expect(first("class TestC:\n def test_a(self): pass", "tests/test_c.py").args).toEqual([ "tests/test_c.py::TestC::test_a", ]); + // pytest also collects TestCase subclasses. + expect(first("class QTests(TestCase):\n def test_a(self): pass", "app/test_q.py").args).toEqual([ + "app/test_q.py::QTests::test_a", + ]); + const django = [cfg({ id: "django:test", kind: "django-test", command: "python3", args: ["manage.py", "test"] })]; + const test = detectRunTargets("class QTests(TestCase):\n def test_a(self): pass", "polls/tests.py", true)[1]!; + expect(targetConfig(test, "polls/tests.py", django)!.args).toEqual([ + "manage.py", + "test", + "polls.tests.QTests.test_a", + ]); + // A module-level function or a plain class: unittest's loader would call it, so no Django run. + const fn = detectRunTargets("def test_top():\n pass", "polls/tests.py", true)[0]!; + expect(targetConfig(fn, "polls/tests.py", django)).toBeNull(); + // Relative to the working directory, where manage.py is. + const nested = [{ ...django[0]!, cwd: "backend" }]; + expect(targetConfig(test, "backend/polls/tests/test_q.py", nested)!.args).toEqual([ + "manage.py", + "test", + "polls.tests.test_q.QTests.test_a", + ]); expect(first("func TestAdd(t *testing.T) {}", "pkg/add_test.go").args).toEqual([ "test", "./pkg", diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index 2d4efff4f..cb28903d4 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -1,7 +1,8 @@ /** * Run targets inside a file, for the gutter ▶ icons and "Run test" code lenses: * `fn main`, `#[test]` functions and `mod tests`, JS/TS `test`/`it`/`describe`, - * pytest `def test_*` / `class Test*`, Go `func TestX(t *testing.T)`, JUnit `@Test`. + * pytest `def test_*` / `class Test*`, unittest `TestCase` subclasses, Go `func TestX(t *testing.T)`, + * JUnit `@Test`. * Line-based regex detection (cheap, no parser), plus the run configuration for * one target derived from the project's detected test configurations. */ @@ -9,7 +10,8 @@ import { formatArgs } from "./configs"; import type { RunConfig } from "@/platform/ipc"; import { applicationConfig, jvmTestConfig } from "./application"; import { goTestParts, withoutGoSelection } from "./goArguments"; -import { contextConfig, testConfigFor } from "./context"; +import { contextConfig, relToCwd, testConfigFor } from "./context"; +import { djangoLabel, djangoTestsOf } from "./djangoTypes"; import { scanJvmTargets, simpleClassName } from "./jvmTargets"; import { escapeRegExp } from "./testing/rerun"; @@ -29,6 +31,8 @@ export interface RunTarget { className?: string; /** JVM: a `@SpringBootApplication` main class. */ spring?: boolean; + /** Python: a unittest `TestCase` subclass or one of its methods (what Django's runner can run). */ + unittest?: boolean; } /** Above this size we skip detection (generated / minified files). */ @@ -142,23 +146,33 @@ function jsTargets(lines: string[]): RunTarget[] { function pythonTargets(lines: string[]): RunTarget[] { const out: RunTarget[] = []; - const classes: Array<{ indent: number; name: string; test: boolean }> = []; + const classes: Array<{ indent: number; name: string; test: boolean; unittest: boolean }> = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]!; if (!line.trim() || /^\s*#/.test(line)) continue; const indent = indentOf(line); while (classes.length && classes[classes.length - 1]!.indent >= indent) classes.pop(); - const cls = /^\s*class\s+(\w+)/.exec(line); + const cls = /^\s*class\s+(\w+)\s*(?:\(([^)]*))?/.exec(line); if (cls) { - const test = /^Test/.test(cls[1]!) && classes.every((c) => c.test); - classes.push({ indent, name: cls[1]!, test }); - if (test) out.push({ line: i, kind: "suite", name: cls[1]!, path: classes.map((c) => c.name) }); + // pytest collects `Test*` classes; unittest (and Django) any `TestCase` subclass. + const unittest = /TestCase\b/.test(cls[2] ?? ""); + const test = (/^Test/.test(cls[1]!) || unittest) && classes.every((c) => c.test); + classes.push({ indent, name: cls[1]!, test, unittest }); + if (test) + out.push({ + line: i, + kind: "suite", + name: cls[1]!, + path: classes.map((c) => c.name), + ...(unittest ? { unittest } : {}), + }); continue; } const def = /^\s*(?:async\s+)?def\s+(test\w*)\s*\(/.exec(line); if (def && classes.every((c) => c.test)) { const path = [...classes.map((c) => c.name), def[1]!]; - out.push({ line: i, kind: "test", name: path.join("."), path }); + const unittest = classes.length > 0 && classes[classes.length - 1]!.unittest; + out.push({ line: i, kind: "test", name: path.join("."), path, ...(unittest ? { unittest } : {}) }); continue; } if (indent === 0 && /^if\s+__name__\s*==\s*['"]__main__['"]\s*:/.test(line)) @@ -298,8 +312,11 @@ function jvmTargets(lines: string[], relPath: string): RunTarget[] { return out; } -/** Run targets of a file (relative path picks the language). */ -export function detectRunTargets(text: string, relPath: string): RunTarget[] { +/** + * Run targets of a file (relative path picks the language). `django`: a Django project, where + * `tests.py` (Django's default test module) holds tests too. + */ +export function detectRunTargets(text: string, relPath: string, django = false): RunTarget[] { if (text.length > MAX_SCAN) return []; const lines = text.split("\n"); switch (targetLanguage(relPath)) { @@ -309,7 +326,10 @@ export function detectRunTargets(text: string, relPath: string): RunTarget[] { return /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)__tests__\//.test(relPath) ? jsTargets(lines) : []; case "python": return pythonTargets(lines).filter( - (t) => t.kind === "main" || /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath), + (t) => + t.kind === "main" || + /(^|\/)(test_[^/]*|[^/]*_test)\.py$|(^|\/)tests?\//.test(relPath) || + (django && /(^|\/)tests\.py$/.test(relPath)), ); case "go": return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath)); @@ -372,6 +392,13 @@ export function targetConfig( case "pytest": case "python": return named(withLast(file.args, (f) => `${f}::${target.path.join("::")}`)); + case "django-test": { + // unittest's loader would call a module-level `test_*` function (or a plain class) to build the suite. + if (!target.unittest) return null; + const label = [djangoLabel(relToCwd(relPath, file)), ...target.path].join("."); + const tests = djangoTestsOf(file, [label]); + return tests ? { ...named(tests.args), ...(tests.params ? { params: tests.params } : {}) } : null; + } case "go": { const pattern = `^${target.path.map(escapeRegExp).join("$/^")}$`; const benchmark = target.path[0]?.startsWith("Benchmark"); diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 93579630e..56fcc76cb 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -5,12 +5,13 @@ import { goParser } from "./go"; import { jsParser } from "./js"; import { jvmParser } from "./jvm"; import { pytestParser } from "./pytest"; +import { unittestParser } from "./unittest"; import type { TestEvent, TestOutputParser } from "./types"; export type { TestEvent, TestOutputParser, TestStatus } from "./types"; export { parseDuration } from "./types"; export { TestResults, nodeStatus, type TestNode } from "./results"; -export { cargoParser, goParser, jsParser, jvmParser, pytestParser }; +export { cargoParser, goParser, jsParser, jvmParser, pytestParser, unittestParser }; /** Runs several parsers side by side (unknown tool: e.g. `make test`). */ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { @@ -20,7 +21,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { }; } -export type Framework = "cargo" | "js" | "pytest" | "go" | "jvm" | "auto"; +export type Framework = "cargo" | "js" | "pytest" | "unittest" | "go" | "jvm" | "auto"; export function frameworkFor(config: Pick): Framework { const cmd = [config.command, ...config.args].join(" "); @@ -32,8 +33,12 @@ export function frameworkFor(config: Pick>(config: T): T { if (frameworkFor(config) === "go" && config.args[0] === "test") { @@ -64,6 +70,10 @@ export function withTestReporting /^(-v|--verbosity)(=?\d)?$/.test(a)); + return chosen ? config : { ...config, args: [...config.args, "-v", "2"] }; + } if (frameworkFor(config) !== "pytest") return config; const cmd = [config.command, ...config.args]; const pytestAt = cmd.findIndex((a) => /(^|[/\\])pytest(\.exe)?$/.test(a)); @@ -80,12 +90,14 @@ export function createTestParser(config: Pick { expect(frameworkFor(cfg("cargo", "cargo", ["test"]))).toBe("cargo"); expect(frameworkFor(cfg("npm", "pnpm", ["run", "test"]))).toBe("js"); expect(frameworkFor(cfg("pytest", "uv", ["run", "pytest"]))).toBe("pytest"); + expect(frameworkFor(cfg("django-test", ".venv/bin/python", ["manage.py", "test"]))).toBe("unittest"); + expect(frameworkFor(cfg("shell", "python3 manage.py test polls"))).toBe("unittest"); + expect(frameworkFor(cfg("python", ".venv/bin/python", ["manage.py", "test"]))).toBe("unittest"); + expect(frameworkFor(cfg("python", ".venv/bin/python", ["-m", "pytest"]))).toBe("pytest"); expect(frameworkFor(cfg("go", "go", ["test", "./..."]))).toBe("go"); expect(frameworkFor(cfg("gradle", "./gradlew", ["test"]))).toBe("jvm"); expect(frameworkFor(cfg("shell", "npx vitest run"))).toBe("js"); @@ -417,9 +421,14 @@ describe("parser selection", () => { it("the combined parser understands any supported format", () => { const r = results( createTestParser(cfg("make", "make", ["test"])), - "test a ... ok\n--- PASS: TestB (0.00s)\nok \tpkg\t0.1s", + "test a ... ok\n--- PASS: TestB (0.00s)\nok \tpkg\t0.1s\ntest_c (app.tests.CTests.test_c) ... ok\n" + + "Finished (0.5s)\ncompile (gcc.exe)", ); expect(leaf(r, "a")?.own).toBe("passed"); + expect(leaf(r, "app.tests", "CTests", "test_c")?.own).toBe("passed"); + // Lines of other tools that only look like a unittest header make no tests. + expect(r.roots.map((n) => n.name)).not.toContain("0"); + expect(r.roots.map((n) => n.name)).not.toContain("gcc"); expect(leaf(r, "pkg", "TestB")?.own).toBe("passed"); }); }); diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index ea426180f..4379d855e 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -103,6 +103,32 @@ describe("rerunFailedConfig", () => { expect(c.args).toEqual(["-q", "tests/test_x.py::TestC::test_y[1]", "tests/test_z.py::test_a"]); }); + it("django: dotted labels in place of the run's target, options kept", () => { + const c = rerunFailedConfig( + cfg("django-test", ".venv/bin/python", ["manage.py", "test", "polls", "--keepdb", "-v", "2"]), + [["polls.tests", "QuestionTests", "test_a"], ["polls.tests", "BrokenSetupTests"], ["broken.tests"]], + )!; + expect(c.args).toEqual([ + "manage.py", + "test", + "polls.tests.QuestionTests.test_a", + "polls.tests.BrokenSetupTests", + "broken.tests", + "--keepdb", + "-v", + "2", + ]); + // A configuration made in the form keeps its Target field in step. + const form = { + ...cfg("django-test", "uv", ["run", "python", "manage.py", "test", "polls"]), + params: { command: "uv", runnerArgs: "run python", target: "polls", settings: "", options: "" }, + }; + expect(rerunFailedConfig(form, [["polls.tests", "QuestionTests", "test_a"]])).toMatchObject({ + args: ["run", "python", "manage.py", "test", "polls.tests.QuestionTests.test_a"], + params: { target: "polls.tests.QuestionTests.test_a" }, + }); + }); + it("go: -run with top-level names and packages", () => { const c = rerunFailedConfig(cfg("go", "go", ["test", "-v", "./..."]), [ ["example.com/m/pkg", "TestAdd"], @@ -152,6 +178,25 @@ describe("withTestReporting", () => { expect(withTestReporting(cfg("pytest", "uv", ["run", "pytest"])).args).toEqual(["run", "pytest", "-v"]); expect(withTestReporting(cfg("pytest", ".venv/bin/pytest", ["-q"])).args).toEqual(["-q"]); expect(withTestReporting(cfg("pytest", "pytest", ["-vv"])).args).toEqual(["-vv"]); + // Django prints one line per test from verbosity 2. + expect(withTestReporting(cfg("django-test", "python3", ["manage.py", "test", "--keepdb"])).args).toEqual([ + "manage.py", + "test", + "--keepdb", + "-v", + "2", + ]); + expect(withTestReporting(cfg("django-test", "python3", ["manage.py", "test", "-v", "3"])).args).toEqual([ + "manage.py", + "test", + "-v", + "3", + ]); + expect(withTestReporting(cfg("shell", "python3", ["manage.py", "test", "--verbosity=0"])).args).toEqual([ + "manage.py", + "test", + "--verbosity=0", + ]); // A plain script run (no pytest) and other frameworks are left alone. expect(withTestReporting(cfg("python", "python3", ["main.py"])).args).toEqual(["main.py"]); expect(withTestReporting(cfg("cargo", "cargo", ["test"])).args).toEqual(["test"]); diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index 2d0bc69a4..46415d2a6 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -4,6 +4,7 @@ */ import type { RunConfig } from "@/platform/ipc"; import { formatArgs } from "../configs"; +import { djangoTestsOf } from "../djangoTypes"; import { goTestParts, withoutGoSelection } from "../goArguments"; import { frameworkFor } from "./index"; import { nodeStatus, type TestNode, type TestResults } from "./results"; @@ -157,6 +158,12 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string): case "pytest": args = pytestArgs(config.args, paths); break; + case "unittest": { + // Django: dotted labels (`polls.tests.QuestionTests.test_x`) in place of the run's own target. + const tests = djangoTestsOf(config, unique(paths.map((p) => p.join(".")))); + if (!tests) return null; + return { ...config, ...tests, name, detected: true, env: { ...config.env } }; + } case "go": args = goArgs(config.args, paths); break; diff --git a/src/features/run/testing/states.test.ts b/src/features/run/testing/states.test.ts index dca6da2ec..8ff5698de 100644 --- a/src/features/run/testing/states.test.ts +++ b/src/features/run/testing/states.test.ts @@ -45,6 +45,19 @@ describe("test states for the run gutter", () => { expect(testState({ path: ["math", "adds"], file: "/work/app/src/math.test.ts" })).toBe("passed"); }); + it("matches unittest results rooted at the dotted module of the file", () => { + run([ + { type: "result", path: ["polls.tests", "QuestionTests", "test_recent"], status: "failed" }, + { type: "result", path: ["polls.tests.test_views", "ViewTests", "test_ok"], status: "passed" }, + { type: "result", path: ["città.tests", "QTests", "test_a"], status: "passed" }, + ]); + expect(testState({ path: ["QuestionTests", "test_recent"], file: "/work/app/polls/tests.py" })).toBe("failed"); + expect(testState({ path: ["QuestionTests"], file: "/work/app/polls/tests/__init__.py" })).toBe("failed"); + expect(testState({ path: ["QuestionTests", "test_recent"], file: "/work/app/blog/tests.py" })).toBeNull(); + expect(testState({ path: ["ViewTests", "test_ok"], file: "/work/app/polls/tests/test_views.py" })).toBe("passed"); + expect(testState({ path: ["QTests", "test_a"], file: "/work/app/città/tests.py" })).toBe("passed"); + }); + it("newer runs win and listeners hear about changes", () => { const listener = vi.fn(); const off = onTestStatesChanged(listener); diff --git a/src/features/run/testing/states.ts b/src/features/run/testing/states.ts index bc23a1f91..4f255bb69 100644 --- a/src/features/run/testing/states.ts +++ b/src/features/run/testing/states.ts @@ -2,7 +2,7 @@ * Last known result per test, across runs, for the gutter ▶ icons: like IDEA, a * test (or suite) that failed last time shows a red run icon, a passed one green. * Results are matched to gutter targets by their test path (the trailing name - * parts), plus the file for frameworks whose results are rooted at the file. + * parts), plus the file for frameworks whose results are rooted at the file (or its module). */ import { nodeStatus, type TestNode, type TestResults } from "./results"; import type { TestStatus } from "./types"; @@ -70,8 +70,17 @@ function segmentMatches(result: string, target: string): boolean { const cleanPath = (p: string) => p.replace(/\\/g, "/").replace(/^\.\//, ""); function sameFile(resultRoot: string, relPath: string): boolean { - const a = cleanPath(resultRoot); const b = cleanPath(relPath); + // unittest / Django results are rooted at the dotted module (`polls.tests`). + if ( + /^[\p{L}_][\p{L}\p{N}_]*(\.[\p{L}_][\p{L}\p{N}_]*)*$/u.test(resultRoot) && + !resultRoot.endsWith(".py") && + b.endsWith(".py") + ) { + const module = resultRoot.replace(/\./g, "/"); + return [`${module}.py`, `${module}/__init__.py`].some((f) => b === f || b.endsWith(`/${f}`)); + } + const a = cleanPath(resultRoot); return a === b || b.endsWith(`/${a}`) || a.endsWith(`/${b}`); } diff --git a/src/features/run/testing/unittest.test.ts b/src/features/run/testing/unittest.test.ts new file mode 100644 index 000000000..009639e4a --- /dev/null +++ b/src/features/run/testing/unittest.test.ts @@ -0,0 +1,371 @@ +import { describe, expect, it } from "vitest"; +import { parseAll } from "./index"; +import { TestResults, nodeStatus } from "./results"; +import { unittestParser } from "./unittest"; + +// Captured from `manage.py test` (Django 4.2 on Python 3.10, Django 6.1 on Python 3.12) as the Run +// console splits it into lines: stdout and stderr on two pipes, never sharing a line. +const PY312 = `System check identified no issues (0 silenced). +test_documented (polls.tests.QuestionModelTests.test_documented) +Explains what is checked. ... ok +test_errors (polls.tests.QuestionModelTests.test_errors) ... ERROR +test_expected_failure (polls.tests.QuestionModelTests.test_expected_failure) ... expected failure +test_fails (polls.tests.QuestionModelTests.test_fails) ... FAIL +test_passes (polls.tests.QuestionModelTests.test_passes) ... ok +test_prints (polls.tests.QuestionModelTests.test_prints) ... hello from stderr +ok +hello from stdout +test_skipped (polls.tests.QuestionModelTests.test_skipped) ... skipped 'not ready' +test_subtests (polls.tests.QuestionModelTests.test_subtests) ... + test_subtests (polls.tests.QuestionModelTests.test_subtests) (i=1) ... FAIL +test_unexpected_success (polls.tests.QuestionModelTests.test_unexpected_success) ... unexpected success +test_ok (polls.more.test_views.ViewTests.test_ok) ... ok +setUpClass (polls.tests.BrokenSetupTests) ... ERROR + +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests.test_errors) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests.test_fails) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests.test_subtests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +====================================================================== +UNEXPECTED SUCCESS: test_unexpected_success (polls.tests.QuestionModelTests.test_unexpected_success) +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...`; + +const PY310 = `System check identified no issues (0 silenced). +test_documented (polls.tests.QuestionModelTests) +Explains what is checked. ... ok +test_errors (polls.tests.QuestionModelTests) ... ERROR +test_expected_failure (polls.tests.QuestionModelTests) ... expected failure +test_fails (polls.tests.QuestionModelTests) ... FAIL +test_passes (polls.tests.QuestionModelTests) ... ok +test_prints (polls.tests.QuestionModelTests) ... hello from stderr +ok +test_skipped (polls.tests.QuestionModelTests) ... skipped 'not ready' +test_subtests (polls.tests.QuestionModelTests) ... test_unexpected_success (polls.tests.QuestionModelTests) ... unexpected success +test_ok (polls.more.test_views.ViewTests) ... ok +ERROR + +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')... +hello from stdout`; + +/** Default verbosity: progress characters only, failures in the sections. */ +const PY310_DOTS = `System check identified no issues (0 silenced). +.ExF. +hello from stdout +hello from stderr +.su.E +====================================================================== +ERROR: test_errors (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 15, in test_errors + raise ValueError("boom") +ValueError: boom + +====================================================================== +ERROR: setUpClass (polls.tests.BrokenSetupTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 50, in setUpClass + raise RuntimeError("setup broke") +RuntimeError: setup broke + +====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") +AssertionError: 'left' != 'right' +- left ++ right + + +====================================================================== +FAIL: test_subtests (polls.tests.QuestionModelTests) (i=1) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/tests.py", line 43, in test_subtests + self.assertNotEqual(i, 1) +AssertionError: 1 == 1 + +---------------------------------------------------------------------- +Ran 10 tests in 0.001s + +FAILED (failures=2, errors=2, skipped=1, expected failures=1, unexpected successes=1) +Destroying test database for alias 'default'...`; + +/** `--parallel`: tracebacks rebuilt by tblib start in unittest's own frames. */ +const PARALLEL = `====================================================================== +FAIL: test_fails (polls.tests.QuestionModelTests.test_fails) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 58, in testPartExecutor + yield + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 634, in run + self._callTestMethod(testMethod) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 589, in _callTestMethod + if method() is not None: + ^^^^^^^^^^^^^^^^^ + File "/e2e/proj/polls/tests.py", line 12, in test_fails + self.assertEqual("left", "right") + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 885, in assertEqual + assertion_func(first, second, msg=msg) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 1251, in assertMultiLineEqual + self.fail(self._formatMessage(msg, standardMsg)) + ^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/case.py", line 715, in fail + raise self.failureException(msg) + ^^^^^^^^^^^^^^^^^ +AssertionError: 'left' != 'right' +- left ++ right`; + +/** Django 4.2 / Python 3.10: `setUpTestData` fails inside Django's own `setUpClass`; a non-ASCII test name. */ +const SETUP_TEST_DATA = `System check identified no issues (0 silenced). +ERROR +test_città (polls.test_extra.UnicodeTests) ... FAIL + +====================================================================== +ERROR: setUpClass (polls.test_extra.DataTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/venvs/py310/lib/python3.10/site-packages/django/test/testcases.py", line 1466, in setUpClass + cls.setUpTestData() + File "/e2e/proj/polls/test_extra.py", line 7, in setUpTestData + raise ValueError("bad data") +ValueError: bad data + +====================================================================== +FAIL: test_città (polls.test_extra.UnicodeTests) +---------------------------------------------------------------------- +Traceback (most recent call last): + File "/e2e/proj/polls/test_extra.py", line 15, in test_città + self.assertEqual(1, 2) +AssertionError: 1 != 2 + +---------------------------------------------------------------------- +Ran 1 test in 0.001s + +FAILED (failures=1, errors=1) +Destroying test database for alias 'default' ('file:memorydb_default?mode=memory&cache=shared')...`; + +/** Python 3.12: no test matched (`-k nomatch`). */ +const NO_TESTS = `System check identified no issues (0 silenced). + +---------------------------------------------------------------------- +Ran 0 tests in 0.000s + +NO TESTS RAN`; + +const IMPORT_ERROR = `broken.tests (unittest.loader._FailedTest.broken.tests) ... ERROR +test_ok (polls.more.test_views.ViewTests.test_ok) ... ok + +====================================================================== +ERROR: broken.tests (unittest.loader._FailedTest.broken.tests) +---------------------------------------------------------------------- +ImportError: Failed to import test module: broken.tests +Traceback (most recent call last): + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/loader.py", line 396, in _find_test_path + module = self._get_module_from_name(name) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "/venvs/pythons/cpython-3.12.16-linux-aarch64-gnu/lib/python3.12/unittest/loader.py", line 339, in _get_module_from_name + __import__(name) + File "/e2e/proj/broken/tests.py", line 1, in + import does_not_exist +ModuleNotFoundError: No module named 'does_not_exist' + + +---------------------------------------------------------------------- +Ran 2 tests in 0.000s + +FAILED (errors=1)`; + +function tree(text: string) { + const results = new TestResults(); + results.apply(parseAll(unittestParser(), text)); + results.finish(1); + const status = (...path: string[]) => { + const node = results.get(path.join("\0")); + return node ? nodeStatus(node) : undefined; + }; + return { results, status }; +} + +const Q = ["polls.tests", "QuestionModelTests"]; + +describe("unittest (Django) output", () => { + it.each([ + ["Python 3.12", PY312], + ["Python 3.10", PY310], + ])("reports every test of a verbose run under its module and class (%s)", (_, text) => { + const { results, status } = tree(text); + expect(results.roots.map((n) => n.name)).toEqual(["polls.tests", "polls.more.test_views"]); + expect(status(...Q, "test_documented")).toBe("passed"); + expect(status(...Q, "test_errors")).toBe("failed"); + expect(status(...Q, "test_expected_failure")).toBe("passed"); + expect(status(...Q, "test_fails")).toBe("failed"); + expect(status(...Q, "test_passes")).toBe("passed"); + // Its output split the result line in two. + expect(status(...Q, "test_prints")).toBe("passed"); + expect(status(...Q, "test_skipped")).toBe("skipped"); + expect(status(...Q, "test_subtests")).toBe("failed"); + expect(status(...Q, "test_unexpected_success")).toBe("failed"); + expect(status("polls.more.test_views", "ViewTests", "test_ok")).toBe("passed"); + // `setUpClass` failed: the class itself. + expect(status("polls.tests", "BrokenSetupTests")).toBe("failed"); + expect(results.counts()).toMatchObject({ passed: 5, failed: 5, skipped: 1, running: 0, total: 11 }); + expect(results.summary).toMatchObject({ failed: 5, skipped: 1, durationMs: 1 }); + + const fails = results.get([...Q, "test_fails"].join("\0"))!; + expect(fails.location).toEqual({ path: "/e2e/proj/polls/tests.py", line: 12, resolve: "path" }); + expect(fails.output).toContain("AssertionError: 'left' != 'right'"); + expect(results.get(["polls.tests", "BrokenSetupTests"].join("\0"))!.location).toMatchObject({ line: 50 }); + expect(results.get([...Q, "test_subtests"].join("\0"))!.output).toContain("AssertionError: 1 == 1"); + }); + + it("reports failures of a run without -v 2 from their sections", () => { + const { results, status } = tree(PY310_DOTS); + expect(status(...Q, "test_fails")).toBe("failed"); + expect(status(...Q, "test_errors")).toBe("failed"); + expect(status(...Q, "test_subtests")).toBe("failed"); + expect(status("polls.tests", "BrokenSetupTests")).toBe("failed"); + // The summary line counts the unexpected success, which has no section in Python 3.10. + expect(results.counts()).toMatchObject({ passed: 4, failed: 5, skipped: 1 }); + }); + + it("locates a failure at the test's own frame, past unittest's frames", () => { + const { results } = tree(PARALLEL); + expect(results.get([...Q, "test_fails"].join("\0"))!.location).toMatchObject({ + path: "/e2e/proj/polls/tests.py", + line: 12, + }); + }); + + it("locates fixture failures in the project, past Django's frames, and reads non-ASCII names", () => { + const { results, status } = tree(SETUP_TEST_DATA); + expect(status("polls.test_extra", "DataTests")).toBe("failed"); + expect(results.get(["polls.test_extra", "DataTests"].join("\0"))!.location).toEqual({ + path: "/e2e/proj/polls/test_extra.py", + line: 7, + resolve: "path", + }); + expect(status("polls.test_extra", "UnicodeTests", "test_città")).toBe("failed"); + expect(results.get(["polls.test_extra", "UnicodeTests", "test_città"].join("\0"))!.location).toMatchObject({ + line: 15, + }); + }); + + it("keeps a project's own unittest/ folder as a location", () => { + const { results } = tree( + [ + "=".repeat(70), + "FAIL: test_a (tests.unittest.test_x.C.test_a)", + "-".repeat(70), + "Traceback (most recent call last):", + ' File "/e2e/proj/tests/unittest/test_x.py", line 9, in test_a', + " self.assertTrue(False)", + "AssertionError: False is not true", + "", + ].join("\n"), + ); + expect(results.get(["tests.unittest.test_x", "C", "test_a"].join("\0"))!.location).toMatchObject({ + path: "/e2e/proj/tests/unittest/test_x.py", + line: 9, + }); + }); + + it("reports a run where no test matched", () => { + const { results } = tree(NO_TESTS); + expect(results.isEmpty).toBe(true); + expect(results.summary).toMatchObject({ passed: 0, failed: 0, skipped: 0 }); + }); + + it("ignores other tools' lines that look like a name and a dotted word", () => { + const { results } = tree("Finished (0.5s)\ncompile (gcc.exe)\nbuilding (app.main) ... done"); + expect(results.isEmpty).toBe(true); + }); + + it("reports a test module that fails to import as the module", () => { + const { results, status } = tree(IMPORT_ERROR); + expect(status("broken.tests")).toBe("failed"); + expect(results.get("broken.tests")!.location).toMatchObject({ path: "/e2e/proj/broken/tests.py", line: 1 }); + expect(status("polls.more.test_views", "ViewTests", "test_ok")).toBe("passed"); + }); +}); diff --git a/src/features/run/testing/unittest.ts b/src/features/run/testing/unittest.ts new file mode 100644 index 000000000..510657d1d --- /dev/null +++ b/src/features/run/testing/unittest.ts @@ -0,0 +1,169 @@ +/** + * unittest's text runner, as Django's `manage.py test` prints it: verbose result + * lines (`-v 2`), failure sections and the summary. Tests are reported as + * `[module, Class, test]`; class and module fixtures (`setUpClass`, a test module + * that fails to import) as their class or module. + */ +import { parseDuration, type TestEvent, type TestOutputParser, type TestStatus } from "./types"; + +/** A Python identifier (Unicode letters, not starting with a digit). */ +const ID = String.raw`[\p{L}_][\p{L}\p{N}_]*`; +// `test_x (polls.tests.QuestionTests.test_x)` (Python ≥ 3.11 adds the test name), +// `test_x (polls.tests.QuestionTests)`, then a subtest's `[msg]` / `(i=1)`. +const HEADER_RE = new RegExp( + String.raw`^\s*(${ID}(?:\.${ID})*) \((${ID}(?:\.${ID})+)\)(?: \[.*?\])?(?: \(.*?\))?(?= \.\.\.|$)`, + "u", +); +const OUTCOME_RE = /^(ok|FAIL|ERROR|skipped(?: .*)?|expected failure|unexpected success)$/; +// A docstring's first line follows the name when the test has one: `Checks x. ... ok`. +const TRAILING_OUTCOME_RE = / \.\.\. (ok|FAIL|ERROR|skipped(?: .*)?|expected failure|unexpected success)$/; +const SECTION_RE = /^(FAIL|ERROR|UNEXPECTED SUCCESS): (.+)$/; +const FRAME_RE = /^\s*File "([^"]+)", line (\d+), in (\S+)/; +// Installed packages and the standard library with its unittest (`lib/python3.12/…`, Windows `Lib\…`); +// a project's own `unittest/` folder is not a library. +const LIBRARY_RE = + /[/\\](?:site-packages|dist-packages)[/\\]|\/lib\/python[\d.]*\/(?:unittest\/|[^/]+\.py$)|\\Lib\\(?:unittest\\|[^\\]+\.py$)/; +const RAN_RE = /^Ran (\d+) tests? in ([\d.]+s)$/; +// Python ≥ 3.12 prints `NO TESTS RAN` when nothing matched the labels. +const RESULT_RE = /^(OK|FAILED|NO TESTS RAN)(?: \((.*)\))?$/; +/** unittest's `separator1` / `separator2`. */ +const RULE1 = "=".repeat(70); +const RULE2 = "-".repeat(70); + +function status(outcome: string): Exclude { + if (outcome === "ok" || outcome === "expected failure") return "passed"; + if (outcome.startsWith("skipped")) return "skipped"; + return "failed"; +} + +/** Tree path of a reported test: its module, class and name. */ +function testPath(name: string, dotted: string): string[] { + // A test module that failed to import: `polls.tests (unittest.loader._FailedTest.polls.tests)`. + if (/^unittest\.loader\._Failed/.test(dotted)) return [name]; + if (name === "setUpModule" || name === "tearDownModule") return [dotted]; + const id = dotted.endsWith(`.${name}`) ? dotted.slice(0, -name.length - 1) : dotted; + const dot = id.lastIndexOf("."); + const parts = dot < 0 ? [id] : [id.slice(0, dot), id.slice(dot + 1)]; + return name === "setUpClass" || name === "tearDownClass" ? parts : [...parts, name]; +} + +export function unittestParser(): TestOutputParser { + /** A test whose outcome hasn't been printed yet (output, a docstring or subtests came first). */ + let pending: string[] | null = null; + let section: { + path: string[]; + /** The function whose frame locates the failure (the test, `setUpClass`, a module's ``). */ + name: string; + ruled: boolean; + location: TestEvent | null; + fallback: TestEvent | null; + } | null = null; + let ran: { count: number; durationMs?: number } | null = null; + + const closeSection = (): TestEvent[] => { + const s = section; + section = null; + const location = s && (s.location ?? s.fallback); + return location ? [location] : []; + }; + + /** Result lines; a line may hold several (Python < 3.11 prints nothing after a test with failed subtests). */ + const results = (line: string, events: TestEvent[]): boolean => { + let rest = line; + let matched = false; + let m: RegExpExecArray | null; + while ((m = HEADER_RE.exec(rest))) { + const after = rest.slice(m[0].length).replace(/^ \.\.\. ?/, ""); + const outcome = OUTCOME_RE.exec(after); + // A result that comes later (after a docstring or the test's output) only for test methods + // (unittest's `test` prefix); fixtures and modules print theirs at once. Lines of other tools + // such as `Finished (0.5s)` or `compile (gcc.exe)` are no tests. + if (!outcome && !m[1]!.startsWith("test")) break; + matched = true; + const path = testPath(m[1]!, m[2]!); + rest = after; + if (outcome) { + events.push({ type: "result", path, status: status(outcome[1]!) }); + pending = null; + return true; + } + events.push({ type: "start", path }); + pending = path; + } + return matched; + }; + + return { + feed(line) { + const events: TestEvent[] = []; + let m: RegExpExecArray | null; + if (line === RULE1) { + events.push(...closeSection()); + pending = null; + return events; + } + // An unexpected success's section has no traceback: the summary follows its rule. + if (section && RAN_RE.test(line)) events.push(...closeSection()); + if (section) { + if (line === RULE2) { + if (!section.ruled) section.ruled = true; + else events.push(...closeSection()); + return events; + } + events.push({ type: "output", path: section.path, line }); + const frame = FRAME_RE.exec(line); + // Library frames never locate a failure, even when named like the test or fixture + // (Django's own `setUpClass` runs the test's `setUpTestData`). + if (frame && !LIBRARY_RE.test(frame[1]!)) { + const location: TestEvent = { + type: "location", + path: section.path, + location: { path: frame[1]!, line: Number(frame[2]), resolve: "path" }, + }; + if (frame[3] === section.name) section.location ??= location; + else section.fallback ??= location; + } + return events; + } + if ((m = SECTION_RE.exec(line))) { + const header = HEADER_RE.exec(m[2]!); + if (header) { + const path = testPath(header[1]!, header[2]!); + const name = /^unittest\.loader\._Failed/.test(header[2]!) ? "" : header[1]!; + section = { path, name, ruled: false, location: null, fallback: null }; + events.push({ type: "result", path, status: "failed" }, { type: "output", path, line }); + return events; + } + } + if ((m = RAN_RE.exec(line))) { + ran = { count: Number(m[1]), durationMs: parseDuration(m[2]) }; + return events; + } + if (ran && (m = RESULT_RE.exec(line))) { + const counts: Record = {}; + for (const c of (m[2] ?? "").matchAll(/([a-z ]+)=(\d+)/g)) counts[c[1]!.trim()] = Number(c[2]); + const failed = (counts.failures ?? 0) + (counts.errors ?? 0) + (counts["unexpected successes"] ?? 0); + const skipped = counts.skipped ?? 0; + events.push({ + type: "summary", + passed: Math.max(0, ran.count - failed - skipped), + failed, + skipped, + durationMs: ran.durationMs, + }); + ran = null; + return events; + } + if (results(line, events)) return events; + if (pending) { + const outcome = OUTCOME_RE.exec(line) ?? TRAILING_OUTCOME_RE.exec(line); + if (outcome) { + events.push({ type: "result", path: pending, status: status(outcome[1]!) }); + pending = null; + } + } + return events; + }, + end: closeSection, + }; +} diff --git a/src/features/run/types.ts b/src/features/run/types.ts index 1ad2edf87..a5b51f4fa 100644 --- a/src/features/run/types.ts +++ b/src/features/run/types.ts @@ -23,6 +23,7 @@ import { tomcatType } from "./tomcat"; import { goBuildType, goTestType } from "./goTypes"; import { dotnetProjectType, dotnetTestType } from "./dotnetTypes"; +import { djangoTestType } from "./djangoTypes"; export const COMPOUND = "compound"; @@ -398,6 +399,7 @@ export const BUILTIN_TYPES: readonly RunConfigTypeContribution[] = [ maven, godot, pytest, + djangoTestType, dotnetProjectType, dotnetTestType, goBuildType, @@ -451,7 +453,7 @@ export function withParams(config: RunConfig, params: Record, ty ...(config.kind === "dotnet" && config.beforeLaunch === undefined ? { beforeLaunch: [{ kind: "dotnetBuild", target: "" }] } : {}), - ...(config.kind === "dotnet-test" ? { isTest: true } : {}), + ...(config.kind === "dotnet-test" || config.kind === "django-test" ? { isTest: true } : {}), params: params.goKind ? { ...params, programArgv: JSON.stringify(parseArgs(params.programArgs ?? "")) } : params, ...(config.kind === "go-test" && !config.id.startsWith("template:") ? { kind: "go" } : {}), ...(params.goKind ? { isTest: params.goKind === "test" } : {}), diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 4240050a1..ed9b91318 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -5504,5 +5504,13 @@ "Image is too large to paste (limit {limit} MB).": "图片过大,无法粘贴(上限 {limit} MB)。", "Show inline blame for the current line": "为当前行显示内联 Git Blame", "You, now · Uncommitted changes": "你,刚刚 · 未提交的更改", - "now": "刚刚" + "now": "刚刚", + "Django tests": "Django 测试", + "Run Django tests with manage.py test": "使用 manage.py test 运行 Django 测试", + "e.g. python3, .venv/bin/python, uv, poetry": "例如 python3、.venv/bin/python、uv、poetry", + "e.g. `run python` for uv/poetry; empty calls the interpreter itself": "例如 uv/poetry 使用 `run python`;留空则直接调用解释器本身", + "App labels, modules or dotted test paths; empty runs every test": "应用标签、模块或以点分隔的测试路径;留空则运行全部测试", + "Settings module": "设置模块", + "Passed as --settings; empty keeps DJANGO_SETTINGS_MODULE or the default in manage.py": "作为 --settings 传入;留空则沿用 DJANGO_SETTINGS_MODULE 或 manage.py 中的默认值", + "e.g. --keepdb --parallel": "例如 --keepdb --parallel" } diff --git a/src/platform/locales/textCatalog.test.ts b/src/platform/locales/textCatalog.test.ts index e23a830dd..1bba83ce9 100644 --- a/src/platform/locales/textCatalog.test.ts +++ b/src/platform/locales/textCatalog.test.ts @@ -212,13 +212,16 @@ const UNTRANSLATED = new Set([ "go env GOPATH", "kebab-case", "KEY=value; OTHER=value", + "mysite.settings_test", "NAME", "NAME=value; OTHER=value", "node_modules/typescript", "PascalCase", + "polls.tests", "pylyzer --server", "pyproject.toml, .git", "run pytest", + "run python", "snake_case", "tests", '{ "section": { "key": "value" } }',