diff --git a/bin/pest b/bin/pest
index 7b31c0b1..16fcaeb5 100755
--- a/bin/pest
+++ b/bin/pest
@@ -1,13 +1,13 @@
#!/usr/bin/env php
$value) {
if (str_contains($value, '--compact')) {
$_SERVER['COLLISION_PRINTER_COMPACT'] = 'true';
+ unset($args[$key]);
}
if (str_contains($value, '--profile')) {
$_SERVER['COLLISION_PRINTER_PROFILE'] = 'true';
+ unset($args[$key]);
+ }
+
+ if (str_contains($value, '--test-directory')) {
+ unset($args[$key]);
+ }
+
+ if (str_contains($value, '--dirty')) {
+ unset($args[$key]);
+ }
+
+ if (str_contains($value, '--teamcity')) {
+ unset($args[$key]);
+ $args[] = '--no-output';
+ unset($_SERVER['COLLISION_PRINTER']);
}
}
@@ -63,14 +79,8 @@ use Symfony\Component\Console\Output\OutputInterface;
$container = Container::getInstance();
$container->add(TestSuite::class, $testSuite);
$container->add(OutputInterface::class, $output);
-
- $argsToUnset = ['--test-directory', '--compact', '--profile', '--dirty'];
-
- foreach ($args as $key => $value) {
- if (in_array($value, $argsToUnset)) {
- unset($args[$key]);
- }
- }
+ $container->add(InputInterface::class, $argv);
+ $container->add(Container::class, $container);
$kernel = Kernel::boot();
diff --git a/composer.json b/composer.json
index 557eebcf..367152e8 100644
--- a/composer.json
+++ b/composer.json
@@ -23,6 +23,7 @@
"pestphp/pest-plugin": "^2.0.0",
"phpunit/phpunit": "10.0.x-dev"
},
+ "version": "2.x-dev",
"autoload": {
"psr-4": {
"Pest\\": "src/"
diff --git a/phpunit.xml b/phpunit.xml
index 2811352b..d1b754d0 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -24,6 +24,8 @@
./tests
+ ./tests/.snapshots
+ ./tests/.tests
diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php
index ab31e337..35a46696 100644
--- a/src/Bootstrappers/BootSubscribers.php
+++ b/src/Bootstrappers/BootSubscribers.php
@@ -6,6 +6,7 @@ namespace Pest\Bootstrappers;
use Pest\Contracts\Bootstrapper;
use Pest\Subscribers;
+use Pest\Support\Container;
use PHPUnit\Event;
use PHPUnit\Event\Subscriber;
@@ -25,16 +26,24 @@ final class BootSubscribers implements Bootstrapper
Subscribers\EnsureRetryRepositoryExists::class,
Subscribers\EnsureErroredTestsAreRetryable::class,
Subscribers\EnsureFailedTestsAreRetryable::class,
+ Subscribers\EnsureTeamCityEnabled::class,
];
+ public function __construct(
+ private readonly Container $container,
+ ) {
+ }
+
/**
* Boots the Subscribers.
*/
public function boot(): void
{
foreach (self::SUBSCRIBERS as $subscriber) {
+ /** @var Subscriber $instance */
+ $instance = $this->container->get($subscriber);
Event\Facade::registerSubscriber(
- new $subscriber()
+ $instance
);
}
}
diff --git a/src/Factories/Annotations/AddsAnnotation.php b/src/Factories/Annotations/AddsAnnotation.php
new file mode 100644
index 00000000..1c92ae1f
--- /dev/null
+++ b/src/Factories/Annotations/AddsAnnotation.php
@@ -0,0 +1,21 @@
+ $annotations
+ * @return array
+ */
+ public function __invoke(TestCaseMethodFactory $method, array $annotations): array;
+}
diff --git a/src/Factories/Annotations/CoversNothing.php b/src/Factories/Annotations/CoversNothing.php
index 956a639f..05bb64ef 100644
--- a/src/Factories/Annotations/CoversNothing.php
+++ b/src/Factories/Annotations/CoversNothing.php
@@ -10,7 +10,7 @@ use Pest\Factories\TestCaseMethodFactory;
/**
* @internal
*/
-final class CoversNothing
+final class CoversNothing implements AddsAnnotation
{
/**
* Adds annotations regarding the "depends" feature.
diff --git a/src/Factories/Annotations/Depends.php b/src/Factories/Annotations/Depends.php
index 5647081f..09e38977 100644
--- a/src/Factories/Annotations/Depends.php
+++ b/src/Factories/Annotations/Depends.php
@@ -10,7 +10,7 @@ use Pest\Support\Str;
/**
* @internal
*/
-final class Depends
+final class Depends implements AddsAnnotation
{
/**
* Adds annotations regarding the "depends" feature.
diff --git a/src/Factories/Annotations/Groups.php b/src/Factories/Annotations/Groups.php
index 9fb61f8a..80641123 100644
--- a/src/Factories/Annotations/Groups.php
+++ b/src/Factories/Annotations/Groups.php
@@ -9,7 +9,7 @@ use Pest\Factories\TestCaseMethodFactory;
/**
* @internal
*/
-final class Groups
+final class Groups implements AddsAnnotation
{
/**
* Adds annotations regarding the "groups" feature.
diff --git a/src/Factories/Annotations/TestDox.php b/src/Factories/Annotations/TestDox.php
new file mode 100644
index 00000000..33689395
--- /dev/null
+++ b/src/Factories/Annotations/TestDox.php
@@ -0,0 +1,24 @@
+ $annotations
+ * @return array
+ */
+ public function __invoke(TestCaseMethodFactory $method, array $annotations): array
+ {
+ // First test dox on class overrides the method name.
+ $annotations[] = "@testdox $method->description";
+
+ return $annotations;
+ }
+}
diff --git a/src/Factories/TestCaseFactory.php b/src/Factories/TestCaseFactory.php
index 9a48cd5c..be73592a 100644
--- a/src/Factories/TestCaseFactory.php
+++ b/src/Factories/TestCaseFactory.php
@@ -11,6 +11,7 @@ use Pest\Exceptions\DatasetMissing;
use Pest\Exceptions\ShouldNotHappen;
use Pest\Exceptions\TestAlreadyExist;
use Pest\Exceptions\TestDescriptionMissing;
+use Pest\Factories\Annotations\AddsAnnotation;
use Pest\Factories\Concerns\HigherOrderable;
use Pest\Plugins\Environment;
use Pest\Support\Reflection;
@@ -29,12 +30,13 @@ final class TestCaseFactory
/**
* The list of annotations.
*
- * @var array
+ * @var array>
*/
private const ANNOTATIONS = [
Annotations\Depends::class,
Annotations\Groups::class,
Annotations\CoversNothing::class,
+ Annotations\TestDox::class,
];
/**
@@ -198,6 +200,10 @@ final class TestCaseFactory
use Pest\Repositories\DatasetsRepository as __PestDatasets;
use Pest\TestSuite as __PestTestSuite;
+
+ /**
+ * @testdox $filename
+ */
$classAttributesCode
#[\AllowDynamicProperties]
final class $className extends $baseClass implements $hasPrintableTestCaseClassFQN {
diff --git a/src/Factories/TestCaseMethodFactory.php b/src/Factories/TestCaseMethodFactory.php
index 1bca4dd9..1eb5a6d4 100644
--- a/src/Factories/TestCaseMethodFactory.php
+++ b/src/Factories/TestCaseMethodFactory.php
@@ -6,6 +6,7 @@ namespace Pest\Factories;
use Closure;
use Pest\Exceptions\ShouldNotHappen;
+use Pest\Factories\Annotations\AddsAnnotation;
use Pest\Factories\Concerns\HigherOrderable;
use Pest\Plugins\Retry;
use Pest\Repositories\DatasetsRepository;
@@ -112,7 +113,7 @@ final class TestCaseMethodFactory
/**
* Creates a PHPUnit method as a string ready for evaluation.
*
- * @param array $annotationsToUse
+ * @param array> $annotationsToUse
* @param array> $attributesToUse
*/
public function buildForEvaluation(string $classFQN, array $annotationsToUse, array $attributesToUse): string
@@ -134,7 +135,6 @@ final class TestCaseMethodFactory
$attributes = [];
foreach ($annotationsToUse as $annotation) {
- /** @phpstan-ignore-next-line */
$annotations = (new $annotation())->__invoke($this, $annotations);
}
diff --git a/src/Kernel.php b/src/Kernel.php
index aa8afa34..ac28fada 100644
--- a/src/Kernel.php
+++ b/src/Kernel.php
@@ -73,9 +73,7 @@ final class Kernel
{
$argv = (new Plugins\Actions\CallsHandleArguments())->__invoke($argv);
- $this->application->run(
- $argv, false,
- );
+ $this->application->run($argv, false);
return (new CallsAddsOutput())->__invoke(
Result::exitCode(),
diff --git a/src/Logging/TeamCity/Converter.php b/src/Logging/TeamCity/Converter.php
new file mode 100644
index 00000000..e62d4d61
--- /dev/null
+++ b/src/Logging/TeamCity/Converter.php
@@ -0,0 +1,238 @@
+testDox()->prettifiedMethodName();
+ }
+
+ public function getTestCaseLocation(Test $test): string
+ {
+ if (! $test instanceof TestMethod) {
+ throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
+ }
+
+ $fileName = $test->testDox()->prettifiedClassName();
+ $fileName = $this->cleanPath($fileName);
+
+ // TODO: Get the description without the dataset.
+ $description = $test->testDox()->prettifiedMethodName();
+
+ return "$fileName::$description";
+ }
+
+ public function getExceptionMessage(Throwable $throwable): string
+ {
+ if (is_a($throwable->className(), FrameworkException::class, true)) {
+ return $throwable->message();
+ }
+
+ $buffer = $throwable->className();
+ $throwableMessage = $throwable->message();
+
+ if ($throwableMessage !== '') {
+ $buffer .= ": $throwableMessage";
+ }
+
+ return $buffer;
+ }
+
+ public function getExceptionDetails(Throwable $throwable): string
+ {
+ $buffer = $this->getStackTrace($throwable);
+
+ while ($throwable->hasPrevious()) {
+ $throwable = $throwable->previous();
+
+ $buffer .= sprintf(
+ "\nCaused by\n%s\n%s",
+ $throwable->description(),
+ $this->getStackTrace($throwable)
+ );
+ }
+
+ return $buffer;
+ }
+
+ public function getStackTrace(Throwable $throwable): string
+ {
+ $stackTrace = $throwable->stackTrace();
+
+ // Split stacktrace per frame.
+ $frames = explode("\n", $stackTrace);
+
+ // Remove empty lines
+ $frames = array_filter($frames);
+
+ // clean the paths of each frame.
+ $frames = array_map(
+ fn (string $frame): string => $this->cleanPath($frame),
+ $frames
+ );
+
+ // Format stacktrace as `at `
+ $frames = array_map(
+ fn (string $frame) => "at $frame",
+ $frames
+ );
+
+ return implode("\n", $frames);
+ }
+
+ public function getTestSuiteName(TestSuite $testSuite): string
+ {
+ $name = $testSuite->name();
+
+ if (! str_starts_with($name, self::PREFIX)) {
+ return $name;
+ }
+
+ return Str::after($name, self::PREFIX);
+ }
+
+ public function getTestSuiteLocation(TestSuite $testSuite): string|null
+ {
+ $tests = $testSuite->tests()->asArray();
+
+ // TODO: figure out how to get the file path without a test being there.
+ if ($tests === []) {
+ return null;
+ }
+
+ $firstTest = $tests[0];
+ if (! $firstTest instanceof TestMethod) {
+ throw ShouldNotHappen::fromMessage('Not an instance of TestMethod');
+ }
+
+ $path = $firstTest->testDox()->prettifiedClassName();
+
+ return $this->cleanPath($path);
+ }
+
+ private function cleanPath(string $path): string
+ {
+ // Remove cwd from the path.
+ return str_replace("$this->rootPath/", '', $path);
+ }
+
+ public function getStateFromResult(PhpUnitTestResult $result): State
+ {
+ $state = new State();
+
+ foreach ($result->testErroredEvents() as $resultEvent) {
+ assert($resultEvent instanceof Errored);
+ $state->add(TestResult::fromTestCase(
+ $resultEvent->test(),
+ TestResult::FAIL,
+ $resultEvent->throwable()
+ ));
+ }
+
+ foreach ($result->testFailedEvents() as $resultEvent) {
+ $state->add(TestResult::fromTestCase(
+ $resultEvent->test(),
+ TestResult::FAIL,
+ $resultEvent->throwable()
+ ));
+ }
+
+ foreach ($result->testMarkedIncompleteEvents() as $resultEvent) {
+ $state->add(TestResult::fromTestCase(
+ $resultEvent->test(),
+ TestResult::INCOMPLETE,
+ $resultEvent->throwable()
+ ));
+ }
+
+ foreach ($result->testConsideredRiskyEvents() as $riskyEvents) {
+ foreach ($riskyEvents as $riskyEvent) {
+ $state->add(TestResult::fromTestCase(
+ $riskyEvent->test(),
+ TestResult::RISKY,
+ Throwable::from(new IncompleteTestError($riskyEvent->message()))
+ ));
+ }
+ }
+
+ foreach ($result->testSkippedEvents() as $resultEvent) {
+ if ($resultEvent->message() === '__TODO__') {
+ $state->add(TestResult::fromTestCase($resultEvent->test(), TestResult::TODO));
+
+ continue;
+ }
+
+ $state->add(TestResult::fromTestCase(
+ $resultEvent->test(),
+ TestResult::SKIPPED,
+ Throwable::from(new SkippedWithMessageException($resultEvent->message()))
+ ));
+ }
+
+ $numberOfPassedTests = $result->numberOfTests()
+ - $result->numberOfTestErroredEvents()
+ - $result->numberOfTestFailedEvents()
+ - $result->numberOfTestSkippedEvents()
+ - $result->numberOfTestsWithTestConsideredRiskyEvents()
+ - $result->numberOfTestMarkedIncompleteEvents();
+
+ for ($i = 0; $i < $numberOfPassedTests; $i++) {
+ $state->add(TestResult::fromTestCase(
+
+ new TestMethod(
+ /** @phpstan-ignore-next-line */
+ "$i",
+ /** @phpstan-ignore-next-line */
+ '',
+ '',
+ 1,
+ /** @phpstan-ignore-next-line */
+ TestDox::fromClassNameAndMethodName('', ''),
+ MetadataCollection::fromArray([]),
+ TestDataCollection::fromArray([])
+ ),
+ TestResult::PASS
+ ));
+ }
+
+ return $state;
+ }
+}
diff --git a/src/Logging/TeamCity/ServiceMessage.php b/src/Logging/TeamCity/ServiceMessage.php
new file mode 100644
index 00000000..61be652e
--- /dev/null
+++ b/src/Logging/TeamCity/ServiceMessage.php
@@ -0,0 +1,133 @@
+ $parameters
+ */
+ public function __construct(
+ private readonly string $type,
+ private readonly array $parameters,
+ ) {
+ }
+
+ public function toString(): string
+ {
+ $paramsToString = '';
+
+ foreach ([...$this->parameters, 'flowId' => self::$flowId] as $key => $value) {
+ $value = self::escapeServiceMessage((string) $value);
+ $paramsToString .= " $key='$value'";
+ }
+
+ return "##teamcity[$this->type$paramsToString]";
+ }
+
+ public static function testSuiteStarted(string $name, string|null $location): self
+ {
+ return new self('testSuiteStarted', [
+ 'name' => $name,
+ 'locationHint' => $location === null ? null : "file://$location",
+ ]);
+ }
+
+ public static function testSuiteFinished(string $name): self
+ {
+ return new self('testSuiteFinished', [
+ 'name' => $name,
+ ]);
+ }
+
+ public static function testStarted(string $name, string $location): self
+ {
+ return new self('testStarted', [
+ 'name' => $name,
+ 'locationHint' => "pest_qn://$location",
+ ]);
+ }
+
+ /**
+ * @param int $duration in milliseconds
+ */
+ public static function testFinished(string $name, int $duration): self
+ {
+ return new self('testFinished', [
+ 'name' => $name,
+ 'duration' => $duration,
+ ]);
+ }
+
+ public static function testStdOut(string $name, string $data): self
+ {
+ if (! str_ends_with($data, "\n")) {
+ $data .= "\n";
+ }
+
+ return new self('testStdOut', [
+ 'name' => $name,
+ 'out' => $data,
+ ]);
+ }
+
+ public static function testFailed(string $name, string $message, string $details): self
+ {
+ return new self('testFailed', [
+ 'name' => $name,
+ 'message' => $message,
+ 'details' => $details,
+ ]);
+ }
+
+ public static function testStdErr(string $name, string $data): self
+ {
+ if (! str_ends_with($data, "\n")) {
+ $data .= "\n";
+ }
+
+ return new self('testStdErr', [
+ 'name' => $name,
+ 'out' => $data,
+ ]);
+ }
+
+ public static function testIgnored(string $name, string $message, string $details = null): self
+ {
+ return new self('testIgnored', [
+ 'name' => $name,
+ 'message' => $message,
+ 'details' => $details,
+ ]);
+ }
+
+ public static function comparisonFailure(string $name, string $message, string $details, string $actual, string $expected): self
+ {
+ return new self('testFailed', [
+ 'name' => $name,
+ 'message' => $message,
+ 'details' => $details,
+ 'type' => 'comparisonFailure',
+ 'actual' => $actual,
+ 'expected' => $expected,
+ ]);
+ }
+
+ private static function escapeServiceMessage(string $text): string
+ {
+ return str_replace(
+ ['|', "'", "\n", "\r", ']', '['],
+ ['||', "|'", '|n', '|r', '|]', '|['],
+ $text
+ );
+ }
+
+ public static function setFlowId(int $flowId): void
+ {
+ self::$flowId = $flowId;
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/Subscriber.php b/src/Logging/TeamCity/Subscriber/Subscriber.php
new file mode 100644
index 00000000..1ea92483
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/Subscriber.php
@@ -0,0 +1,22 @@
+logger;
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestConsideredRiskySubscriber.php b/src/Logging/TeamCity/Subscriber/TestConsideredRiskySubscriber.php
new file mode 100644
index 00000000..75a49997
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestConsideredRiskySubscriber.php
@@ -0,0 +1,19 @@
+logger()->testConsideredRisky($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php b/src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php
new file mode 100644
index 00000000..29c61627
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestErroredSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testErrored($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestExecutionFinishedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestExecutionFinishedSubscriber.php
new file mode 100644
index 00000000..3592b6b9
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestExecutionFinishedSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testExecutionFinished($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php
new file mode 100644
index 00000000..ec25ce02
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestFailedSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testFailed($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php
new file mode 100644
index 00000000..8e04c338
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestFinishedSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testFinished($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestMarkedIncompleteSubscriber.php b/src/Logging/TeamCity/Subscriber/TestMarkedIncompleteSubscriber.php
new file mode 100644
index 00000000..b9c4aa66
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestMarkedIncompleteSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testMarkedIncomplete($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php
new file mode 100644
index 00000000..7b62b072
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestPreparedSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testPrepared($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php
new file mode 100644
index 00000000..9e6b284b
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestSkippedSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testSkipped($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestSuiteFinishedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestSuiteFinishedSubscriber.php
new file mode 100644
index 00000000..c4dd32c7
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestSuiteFinishedSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testSuiteFinished($event);
+ }
+}
diff --git a/src/Logging/TeamCity/Subscriber/TestSuiteStartedSubscriber.php b/src/Logging/TeamCity/Subscriber/TestSuiteStartedSubscriber.php
new file mode 100644
index 00000000..7c5d5dea
--- /dev/null
+++ b/src/Logging/TeamCity/Subscriber/TestSuiteStartedSubscriber.php
@@ -0,0 +1,19 @@
+logger()->testSuiteStarted($event);
+ }
+}
diff --git a/src/Logging/TeamCity/TeamCityLogger.php b/src/Logging/TeamCity/TeamCityLogger.php
new file mode 100644
index 00000000..3ab17550
--- /dev/null
+++ b/src/Logging/TeamCity/TeamCityLogger.php
@@ -0,0 +1,251 @@
+registerSubscribers();
+ $this->setFlowId();
+ }
+
+ public function testSuiteStarted(TestSuiteStarted $event): void
+ {
+ $message = ServiceMessage::testSuiteStarted(
+ $this->converter->getTestSuiteName($event->testSuite()),
+ $this->converter->getTestSuiteLocation($event->testSuite())
+ );
+
+ $this->output($message);
+ }
+
+ public function testSuiteFinished(TestSuiteFinished $event): void
+ {
+ $message = ServiceMessage::testSuiteFinished(
+ $this->converter->getTestSuiteName($event->testSuite()),
+ );
+
+ $this->output($message);
+ }
+
+ public function testPrepared(Prepared $event): void
+ {
+ $message = ServiceMessage::testStarted(
+ $this->converter->getTestCaseMethodName($event->test()),
+ $this->converter->getTestCaseLocation($event->test()),
+ );
+
+ $this->output($message);
+
+ $this->time = $event->telemetryInfo()->time();
+ }
+
+ public function testMarkedIncomplete(MarkedIncomplete $event): never
+ {
+ // TODO: when does this trigger?
+ throw ShouldNotHappen::fromMessage('testMarkedIncomplete not implemented.');
+ }
+
+ public function testSkipped(Skipped $event): void
+ {
+ $message = ServiceMessage::testIgnored(
+ $this->converter->getTestCaseMethodName($event->test()),
+ 'This test was ignored.'
+ );
+
+ $this->output($message);
+ }
+
+ /**
+ * This will trigger in the following scenarios
+ * - When an exception is thrown
+ */
+ public function testErrored(Errored $event): void
+ {
+ $testName = $this->converter->getTestCaseMethodName($event->test());
+ $message = $this->converter->getExceptionMessage($event->throwable());
+ $details = $this->converter->getExceptionDetails($event->throwable());
+
+ $message = ServiceMessage::testFailed(
+ $testName,
+ $message,
+ $details,
+ );
+
+ $this->output($message);
+ }
+
+ /**
+ * This will trigger in the following scenarios
+ * - When an assertion fails
+ */
+ public function testFailed(Failed $event): void
+ {
+ $testName = $this->converter->getTestCaseMethodName($event->test());
+ $message = $this->converter->getExceptionMessage($event->throwable());
+ $details = $this->converter->getExceptionDetails($event->throwable());
+
+ if ($event->hasComparisonFailure()) {
+ $comparison = $event->comparisonFailure();
+ $message = ServiceMessage::comparisonFailure(
+ $testName,
+ $message,
+ $details,
+ $comparison->actual(),
+ $comparison->expected()
+ );
+ } else {
+ $message = ServiceMessage::testFailed(
+ $testName,
+ $message,
+ $details,
+ );
+ }
+
+ $this->output($message);
+ }
+
+ /**
+ * This will trigger in the following scenarios
+ * - When no assertions in a test
+ */
+ public function testConsideredRisky(ConsideredRisky $event): void
+ {
+ $message = ServiceMessage::testIgnored(
+ $this->converter->getTestCaseMethodName($event->test()),
+ $event->message()
+ );
+
+ $this->output($message);
+ }
+
+ public function testFinished(Finished $event): void
+ {
+ if ($this->time === null) {
+ throw ShouldNotHappen::fromMessage('Start time has not been set.');
+ }
+
+ $testName = $this->converter->getTestCaseMethodName($event->test());
+ $duration = $event->telemetryInfo()->time()->duration($this->time)->asFloat();
+ if ($this->withoutDuration) {
+ $duration = 100;
+ }
+
+ $message = ServiceMessage::testFinished(
+ $testName,
+ (int) ($duration * 1000)
+ );
+
+ $this->output($message);
+ }
+
+ public function testExecutionFinished(ExecutionFinished $event): void
+ {
+ $result = TestResultFacade::result();
+ $state = $this->converter->getStateFromResult($result);
+
+ assert($this->output instanceof ConsoleOutput);
+ $style = new Style($this->output);
+
+ $telemetry = $event->telemetryInfo();
+ if ($this->withoutDuration) {
+ $telemetry = new Info(
+ new Snapshot(
+ $telemetry->time(),
+ $telemetry->memoryUsage(),
+ $telemetry->peakMemoryUsage(),
+ ),
+ Duration::fromSecondsAndNanoseconds(1, 0),
+ $telemetry->memoryUsageSinceStart(),
+ $telemetry->durationSincePrevious(),
+ $telemetry->memoryUsageSincePrevious(),
+ $telemetry->emitter(),
+ );
+ }
+
+ $style->writeRecap($state, $telemetry);
+ }
+
+ public function output(ServiceMessage $message): void
+ {
+ $this->output->writeln("{$message->toString()}");
+ }
+
+ /**
+ * @throws EventFacadeIsSealedException
+ * @throws UnknownSubscriberTypeException
+ */
+ private function registerSubscribers(): void
+ {
+ Facade::registerSubscribers(
+ new TestSuiteStartedSubscriber($this),
+ new TestSuiteFinishedSubscriber($this),
+ new TestPreparedSubscriber($this),
+ new TestFinishedSubscriber($this),
+ new TestErroredSubscriber($this),
+ new TestFailedSubscriber($this),
+ new TestMarkedIncompleteSubscriber($this),
+ new TestSkippedSubscriber($this),
+ new TestConsideredRiskySubscriber($this),
+ new TestExecutionFinishedSubscriber($this),
+ );
+ }
+
+ private function setFlowId(): void
+ {
+ if ($this->flowId === null) {
+ return;
+ }
+
+ ServiceMessage::setFlowId($this->flowId);
+ }
+}
diff --git a/src/Repositories/TestRepository.php b/src/Repositories/TestRepository.php
index eb4e4936..4cb98940 100644
--- a/src/Repositories/TestRepository.php
+++ b/src/Repositories/TestRepository.php
@@ -29,7 +29,7 @@ final class TestRepository
private array $uses = [];
/**
- * @var array
+ * @var array
*/
private array $filters = [];
diff --git a/src/Subscribers/EnsureTeamCityEnabled.php b/src/Subscribers/EnsureTeamCityEnabled.php
new file mode 100644
index 00000000..8e1f5d6e
--- /dev/null
+++ b/src/Subscribers/EnsureTeamCityEnabled.php
@@ -0,0 +1,46 @@
+input->hasParameterOption('--teamcity')) {
+ return;
+ }
+
+ $flowId = getenv('FLOW_ID');
+ $flowId = is_string($flowId) ? (int) $flowId : getmypid();
+
+ new TeamCityLogger(
+ $this->output,
+ new Converter($this->testSuite->rootPath),
+ $flowId === false ? null : $flowId,
+ getenv('COLLISION_IGNORE_DURATION') !== false
+ );
+ }
+}
diff --git a/src/Support/ExceptionTrace.php b/src/Support/ExceptionTrace.php
index faa5080b..014b88df 100644
--- a/src/Support/ExceptionTrace.php
+++ b/src/Support/ExceptionTrace.php
@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Support;
use Closure;
-use ReflectionProperty;
use Throwable;
/**
@@ -37,32 +36,4 @@ final class ExceptionTrace
throw $throwable;
}
}
-
- /**
- * Removes any item from the stack trace referencing Pest so as not to
- * crowd the error log for the end user.
- */
- public static function removePestReferences(Throwable $t): void
- {
- if (! property_exists($t, 'serializableTrace')) {
- return;
- }
-
- $property = new ReflectionProperty($t, 'serializableTrace');
- $property->setAccessible(true);
-
- /** @var array> $trace */
- $trace = $property->getValue($t);
-
- $cleanedTrace = [];
- foreach ($trace as $item) {
- if (array_key_exists('file', $item) && mb_strpos($item['file'], 'vendor/pestphp/pest/') > 0) {
- continue;
- }
-
- $cleanedTrace[] = $item;
- }
-
- $property->setValue($t, $cleanedTrace);
- }
}
diff --git a/src/Support/Str.php b/src/Support/Str.php
index 6ceaed1d..1ad98a77 100644
--- a/src/Support/Str.php
+++ b/src/Support/Str.php
@@ -76,4 +76,9 @@ final class Str
return substr($subject, 0, $pos);
}
+
+ public static function after(string $subject, string $search): string
+ {
+ return $search === '' ? $subject : array_reverse(explode($search, $subject, 2))[0];
+ }
}
diff --git a/tests/.snapshots/Failure.php.inc b/tests/.snapshots/Failure.php.inc
new file mode 100644
index 00000000..10207012
--- /dev/null
+++ b/tests/.snapshots/Failure.php.inc
@@ -0,0 +1,24 @@
+##teamcity[testSuiteStarted name='Tests\tests\Failure' locationHint='file://tests/.tests/Failure.php' flowId='1234']
+##teamcity[testStarted name='it can fail with comparison' locationHint='pest_qn://tests/.tests/Failure.php::it can fail with comparison' flowId='1234']
+##teamcity[testFailed name='it can fail with comparison' message='Failed asserting that true matches expected false.' details='at src/Mixins/Expectation.php:312|nat src/Support/ExpectationPipeline.php:75|nat src/Support/ExpectationPipeline.php:79|nat src/Expectation.php:300|nat tests/.tests/Failure.php:6|nat src/Factories/TestCaseMethodFactory.php:101|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:76' type='comparisonFailure' actual='true' expected='false' flowId='1234']
+##teamcity[testFinished name='it can fail with comparison' duration='100000' flowId='1234']
+##teamcity[testStarted name='it can be ignored because of no assertions' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because of no assertions' flowId='1234']
+##teamcity[testIgnored name='it can be ignored because of no assertions' message='This test did not perform any assertions' details='' flowId='1234']
+##teamcity[testFinished name='it can be ignored because of no assertions' duration='100000' flowId='1234']
+##teamcity[testStarted name='it can be ignored because it is skipped' locationHint='pest_qn://tests/.tests/Failure.php::it can be ignored because it is skipped' flowId='1234']
+##teamcity[testIgnored name='it can be ignored because it is skipped' message='This test was ignored.' details='' flowId='1234']
+##teamcity[testFinished name='it can be ignored because it is skipped' duration='100000' flowId='1234']
+##teamcity[testStarted name='it can fail' locationHint='pest_qn://tests/.tests/Failure.php::it can fail' flowId='1234']
+##teamcity[testFailed name='it can fail' message='oh noo' details='at tests/.tests/Failure.php:18|nat src/Factories/TestCaseMethodFactory.php:101|nat src/Concerns/Testable.php:262|nat src/Support/ExceptionTrace.php:28|nat src/Concerns/Testable.php:262|nat src/Concerns/Testable.php:217|nat src/Kernel.php:76' flowId='1234']
+##teamcity[testFinished name='it can fail' duration='100000' flowId='1234']
+##teamcity[testStarted name='it is not done yet' locationHint='pest_qn://tests/.tests/Failure.php::it is not done yet' flowId='1234']
+##teamcity[testIgnored name='it is not done yet' message='This test was ignored.' details='' flowId='1234']
+##teamcity[testFinished name='it is not done yet' duration='100000' flowId='1234']
+##teamcity[testStarted name='build this one.' locationHint='pest_qn://tests/.tests/Failure.php::build this one.' flowId='1234']
+##teamcity[testIgnored name='build this one.' message='This test was ignored.' details='' flowId='1234']
+##teamcity[testFinished name='build this one.' duration='100000' flowId='1234']
+##teamcity[testSuiteFinished name='Tests\tests\Failure' flowId='1234']
+
+ [90mTests:[39m [31;1m2 failed[39;22m[90m,[39m[39m [39m[33;1m1 risky[39;22m[90m,[39m[39m [39m[36;1m2 todos[39;22m[90m,[39m[39m [39m[33;1m1 skipped[39;22m[90m (2 assertions)[39m
+ [90mDuration:[39m [39m1.00s[39m
+
diff --git a/tests/.snapshots/SuccessOnly.php.inc b/tests/.snapshots/SuccessOnly.php.inc
new file mode 100644
index 00000000..cdcb71a9
--- /dev/null
+++ b/tests/.snapshots/SuccessOnly.php.inc
@@ -0,0 +1,10 @@
+##teamcity[testSuiteStarted name='Tests\tests\SuccessOnly' locationHint='file://tests/.tests/SuccessOnly.php' flowId='1234']
+##teamcity[testStarted name='it can pass with comparison' locationHint='pest_qn://tests/.tests/SuccessOnly.php::it can pass with comparison' flowId='1234']
+##teamcity[testFinished name='it can pass with comparison' duration='100000' flowId='1234']
+##teamcity[testStarted name='can also pass' locationHint='pest_qn://tests/.tests/SuccessOnly.php::can also pass' flowId='1234']
+##teamcity[testFinished name='can also pass' duration='100000' flowId='1234']
+##teamcity[testSuiteFinished name='Tests\tests\SuccessOnly' flowId='1234']
+
+ [90mTests:[39m [32;1m2 passed[39;22m[90m (2 assertions)[39m
+ [90mDuration:[39m [39m1.00s[39m
+
diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt
index e91aae32..7d26d0b0 100644
--- a/tests/.snapshots/success.txt
+++ b/tests/.snapshots/success.txt
@@ -893,9 +893,10 @@
- visual snapshot of test suite on success
WARN Tests\Visual\TeamCity
- - it is can successfully call all public methods → Not supported yet.
+ - visual snapshot of team city with ('Failure.php')
+ - visual snapshot of team city with ('SuccessOnly.php')
PASS Tests\Visual\Version
✓ visual snapshot of help command output
- Tests: 4 incomplete, 4 todos, 17 skipped, 624 passed (1511 assertions)
\ No newline at end of file
+ Tests: 4 incomplete, 4 todos, 18 skipped, 624 passed (1511 assertions)
\ No newline at end of file
diff --git a/tests/.tests/Failure.php b/tests/.tests/Failure.php
new file mode 100644
index 00000000..90d92593
--- /dev/null
+++ b/tests/.tests/Failure.php
@@ -0,0 +1,26 @@
+toEqual(false);
+});
+
+it('can be ignored because of no assertions', function () {
+
+});
+
+it('can be ignored because it is skipped', function () {
+ expect(true)->toBeTrue();
+})->skip("this is why");
+
+it('can fail', function () {
+ $this->fail("oh noo");
+});
+
+it('is not done yet', function () {
+
+})->todo();
+
+todo("build this one.");
+
diff --git a/tests/.tests/SuccessOnly.php b/tests/.tests/SuccessOnly.php
new file mode 100644
index 00000000..38672a0d
--- /dev/null
+++ b/tests/.tests/SuccessOnly.php
@@ -0,0 +1,11 @@
+toEqual(true);
+});
+
+test('can also pass', function () {
+ expect("string")->toBeString();
+});
diff --git a/tests/Visual/TeamCity.php b/tests/Visual/TeamCity.php
index a3f9e7b2..0ea3bc08 100644
--- a/tests/Visual/TeamCity.php
+++ b/tests/Visual/TeamCity.php
@@ -1,32 +1,42 @@
toBeTrue();
- $teamCity->startTestSuite(new TestSuite());
- $teamCity->startTest($this);
- $teamCity->addError($this, new Exception('Don\'t worry about this error. Its purposeful.'), 0);
- $teamCity->addFailure($this, new AssertionFailedError('Don\'t worry about this error. Its purposeful.'), 0);
- $teamCity->addWarning($this, new Warning(), 0);
- $teamCity->addIncompleteTest($this, new Exception(), 0);
- $teamCity->addRiskyTest($this, new Exception(), 0);
- $teamCity->addSkippedTest($this, new Exception(), 0);
- $teamCity->endTest($this, 0);
- $teamCity->printResult(new TestResult());
- $teamCity->endTestSuite(new TestSuite());
-})->skip('Not supported yet.');
+ $output = function () use ($testsPath) {
+ $process = (new Symfony\Component\Process\Process(
+ ['php', 'bin/pest', '--teamcity', $testsPath],
+ dirname(__DIR__, levels: 2),
+ [
+ 'EXCLUDE' => 'integration',
+ 'REBUILD_SNAPSHOTS' => false,
+ 'PARATEST' => 0,
+ 'COLLISION_IGNORE_DURATION' => 'true',
+ 'FLOW_ID' => '1234',
+ ],
+ ));
-afterEach(function () {
- unlink(__DIR__.'/output.txt');
-});
+ $process->run();
+
+ return $process->getOutput();
+ };
+
+ if (getenv('REBUILD_SNAPSHOTS')) {
+ $outputContent = explode("\n", $output());
+
+ file_put_contents($snapshot, implode("\n", $outputContent));
+ } elseif (! getenv('EXCLUDE')) {
+ $output = explode("\n", $output());
+
+ expect(implode("\n", $output))->toEqual(file_get_contents($snapshot));
+ }
+})->with([
+ 'Failure.php',
+ 'SuccessOnly.php',
+])->skip(! getenv('REBUILD_SNAPSHOTS') && getenv('EXCLUDE'));