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 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Цей коміт міститься в:
authored and GitHub committed 2026-11-02 22:49:04 +08:00
1 джерело d981603f3c
коміт d939a19747
25 files changed
+1049 -36

No files matched your search

+13
Переглянути файл
@@ -890,6 +890,19 @@ fn detect_python(root: &Path, out: &mut Vec<RunConfig>) {
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
+2 -2
Переглянути файл
@@ -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<LaunchEnv> {
let paths: Vec<PathBuf> = 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")
{
+8 -1
Переглянути файл
@@ -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)]
+6 -1
Переглянути файл
@@ -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();
+2 -2
Переглянути файл
@@ -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<PathBuf> = 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 …` → `<interpreter> …`
let skip = if c.args.get(1).is_some_and(|a| is_python_command(a)) { 2 } else { 1 };
+7
Переглянути файл
@@ -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) {
+24
Переглянути файл
@@ -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",
+12 -4
Переглянути файл
@@ -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);
+121
Переглянути файл
@@ -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<RunConfig, "args" | "params"> | 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),
};
},
};
+50
Переглянути файл
@@ -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:",
+12 -2
Переглянути файл
@@ -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<ContextInput, "path" | "root" | "configs
(t) => 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]);
+54
Переглянути файл
@@ -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<string, string> }> = [
{
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");
+39
Переглянути файл
@@ -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",
+38 -11
Переглянути файл
@@ -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");
+20 -8
Переглянути файл
@@ -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<RunConfig, "kind" | "command" | "args">): Framework {
const cmd = [config.command, ...config.args].join(" ");
@@ -32,8 +33,12 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
case "bun":
return "js";
case "pytest":
case "python":
return "pytest";
case "python":
// `manage.py test` made as a Python configuration before the Django tests type existed.
return /\bmanage\.py test\b/.test(cmd) ? "unittest" : "pytest";
case "django-test":
return "unittest";
case "go":
return "go";
case "gradle":
@@ -43,16 +48,17 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
if (/\bcargo\b/.test(cmd)) return "cargo";
if (/\b(jest|vitest|npm|pnpm|yarn|bun|npx|deno)\b/.test(cmd)) return "js";
if (/\bpytest\b/.test(cmd)) return "pytest";
if (/\bmanage\.py test\b/.test(cmd)) return "unittest";
if (/\bgo\s+test\b/.test(cmd)) return "go";
if (/\b(gradlew?|mvnw?)\b/.test(cmd)) return "jvm";
return "auto";
}
/**
* Launch arguments that make a test run reportable per test: pytest prints
* only progress dots by default, so passed tests would never reach the tree.
* `-v` adds one line per test (live, like IDEA's runner) unless the user
* chose a verbosity already.
* Launch arguments that make a test run reportable per test: pytest and
* Django print only progress dots by default, so passed tests would never
* reach the tree. `-v` (Django: `-v 2`) adds one line per test (live, like
* IDEA's runner) unless the user chose a verbosity already.
*/
export function withTestReporting<T extends Pick<RunConfig, "kind" | "command" | "args">>(config: T): T {
if (frameworkFor(config) === "go" && config.args[0] === "test") {
@@ -64,6 +70,10 @@ export function withTestReporting<T extends Pick<RunConfig, "kind" | "command" |
args: ["test", "-json", ...args.slice(1), ...(boundary < 0 ? [] : config.args.slice(boundary))],
};
}
if (frameworkFor(config) === "unittest") {
const chosen = config.args.some((a) => /^(-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<RunConfig, "kind" | "command" | "a
return jsParser();
case "pytest":
return pytestParser();
case "unittest":
return unittestParser();
case "go":
return goParser();
case "jvm":
return jvmParser();
default:
return combinedParser([cargoParser(), jsParser(), pytestParser(), goParser(), jvmParser()]);
return combinedParser([cargoParser(), jsParser(), pytestParser(), unittestParser(), goParser(), jvmParser()]);
}
}
+10 -1
Переглянути файл
@@ -408,6 +408,10 @@ describe("parser selection", () => {
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");
});
});
+45
Переглянути файл
@@ -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"]);
+7
Переглянути файл
@@ -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;
+13
Переглянути файл
@@ -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);
+11 -2
Переглянути файл
@@ -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}`);
}
+371
Переглянути файл
@@ -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 <module>
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");
});
});
+169
Переглянути файл
@@ -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<TestStatus, "running"> {
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 `<module>`). */
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]!) ? "<module>" : 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<string, number> = {};
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,
};
}
+3 -1
Переглянути файл
@@ -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<string, string>, 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" } : {}),
+9 -1
Переглянути файл
@@ -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"
}
+3
Переглянути файл
@@ -212,13 +212,16 @@ const UNTRANSLATED = new Set<string>([
"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" } }',