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" } }',