feat(php): PHPUnit and Composer support and a PHP interpreter setting (#959)

Composer projects and PHPUnit suites are detected (Composer scripts, PHPUnit, artisan serve); PHPUnit runs report into the test tree through --teamcity with gutter runs for classes and methods, file-level runs, rerun failed and a PHPUnit run configuration type. Settings › Languages & Frameworks › PHP selects the CLI interpreter (discovered on PATH, Homebrew, phpbrew, phpenv, Herd, XAMPP, Laragon); it is applied to run configurations and PHP language servers through PATH. TeamCity service messages feed only the test tree and are kept out of the run console, sharing the stdout decoder go test -json uses.

Co-authored-by: ravuthz <[email protected]>
Цей коміт міститься в:
Ravuthzтаravuthz authored and GitHub committed 2026-11-03 23:17:52 +08:00
1 джерело d6efea8dac
коміт 9100187fc8
27 files changed
+925 -41

No files matched your search

+7 -20
Переглянути файл
@@ -3,7 +3,7 @@
* name only appears in the trailing `ok`/`FAIL` line, so events are buffered
* per package and emitted under it once that line arrives.
*/
import { parseDuration, type TestEvent, type TestOutputParser } from "./types";
import { parseDuration, TestStdout, type TestEvent, type TestOutputParser } from "./types";
const RUN_RE = /^=== (?:RUN|CONT|PAUSE)\s+(\S+)/;
const RESULT_RE = /^\s*--- (PASS|FAIL|SKIP): (\S+) \(([\d.]+s)\)/;
@@ -164,24 +164,11 @@ export function goParser(): TestOutputParser {
}
/** Decode stdout for the console while feeding complete, unsplit JSON records to the test parser. */
export class GoTestOutput {
private pending = "";
constructor(private readonly feedLine: (line: string) => void) {}
feed(text: string): string {
this.pending += text;
const lines = this.pending.split("\n");
this.pending = lines.pop()!;
return lines.map((line) => this.line(line)).join("");
}
end(): string {
const pending = this.pending;
this.pending = "";
return pending ? this.line(pending) : "";
}
private line(raw: string): string {
const line = raw.replace(/\r$/, "");
this.feedLine(line);
const event = goJsonEvent(line);
return event ? (event.Output ?? "") : line + "\n";
export class GoTestOutput extends TestStdout {
constructor(feedLine: (line: string) => void) {
super(feedLine, (line) => {
const event = goJsonEvent(line);
return event ? (event.Output ?? "") : line + "\n";
});
}
}
+18 -3
Переглянути файл
@@ -4,6 +4,7 @@ import { cargoParser } from "./cargo";
import { goParser } from "./go";
import { jsParser } from "./js";
import { jvmParser } from "./jvm";
import { phpunitParser, withTeamcity } from "./phpunit";
import { pytestParser } from "./pytest";
import { unittestParser } from "./unittest";
import type { TestEvent, TestOutputParser } from "./types";
@@ -11,7 +12,7 @@ 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, unittestParser };
export { cargoParser, goParser, jsParser, jvmParser, phpunitParser, pytestParser, unittestParser };
/** Runs several parsers side by side (unknown tool: e.g. `make test`). */
export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
@@ -21,7 +22,7 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser {
};
}
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "go" | "jvm" | "auto";
export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "auto";
export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args">): Framework {
const cmd = [config.command, ...config.args].join(" ");
@@ -39,6 +40,8 @@ export function frameworkFor(config: Pick<RunConfig, "kind" | "command" | "args"
return /\bmanage\.py test\b/.test(cmd) ? "unittest" : "pytest";
case "django-test":
return "unittest";
case "phpunit":
return "phpunit";
case "go":
return "go";
case "gradle":
@@ -49,6 +52,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 (/\bgo\s+test\b/.test(cmd)) return "go";
if (/\b(gradlew?|mvnw?)\b/.test(cmd)) return "jvm";
return "auto";
@@ -70,6 +74,7 @@ export function withTestReporting<T extends Pick<RunConfig, "kind" | "command" |
args: ["test", "-json", ...args.slice(1), ...(boundary < 0 ? [] : config.args.slice(boundary))],
};
}
if (frameworkFor(config) === "phpunit") return { ...config, args: withTeamcity(config.args) };
if (frameworkFor(config) === "unittest") {
const chosen = config.args.some((a) => /^(-v|--verbosity)(=?\d)?$/.test(a));
return chosen ? config : { ...config, args: [...config.args, "-v", "2"] };
@@ -92,12 +97,22 @@ export function createTestParser(config: Pick<RunConfig, "kind" | "command" | "a
return pytestParser();
case "unittest":
return unittestParser();
case "phpunit":
return phpunitParser();
case "go":
return goParser();
case "jvm":
return jvmParser();
default:
return combinedParser([cargoParser(), jsParser(), pytestParser(), unittestParser(), goParser(), jvmParser()]);
return combinedParser([
cargoParser(),
jsParser(),
pytestParser(),
unittestParser(),
phpunitParser(),
goParser(),
jvmParser(),
]);
}
}
+156
Переглянути файл
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import { createTestParser, frameworkFor, withTestReporting } from "./index";
import { phpunitArgs, phpunitFilter, phpunitParser, TeamcityOutput, withTeamcity } from "./phpunit";
import { TestResults } from "./results";
import type { TestEvent } from "./types";
const run = (lines: string[]): TestEvent[] => {
const parser = phpunitParser();
return lines.flatMap((l) => parser.feed(l));
};
const SUITE = "App\\Tests\\CalcTest";
describe("phpunitParser", () => {
it("reports passed, failed and skipped tests with locations", () => {
const events = run([
"PHPUnit 11.0.0 by Sebastian Bergmann and contributors.",
"##teamcity[testSuiteStarted name='Unit' locationHint='php_qn:///app/phpunit.xml::\\Unit' flowId='1']",
`##teamcity[testSuiteStarted name='${SUITE}' locationHint='php_qn:///app/tests/CalcTest.php::\\${SUITE}' flowId='1']`,
"##teamcity[testStarted name='testAdd' locationHint='php_qn:///app/tests/CalcTest.php::\\App\\Tests\\CalcTest::testAdd' flowId='1']",
"##teamcity[testFinished name='testAdd' duration='3' flowId='1']",
"##teamcity[testStarted name='testSub' locationHint='php_qn:///app/tests/CalcTest.php::\\App\\Tests\\CalcTest::testSub' flowId='1']",
"##teamcity[testFailed name='testSub' message='Failed asserting that 1 matches expected 2.' details=' /app/vendor/phpunit/Assert.php:100|n /app/tests/CalcTest.php:21|n' flowId='1']",
"##teamcity[testFinished name='testSub' duration='1' flowId='1']",
"##teamcity[testStarted name='testSkip' flowId='1']",
"##teamcity[testIgnored name='testSkip' message='not yet' flowId='1']",
"##teamcity[testFinished name='testSkip' duration='0' flowId='1']",
`##teamcity[testSuiteFinished name='${SUITE}' flowId='1']`,
"##teamcity[testSuiteFinished name='Unit' flowId='1']",
]);
const add = ["Unit", SUITE, "testAdd"];
const sub = ["Unit", SUITE, "testSub"];
expect(events).toContainEqual({ type: "result", path: add, status: "passed", durationMs: 3 });
expect(events).toContainEqual({ type: "result", path: sub, status: "failed" });
expect(events).toContainEqual({ type: "result", path: ["Unit", SUITE, "testSkip"], status: "skipped" });
// A failure points at the first frame outside vendor/.
expect(events).toContainEqual({
type: "location",
path: sub,
location: { path: "/app/tests/CalcTest.php", line: 21, resolve: "path" },
});
expect(events).toContainEqual({ type: "output", path: sub, line: "Failed asserting that 1 matches expected 2." });
// The finish of a failed test doesn't turn it into a pass.
expect(events.filter((e) => e.type === "result" && e.path.join() === sub.join())).toHaveLength(1);
});
it("nests data sets under their method", () => {
const events = run([
`##teamcity[testSuiteStarted name='${SUITE}']`,
`##teamcity[testSuiteStarted name='${SUITE}::testAdd']`,
"##teamcity[testStarted name='testAdd with data set #0']",
"##teamcity[testFinished name='testAdd with data set #0' duration='1']",
`##teamcity[testSuiteFinished name='${SUITE}::testAdd']`,
]);
expect(events[0]).toEqual({ type: "start", path: [SUITE, "testAdd", "testAdd with data set #0"] });
});
it("unescapes TeamCity values", () => {
const events = run([
"##teamcity[testSuiteStarted name='S']",
"##teamcity[testStarted name='t']",
"##teamcity[testFailed name='t' message='a |'quoted|' |[x|] b' details='']",
]);
expect(events).toContainEqual({ type: "output", path: ["S", "t"], line: "a 'quoted' [x] b" });
});
it("ignores everything else", () => {
expect(run(["Time: 00:00.012, Memory: 8.00 MB", "OK (1 test, 1 assertion)", ""])).toEqual([]);
});
it("builds a tree", () => {
const results = new TestResults();
const parser = phpunitParser();
for (const l of [
"##teamcity[testSuiteStarted name='S']",
"##teamcity[testStarted name='a']",
"##teamcity[testFinished name='a' duration='1']",
"##teamcity[testStarted name='b']",
"##teamcity[testFailed name='b' message='x' details='']",
"##teamcity[testFinished name='b' duration='1']",
])
results.apply(parser.feed(l));
expect(results.counts()).toMatchObject({ passed: 1, failed: 1 });
});
});
describe("TeamcityOutput", () => {
it("feeds every line to the tree but keeps service messages out of the console", () => {
const results = new TestResults();
const parser = phpunitParser();
const output = new TeamcityOutput((line) => results.apply(parser.feed(line)));
const shown =
output.feed(
"PHPUnit 11.5 by Sebastian Bergmann\n##teamcity[testSuiteStarted name='CalcTest']\n##teamcity[testStar",
) +
output.feed("ted name='testAdd']\r\n##teamcity[testFinished name='testAdd' duration='1']\nOK (1 test") +
output.end();
expect(shown).toBe("PHPUnit 11.5 by Sebastian Bergmann\nOK (1 test\n");
expect(results.counts()).toMatchObject({ passed: 1 });
});
});
describe("command lines", () => {
it("puts --teamcity before any path, after the runner", () => {
expect(withTeamcity(["tests/FooTest.php"])).toEqual(["--teamcity", "tests/FooTest.php"]);
expect(withTeamcity(["vendor/bin/phpunit", "--stop-on-failure", "tests"])).toEqual([
"vendor/bin/phpunit",
"--teamcity",
"--stop-on-failure",
"tests",
]);
expect(withTeamcity(["--testdox"])).toEqual(["--testdox"]);
expect(withTeamcity(["--teamcity"])).toEqual(["--teamcity"]);
});
it("detects PHPUnit configurations", () => {
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(createTestParser({ kind: "phpunit", command: "phpunit", args: [] })).toBeDefined();
expect(withTestReporting({ kind: "phpunit", command: "phpunit", args: ["tests"] }).args).toEqual([
"--teamcity",
"tests",
]);
});
it("replaces the target and filter, keeping options", () => {
expect(phpunitArgs(["-c", "ci.xml", "tests"], { target: "tests/FooTest.php" })).toEqual([
"-c",
"ci.xml",
"tests/FooTest.php",
]);
expect(phpunitArgs(["--filter", "old", "--colors=never", "tests"], { filter: "/x/" })).toEqual([
"--colors=never",
"--filter",
"/x/",
"tests",
]);
expect(phpunitArgs(["vendor/bin/phpunit", "tests"], { target: "a.php", filter: "/x/" })).toEqual([
"vendor/bin/phpunit",
"--filter",
"/x/",
"a.php",
]);
});
it("filters by class and method", () => {
expect(phpunitFilter([["Unit", SUITE, "testAdd"]])).toBe(
"/(App\\\\Tests\\\\CalcTest::testAdd( with data set .*)?)$/",
);
expect(phpunitFilter([["Unit", SUITE, "testAdd", "testAdd with data set #0"]])).toBe(
"/(App\\\\Tests\\\\CalcTest::testAdd( with data set .*)?)$/",
);
expect(phpunitFilter([[SUITE]])).toBe("/(App\\\\Tests\\\\CalcTest::.*)$/");
});
});
+197
Переглянути файл
@@ -0,0 +1,197 @@
/**
* PHPUnit (and Pest, which runs on it) through `--teamcity`: one `##teamcity[…]` service message per
* suite and test event, with the exact `Class::method` names and failure details. Tests are reported as
* `[...suites, test]`: the configured suite (`Unit`), the class, then the method (with its data set).
*/
import { TestStdout, type TestEvent, type TestOutputParser } from "./types";
const MESSAGE_RE = /^\s*##teamcity\[(\w+)((?:\s+[\w.-]+='(?:[^|']|\|.)*')*)\s*\]\s*$/;
const ATTR_RE = /([\w.-]+)='((?:[^|']|\|.)*)'/g;
/** TeamCity escapes: `|n` `|r` `|'` `|[` `|]` `||` and `|0xNNNN`. */
function unescape(value: string): string {
return value.replace(/\|(0x[0-9a-fA-F]{4}|.)/g, (_, c: string) => {
if (c.startsWith("0x")) return String.fromCharCode(Number.parseInt(c, 16));
return c === "n" ? "\n" : c === "r" ? "\r" : c;
});
}
function attributes(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of text.matchAll(ATTR_RE)) out[m[1]!] = unescape(m[2]!);
return out;
}
/** `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 ?? "");
return m ? m[1]! : null;
}
/** First stack frame inside the project (not `vendor/`): `/app/tests/FooTest.php:12`. */
function failureLocation(details: string): { path: string; line: number } | null {
for (const line of details.split("\n")) {
const m = /(?:^|\s)((?:[A-Za-z]:)?[^\s:]+\.php):(\d+)\s*$/.exec(line.trim());
if (m && !/[/\\]vendor[/\\]/.test(m[1]!)) return { path: m[1]!, line: Number(m[2]) };
}
return null;
}
export function phpunitParser(): TestOutputParser {
/** Open suites, outermost first (the data-provider suite `Class::method` is one of them). */
const suites: string[] = [];
const open = new Map<string, { path: string[]; file: string | null; done: boolean }>();
/** A data-provider suite is named `Class::method`; its data sets belong under the method (the class is open already). */
const suitePath = (): string[] =>
suites.map((s) => {
const at = s.lastIndexOf("::");
return at > 0 ? s.slice(at + 2) : s;
});
return {
feed(line) {
const m = MESSAGE_RE.exec(line);
if (!m) return [];
const kind = m[1]!;
const a = attributes(m[2] ?? "");
const name = a.name ?? "";
const events: TestEvent[] = [];
switch (kind) {
case "testSuiteStarted":
suites.push(name);
break;
case "testSuiteFinished":
if (suites[suites.length - 1] === name) suites.pop();
break;
case "testStarted": {
const path = [...suitePath(), name];
open.set(name, { path, file: hintFile(a.locationHint), done: false });
events.push({ type: "start", path });
break;
}
case "testFailed":
case "testIgnored": {
const test = open.get(name);
if (!test) break;
const details = a.details ?? "";
const text = [a.message, details].filter(Boolean).join("\n");
for (const l of text.split("\n")) events.push({ type: "output", path: test.path, line: l });
const at = failureLocation(details) ?? (test.file ? { path: test.file, line: undefined } : null);
if (at) {
events.push({
type: "location",
path: test.path,
location: { path: at.path, ...(at.line ? { line: at.line } : {}), resolve: "path" },
});
}
events.push({ type: "result", path: test.path, status: kind === "testIgnored" ? "skipped" : "failed" });
test.done = true;
break;
}
case "testFinished": {
const test = open.get(name);
if (!test) break;
open.delete(name);
// A failed or ignored test already has its result; the finish only reports a pass.
if (!test.done) {
events.push({
type: "result",
path: test.path,
status: "passed",
...(a.duration ? { durationMs: Number(a.duration) } : {}),
});
}
break;
}
}
return events;
},
};
}
// ----------------------------------------------------------------- command lines
/** Options that take a value in a separate argument. */
const VALUE_OPTIONS = new Set([
"-c",
"--configuration",
"--bootstrap",
"--filter",
"--testsuite",
"--group",
"--exclude-group",
"--covers",
"--uses",
"--cache-directory",
]);
/** Number of leading runner arguments (`vendor/bin/phpunit` under `php`); PHPUnit's own options follow. */
function runnerLength(args: readonly string[]): number {
return args[0] && /(^|[/\\])(phpunit|pest)(\.phar|\.bat)?$/.test(args[0]) ? 1 : 0;
}
/** Splits the arguments after the runner into options and positional targets. */
function splitArgs(rest: readonly string[], dropFilter: boolean): { options: string[]; targets: string[] } {
const options: string[] = [];
const targets: string[] = [];
for (let i = 0; i < rest.length; i++) {
const arg = rest[i]!;
if (!arg.startsWith("-")) targets.push(arg);
else if (VALUE_OPTIONS.has(arg) && i + 1 < rest.length) {
if (!(dropFilter && arg === "--filter")) options.push(arg, rest[i + 1]!);
i++;
} else if (!(dropFilter && arg.startsWith("--filter="))) options.push(arg);
}
return { options, targets };
}
/**
* `args` running `target` (a file or directory) and/or a `--filter` pattern in place of the command's own.
* PHPUnit stops parsing options at the first path, so everything is placed before it.
*/
export function phpunitArgs(args: readonly string[], run: { target?: string; filter?: string }): string[] {
const n = runnerLength(args);
const { options, targets } = splitArgs(args.slice(n), run.filter !== undefined);
return [
...args.slice(0, n),
...options,
...(run.filter === undefined ? [] : ["--filter", run.filter]),
...(run.target === undefined ? targets : [run.target]),
];
}
/** `--teamcity` ahead of any path, unless the run already picked a result format. */
export function withTeamcity(args: readonly string[]): string[] {
if (args.some((a) => a === "--teamcity" || /^--(testdox|log-|no-output)/.test(a))) return [...args];
const n = runnerLength(args);
return [...args.slice(0, n), "--teamcity", ...args.slice(n)];
}
const escapePattern = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
/**
* `--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.
*/
export function phpunitFilter(paths: readonly (readonly string[])[]): string {
const parts = new Set<string>();
for (const path of paths) {
const last = path[path.length - 1] ?? "";
const dataSet = / with data set .*$/.test(last);
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))
parts.add(`${escapePattern(cls)}::${escapePattern(method)}( with data set .*)?`);
else parts.add(`${escapePattern(last)}::.*`);
}
return `/(${[...parts].join("|")})$/`;
}
/** Console text of a `--teamcity` run: the service messages only feed the test tree, as in PhpStorm. */
export class TeamcityOutput extends TestStdout {
constructor(feedLine: (line: string) => void) {
super(feedLine, (line) => (MESSAGE_RE.test(line) ? "" : line + "\n"));
}
}
+4
Переглянути файл
@@ -2,6 +2,7 @@
* "Rerun Failed Tests": derives a run configuration that runs only the failed
* tests of a previous run, using each framework's own filter syntax.
*/
import { phpunitArgs, phpunitFilter } from "./phpunit";
import type { RunConfig } from "@/platform/ipc";
import { formatArgs } from "../configs";
import { djangoTestsOf } from "../djangoTypes";
@@ -164,6 +165,9 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string):
if (!tests) return null;
return { ...config, ...tests, name, detected: true, env: { ...config.env } };
}
case "phpunit":
args = phpunitArgs(config.args, { filter: phpunitFilter(paths) });
break;
case "go":
args = goArgs(config.args, paths);
break;
+28
Переглянути файл
@@ -42,3 +42,31 @@ export function parseDuration(text: string | undefined): number | undefined {
}
return matched ? Math.round(total * 1000) / 1000 : undefined;
}
/**
* Stdout of a runner that prints machine-readable records (`go test -json`, PHPUnit `--teamcity`):
* feeds complete, unsplit lines to the test parser and returns each line's console text.
*/
export class TestStdout {
private pending = "";
constructor(
private readonly feedLine: (line: string) => void,
private readonly display: (line: string) => string,
) {}
feed(text: string): string {
this.pending += text;
const lines = this.pending.split("\n");
this.pending = lines.pop()!;
return lines.map((line) => this.line(line)).join("");
}
end(): string {
const pending = this.pending;
this.pending = "";
return pending ? this.line(pending) : "";
}
private line(raw: string): string {
const line = raw.replace(/\r$/, "");
this.feedLine(line);
return this.display(line);
}
}