From de4c325a284ac339e60af632201507dd1a51dd0b Mon Sep 17 00:00:00 2001 From: Pedro Pessoa <54516747+yzPeedro@users.noreply.github.com> Date: Sat, 7 Nov 2026 03:30:17 -0300 Subject: [PATCH] feat(run): run and debug Pest tests from the gutter (#1925) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ide-run/src/detect.rs | 23 +++++-- crates/ide-run/tests/detect.rs | 21 ++++++ src/features/run/targets.test.ts | 84 ++++++++++++++++++++++++ src/features/run/targets.ts | 37 ++++++++++- src/features/run/testing/index.ts | 2 +- src/features/run/testing/phpunit.test.ts | 38 +++++++++++ src/features/run/testing/phpunit.ts | 16 ++++- 7 files changed, 209 insertions(+), 12 deletions(-) diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 101351ddd..24120a724 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -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) { ); 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") { diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 0c35cc037..4d6766cf1 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -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", ""), + ("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", " { expect(run?.command).toBe("vendor/bin/phpunit"); }); }); + +describe("php (Pest)", () => { + const src = [ + "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 = [ + " 1);", + ]; + expect(pick(detectRunTargets(mixed.join("\n"), "tests/CalcTest.php"))).toEqual([ + [1, "suite", "CalcTest"], + [3, "test", "CalcTest/testAdd"], + [5, "test", "x"], + ]); + }); +}); diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index ef7573120..867e99d5c 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -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": { diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 33704129f..453242559 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -57,7 +57,7 @@ export function frameworkFor(config: Pick { 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::.*)$/"); + }); }); diff --git a/src/features/run/testing/phpunit.ts b/src/features/run/testing/phpunit.ts index c6fdbe81f..bf8f17782 100644 --- a/src/features/run/testing/phpunit.ts +++ b/src/features/run/testing/phpunit.ts @@ -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 .*)?$/`; +}