feat(run): run and debug Pest tests from the gutter (#1925)

Pest test() and it() calls and describe() blocks get gutter Run and Debug,
nested by describe and filtered by the names Pest prints (`outer` → it does
x, plus a data set suffix). Projects with vendor/bin/pest get a Pest test
configuration of the PHPUnit kind, pest commands are recognised, Pest's
pest_qn:// hints are read and test tree reruns filter Pest tests by name.
Цей коміт міститься в:
Pedro Pessoa authored and GitHub committed 2026-11-07 14:30:17 +08:00
1 джерело a4a3c2105f
коміт de4c325a28
7 files changed
+209 -12

No files matched your search

+17 -6
Переглянути файл
@@ -1048,10 +1048,15 @@ fn is_composer_event(name: &str) -> bool {
name.starts_with("pre-") || name.starts_with("post-") || name == "command"
}
/// The PHPUnit entry point: the project's `vendor/bin/phpunit`, else a global `phpunit`.
fn phpunit_command(root: &Path) -> String {
let bin = if cfg!(windows) { "vendor/bin/phpunit.bat" } else { "vendor/bin/phpunit" };
if root.join(bin).is_file() { bin.to_owned() } else { "phpunit".to_owned() }
/// The PHPUnit entry point: the project's `vendor/bin/pest` or `vendor/bin/phpunit`, else a global `phpunit`.
fn phpunit_command(root: &Path) -> (String, &'static str) {
for (runner, label) in [("pest", "Pest"), ("phpunit", "PHPUnit")] {
let bin = format!("vendor/bin/{runner}{}", if cfg!(windows) { ".bat" } else { "" });
if root.join(&bin).is_file() {
return (bin, label);
}
}
("phpunit".to_owned(), "PHPUnit")
}
/// Composer projects (`composer.json`) and PHPUnit suites (`phpunit.xml`, `phpunit.xml.dist`).
@@ -1085,8 +1090,14 @@ fn detect_php(root: &Path, out: &mut Vec<RunConfig>) {
);
out.push(if name == "test" { c.test() } else { c });
}
if phpunit_xml.is_some() || requires("phpunit/phpunit") || root.join("vendor/bin/phpunit").is_file() {
let mut c = config("phpunit", "PHPUnit", "phpunit", &phpunit_command(root), &[]).test().debug("php-debug");
if phpunit_xml.is_some()
|| requires("phpunit/phpunit")
|| requires("pestphp/pest")
|| root.join("vendor/bin/phpunit").is_file()
|| root.join("vendor/bin/pest").is_file()
{
let (command, label) = phpunit_command(root);
let mut c = config("phpunit", label, "phpunit", &command, &[]).test().debug("php-debug");
// A configuration file with a non-default name must be passed explicitly.
let named = phpunit_xml.as_deref().and_then(Path::file_name).map(|n| n.to_string_lossy().into_owned());
if let Some(file) = named.filter(|f| f == "phpunit.dist.xml") {
+21
Переглянути файл
@@ -827,6 +827,27 @@ fn php_phpunit_project() {
assert!(!find(&c, "composer:lint").is_test);
}
#[test]
fn php_pest_project() {
let dir = project(&[
("composer.json", r#"{"require-dev":{"pestphp/pest":"^3"}}"#),
("phpunit.xml", "<phpunit/>"),
("vendor/bin/phpunit", ""),
("vendor/bin/phpunit.bat", ""),
("vendor/bin/pest", ""),
("vendor/bin/pest.bat", ""),
]);
let c = detect(dir.path());
let pest = find(&c, "phpunit");
assert_eq!(pest.name, "Pest");
assert_eq!(cmdline(pest), if cfg!(windows) { "vendor/bin/pest.bat" } else { "vendor/bin/pest" });
assert_eq!(pest.kind, "phpunit");
assert!(pest.is_test);
assert_eq!(pest.debug_adapter.as_deref(), Some("php-debug"));
let dir = project(&[("composer.json", r#"{"require-dev":{"pestphp/pest":"^3"}}"#)]);
assert_eq!(find(&detect(dir.path()), "phpunit").name, "PHPUnit");
}
#[test]
fn php_detection_needs_a_marker() {
let dir = project(&[("index.php", "<?php")]);
+84
Переглянути файл
@@ -317,3 +317,87 @@ describe("php (PHPUnit)", () => {
expect(run?.command).toBe("vendor/bin/phpunit");
});
});
describe("php (Pest)", () => {
const src = [
"<?php", // 0
"",
"test('adds numbers', function () {", // 2
" expect(1 + 1)->toBe(2);",
"});",
"",
'it("handles \\"quoted\\" names", fn () => true);', // 6
"",
"describe('division', function () {", // 8
" it('divides', function () {", // 9
" expect(4 / 2)->toBe(2);",
" });",
"",
" describe('by zero', function () {", // 13
" test('throws', fn () => true);", // 14
" });",
"});",
"",
"it('doubles', function (int $n) {", // 18
" expect($n * 2)->toBe($n + $n);",
"})->with([1, 2, 3]);",
"// it('is commented out')",
].join("\n");
const pest = cfg({ id: "phpunit", kind: "phpunit", command: "vendor/bin/pest" });
const run = (line: number) => {
const target = detectRunTargets(src, "tests/Unit/MathTest.php").find((t) => t.line === line)!;
return targetConfig(target, "tests/Unit/MathTest.php", [pest], src);
};
it("finds tests and describe blocks with the names Pest gives them", () => {
expect(pick(detectRunTargets(src, "tests/Unit/MathTest.php"))).toEqual([
[2, "test", "adds numbers"],
[6, "test", 'it handles "quoted" names'],
[8, "suite", "division"],
[9, "test", "division/it divides"],
[13, "suite", "division/by zero"],
[14, "test", "division/by zero/throws"],
[18, "test", "it doubles"],
]);
expect(detectRunTargets(src, "tests/Unit/MathTest.php")[3]).toMatchObject({
name: "division → it divides",
pest: true,
});
});
it("ignores files that are not tests", () => {
expect(detectRunTargets(src, "src/Math.php")).toEqual([]);
});
it("runs one test by the name Pest matches, data sets included", () => {
expect(run(2)?.args).toEqual(["--filter", "/::adds numbers( with data set .*)?$/", "tests/Unit/MathTest.php"]);
expect(run(14)?.args).toEqual([
"--filter",
"/::`division` → `by zero` → throws( with data set .*)?$/",
"tests/Unit/MathTest.php",
]);
expect(run(18)?.args[1]).toBe("/::it doubles( with data set .*)?$/");
expect(run(18)?.command).toBe("vendor/bin/pest");
});
it("runs every test of a describe block", () => {
expect(run(8)?.args[1]).toBe("/::`division` → /");
expect(run(13)?.args[1]).toBe("/::`division` → `by zero` → /");
});
it("keeps PHPUnit classes in the same file", () => {
const mixed = [
"<?php",
"final class CalcTest extends TestCase",
"{",
" public function testAdd() {}",
"}",
"test('x', fn () => 1);",
];
expect(pick(detectRunTargets(mixed.join("\n"), "tests/CalcTest.php"))).toEqual([
[1, "suite", "CalcTest"],
[3, "test", "CalcTest/testAdd"],
[5, "test", "x"],
]);
});
});
+34 -3
Переглянути файл
@@ -14,7 +14,7 @@ import { contextConfig, relToCwd, testConfigFor } from "./context";
import { djangoLabel, djangoTestsOf } from "./djangoTypes";
import { scanJvmTargets, simpleClassName } from "./jvmTargets";
import { escapeRegExp } from "./testing/rerun";
import { phpunitArgs, phpunitFilter } from "./testing/phpunit";
import { pestFilter, phpunitArgs, phpunitFilter } from "./testing/phpunit";
export type TargetKind = "main" | "test" | "suite" | "generate";
@@ -36,6 +36,7 @@ export interface RunTarget {
unittest?: boolean;
/** Ruby: a `module` suite, which runs the tests of every class declared inside it. */
rubyModule?: boolean;
pest?: boolean;
}
/** Above this size we skip detection (generated / minified files). */
@@ -177,6 +178,30 @@ function phpTargets(lines: string[]): RunTarget[] {
return out;
}
const PEST_RE = /^(\s*)(describe|test|it)\s*\(\s*(['"])((?:\\.|(?!\3).)*)\3/;
function pestTargets(lines: string[]): RunTarget[] {
const out: RunTarget[] = [];
const stack: Array<{ indent: number; name: string }> = [];
for (let i = 0; i < lines.length; i++) {
const m = PEST_RE.exec(lines[i]!);
if (!m) continue;
const indent = indentOf(m[1]!);
while (stack.length && stack[stack.length - 1]!.indent >= indent) stack.pop();
const text = m[4]!.replace(m[3] === "'" ? /\\(['\\])/g : /\\(["\\$])/g, "$1");
const describes = stack.map((s) => s.name);
if (m[2] === "describe") {
const path = [...describes, text];
out.push({ line: i, kind: "suite", name: path.join(" → "), path, pest: true });
stack.push({ indent, name: text });
} else {
const path = [...describes, m[2] === "it" ? `it ${text}` : text];
out.push({ line: i, kind: "test", name: path.join(" → "), path, pest: true });
}
}
return out;
}
function pythonTargets(lines: string[]): RunTarget[] {
const out: RunTarget[] = [];
const classes: Array<{ indent: number; name: string; test: boolean; unittest: boolean }> = [];
@@ -412,7 +437,9 @@ export function detectRunTargets(text: string, relPath: string, django = false):
(django && /(^|\/)tests\.py$/.test(relPath)),
);
case "php":
return /Test\.php$|(^|\/)tests?\//.test(relPath) ? phpTargets(lines) : [];
return /Test\.php$|(^|\/)tests?\//.test(relPath)
? [...phpTargets(lines), ...pestTargets(lines)].sort((a, b) => a.line - b.line)
: [];
case "go":
return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath));
case "jvm":
@@ -479,7 +506,11 @@ export function targetConfig(
return named(withLast(file.args, (f) => `${f}::${target.path.join("::")}`));
case "phpunit": {
const [cls, method] = [target.path[0]!, target.path[1]];
const filter = phpunitFilter([method ? [cls, method] : [cls]]);
const filter = !target.pest
? phpunitFilter([method ? [cls, method] : [cls]])
: target.kind === "suite"
? pestFilter(target.path)
: pestFilter(target.path.slice(0, -1), target.path[target.path.length - 1]);
return named(phpunitArgs(file.args, { target: relToCwd(relPath, file), filter }));
}
case "django-test": {
+1 -1
Переглянути файл
@@ -57,7 +57,7 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
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 (/(^|[/\\\s])phpunit(\.phar|\.bat)?(\s|$)/.test(cmd)) return "phpunit";
if (/(^|[/\\\s])(phpunit|pest)(\.phar|\.bat)?(\s|$)/.test(cmd)) return "phpunit";
if (/\bgo\s+test\b/.test(cmd)) return "go";
if (/\b(gradlew?|mvnw?)\b/.test(cmd)) return "jvm";
return "auto";
+38
Переглянути файл
@@ -56,6 +56,27 @@ describe("phpunitParser", () => {
expect(events[0]).toEqual({ type: "start", path: [SUITE, "testAdd", "testAdd with data set #0"] });
});
it("reads Pest's names and location hints", () => {
const events = run([
"##teamcity[testSuiteStarted name='Unit' locationHint='pest_qn://tests/Unit/MathTest.php' flowId='1']",
"##teamcity[testSuiteStarted name='Tests\\Unit\\MathTest' locationHint='pest_qn://tests/Unit/MathTest.php' flowId='1']",
"##teamcity[testStarted name='`division` → it divides' locationHint='pest_qn://tests/Unit/MathTest.php::`division` → it divides' flowId='1']",
"##teamcity[testFailed name='`division` → it divides' message='Failed asserting that 1 is identical to 2.' details='' flowId='1']",
"##teamcity[testSuiteStarted name='it doubles' locationHint='pest_qn://tests/Unit/MathTest.php::it doubles' flowId='1']",
"##teamcity[testStarted name='it doubles with data set \"(1)\"' locationHint='pest_qn://tests/Unit/MathTest.php::it doubles with data set \"(1)\"' flowId='1']",
]);
const divides = ["Unit", "Tests\\Unit\\MathTest", "`division` → it divides"];
expect(events).toContainEqual({
type: "location",
path: divides,
location: { path: "tests/Unit/MathTest.php", resolve: "path" },
});
expect(events).toContainEqual({
type: "start",
path: ["Unit", "Tests\\Unit\\MathTest", "it doubles", 'it doubles with data set "(1)"'],
});
});
it("unescapes TeamCity values", () => {
const events = run([
"##teamcity[testSuiteStarted name='S']",
@@ -118,6 +139,7 @@ describe("command lines", () => {
expect(frameworkFor({ kind: "phpunit", command: "vendor/bin/phpunit", args: [] })).toBe("phpunit");
expect(frameworkFor({ kind: "composer", command: "composer", args: ["run-script", "test"] })).toBe("auto");
expect(frameworkFor({ kind: "shell", command: "php", args: ["vendor/bin/phpunit"] })).toBe("phpunit");
expect(frameworkFor({ kind: "shell", command: "vendor/bin/pest", args: ["--parallel"] })).toBe("phpunit");
expect(createTestParser({ kind: "phpunit", command: "phpunit", args: [] })).toBeDefined();
expect(withTestReporting({ kind: "phpunit", command: "phpunit", args: ["tests"] }).args).toEqual([
"--teamcity",
@@ -154,4 +176,20 @@ describe("command lines", () => {
);
expect(phpunitFilter([[SUITE]])).toBe("/(App\\\\Tests\\\\CalcTest::.*)$/");
});
it("filters Pest tests, data sets and their class by the names Pest prints", () => {
const cls = "Tests\\Unit\\MathTest";
expect(phpunitFilter([["Unit", cls, "it fails on purpose"]])).toBe(
"/(Tests\\\\Unit\\\\MathTest::it fails on purpose( with data set .*)?)$/",
);
expect(phpunitFilter([["Unit", cls, "`division` → `by zero` → Throws"]])).toBe(
"/(Tests\\\\Unit\\\\MathTest::`division` → `by zero` → Throws( with data set .*)?)$/",
);
expect(phpunitFilter([["Unit", cls, "Adds"]])).toBe("/(Tests\\\\Unit\\\\MathTest::Adds( with data set .*)?)$/");
expect(phpunitFilter([["Unit", cls, "it doubles", 'it doubles with data set "(2)"']])).toBe(
"/(Tests\\\\Unit\\\\MathTest::it doubles( with data set .*)?)$/",
);
expect(phpunitFilter([["Unit", cls]])).toBe("/(Tests\\\\Unit\\\\MathTest::.*)$/");
expect(phpunitFilter([["/app/phpunit.xml", "Unit"]])).toBe("/(Unit::.*)$/");
});
});
+14 -2
Переглянути файл
@@ -8,7 +8,7 @@ import type { TestEvent, TestOutputParser } from "./types";
/** `php_qn:///app/tests/FooTest.php::\App\FooTest::testBar` → the file. */
function hintFile(hint: string | undefined): string | null {
const m = /^php_qn:\/\/(.+?\.php)(?:::|$)/.exec(hint ?? "");
const m = /^(?:php|pest)_qn:\/\/(.+?\.php)(?:::|$)/.exec(hint ?? "");
return m ? m[1]! : null;
}
@@ -153,6 +153,11 @@ export function withTeamcity(args: readonly string[]): string[] {
const escapePattern = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
const CLASS_NAME = /^\\?[A-Z]\w*(?:\\\w+)*$/;
const isTestName = (name: string, parent: string) =>
/^[a-z_]\w*$/.test(name) || !CLASS_NAME.test(name) || (CLASS_NAME.test(parent) && parent.includes("\\"));
/**
* `--filter` pattern selecting the tests at `paths` (`[suite, class, method]`, a data set under its method).
* PHPUnit matches `Class::method with data set …`; a class or suite node selects by its name.
@@ -165,9 +170,16 @@ export function phpunitFilter(paths: readonly (readonly string[])[]): string {
const method = last.replace(/ with data set .*$/, "");
// A data set sits under a node named after its method (the data-provider suite).
const cls = path[path.length - (dataSet ? 3 : 2)];
if (cls && /^[a-z_]\w*$/.test(method))
if (cls && isTestName(method, cls))
parts.add(`${escapePattern(cls)}::${escapePattern(method)}( with data set .*)?`);
else parts.add(`${escapePattern(last)}::.*`);
}
return `/(${[...parts].join("|")})$/`;
}
export function pestFilter(describes: readonly string[], name?: string): string {
const prefix = describes.map((d) => `\`${d}\` → `).join("");
return name === undefined
? `/::${escapePattern(prefix)}/`
: `/::${escapePattern(prefix + name)}( with data set .*)?$/`;
}