дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
a4a3c2105f
коміт
de4c325a28
7 files changed
+209
-12
No files matched your search
@@ -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") {
|
||||
|
||||
@@ -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")]);
|
||||
|
||||
@@ -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"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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::.*)$/");
|
||||
});
|
||||
});
|
||||
@@ -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 .*)?$/`;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача