Skip to content
2 changes: 1 addition & 1 deletion .php-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
8.3.30
8.3.31
223 changes: 120 additions & 103 deletions composer.lock

Large diffs are not rendered by default.

107 changes: 67 additions & 40 deletions src/Config/ReporterConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace WebProject\Codeception\Module\AiReporter\Config;

use function implode;
use function in_array;
use InvalidArgumentException;
use function is_bool;
Expand Down Expand Up @@ -54,59 +55,85 @@ private function __construct(
public static function fromArray(array $raw, string $defaultOutputDir, string $projectRoot): self
{
/** @var self::FORMAT_* $format */
$format = $raw['format'] ?? self::FORMAT_BOTH;
// @phpstan-ignore-next-line
if (!is_string($format) || !in_array($format, [self::FORMAT_TEXT, self::FORMAT_JSON, self::FORMAT_BOTH], true)) {
throw new InvalidArgumentException('Invalid `format`; expected one of: text, json, both.');
$format = self::readEnum(
$raw['format'] ?? null,
'format',
[self::FORMAT_TEXT, self::FORMAT_JSON, self::FORMAT_BOTH],
self::FORMAT_BOTH,
);

$output = self::readOutput($raw['output'] ?? null, $defaultOutputDir);
$resolved = self::resolvePath($output, $projectRoot);
$trimmed = rtrim($resolved, '/\\');

/** @var non-empty-string $outputDir */
$outputDir = '' === $trimmed ? $resolved : $trimmed;

return new self(
format: $format,
outputDir: $outputDir,
maxFrames: self::readPositiveInt($raw['max_frames'] ?? null, 'max_frames', self::DEFAULT_MAX_FRAMES),
includeSteps: self::readBool($raw['include_steps'] ?? null, 'include_steps', true),
includeArtifacts: self::readBool($raw['include_artifacts'] ?? null, 'include_artifacts', true),
compactPaths: self::readBool($raw['compact_paths'] ?? null, 'compact_paths', true),
);
}

/**
* @param list<string> $allowed
*/
private static function readEnum(mixed $value, string $field, array $allowed, string $default): string
{
if (null === $value) {
return $default;
}
if (!is_string($value) || !in_array($value, $allowed, true)) {
throw new InvalidArgumentException(sprintf('Invalid `%s`; expected one of: %s.', $field, implode(', ', $allowed)));
}

$output = $raw['output'] ?? $defaultOutputDir;
if ('' === $output) {
$output = $defaultOutputDir;
return $value;
}

/** @param non-empty-string $default */
private static function readOutput(mixed $value, string $default): string
{
if (null === $value || '' === $value) {
return $default;
}
// @phpstan-ignore-next-line
if (!is_string($output) || '' === $output) {
if (!is_string($value)) {
throw new InvalidArgumentException('Invalid `output`; expected a non-empty directory path.');
}

$outputDir = self::resolvePath($output, $projectRoot);
return $value;
}

/** @var int<1, max> $maxFrames */
$maxFrames = $raw['max_frames'] ?? self::DEFAULT_MAX_FRAMES;
// @phpstan-ignore-next-line
if (!is_int($maxFrames) || $maxFrames < 1) {
throw new InvalidArgumentException('Invalid `max_frames`; expected a positive integer.');
/**
* @param int<1, max> $default
*
* @return int<1, max>
*/
private static function readPositiveInt(mixed $value, string $field, int $default): int
{
if (null === $value) {
return $default;
}

$includeSteps = $raw['include_steps'] ?? true;
// @phpstan-ignore-next-line
if (!is_bool($includeSteps)) {
throw new InvalidArgumentException('Invalid `include_steps`; expected boolean.');
if (!is_int($value) || $value < 1) {
throw new InvalidArgumentException(sprintf('Invalid `%s`; expected a positive integer.', $field));
}

$includeArtifacts = $raw['include_artifacts'] ?? true;
// @phpstan-ignore-next-line
if (!is_bool($includeArtifacts)) {
throw new InvalidArgumentException('Invalid `include_artifacts`; expected boolean.');
}
return $value;
}

$compactPaths = $raw['compact_paths'] ?? true;
// @phpstan-ignore-next-line
if (!is_bool($compactPaths)) {
throw new InvalidArgumentException('Invalid `compact_paths`; expected boolean.');
private static function readBool(mixed $value, string $field, bool $default): bool
{
if (null === $value) {
return $default;
}
if (!is_bool($value)) {
throw new InvalidArgumentException(sprintf('Invalid `%s`; expected boolean.', $field));
}

/** @var non-empty-string $outputDir */
$outputDir = rtrim($outputDir, '/\\');

return new self(
format: $format,
outputDir: $outputDir,
maxFrames: $maxFrames,
includeSteps: $includeSteps,
includeArtifacts: $includeArtifacts,
compactPaths: $compactPaths,
);
return $value;
}

public function wantsJson(): bool
Expand Down
77 changes: 42 additions & 35 deletions src/Extension/AiReporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

namespace WebProject\Codeception\Module\AiReporter\Extension;

use function array_map;
use function array_slice;
use function array_values;
use Codeception\Event\FailEvent;
use Codeception\Event\PrintResultEvent;
use Codeception\Event\SuiteEvent;
Expand Down Expand Up @@ -129,8 +129,7 @@ public function _initialize(): void

public function beforeSuite(SuiteEvent $event): void
{
$suite = $event->getSuite();
$this->currentSuite = null !== $suite ? $suite->getName() : '';
$this->currentSuite = $event->getSuite()?->getName() ?? '';
}

public function onFailure(FailEvent $event): void
Expand Down Expand Up @@ -201,7 +200,7 @@ private function captureFailure(string $status, FailEvent $event): void
? $this->scenarioExtractor->extract($test, $this->runtimeConfig->maxFrames())
: [];

$hints = array_values($this->hintGenerator->generate($throwable, $trace, $scenarioSteps));
$hints = $this->hintGenerator->generate($throwable, $trace, $scenarioSteps);

$exception = [
'class' => $throwable::class,
Expand Down Expand Up @@ -259,42 +258,53 @@ private function printInlineContext(array $failure): void
$artifacts = $failure['artifacts'];

$this->writeln(' <comment>AI Context</comment>');
$this->writeln(sprintf(' Test failed: %s', $this->consoleText->escape($failure['test']['full_name'])));
$this->writeln(sprintf(' Exception: %s', $this->consoleText->escape($exception['class'])));
$this->writeln(sprintf(' Message: %s', $this->consoleText->escape($this->consoleText->truncate($exception['message']))));

if (isset($exception['comparison_diff']) && '' !== $exception['comparison_diff']) {
$this->writeln(' Diff:');
foreach (explode("\n", $exception['comparison_diff']) as $diffLine) {
$this->writeln(sprintf(' %s', $this->consoleText->escape($diffLine)));
}
$diff = $exception['comparison_diff'] ?? '';
$this->printSection('Diff', '' === $diff ? [] : array_map(
fn (string $line): string => $this->consoleText->escape($line),
explode("\n", $diff),
));

$traceLines = [];
foreach (array_slice($trace, 0, $this->runtimeConfig->maxFrames()) as $index => $frame) {
$traceLines[] = sprintf('#%d %s', $index + 1, $this->consoleText->escape($this->traceFrameProcessor->formatFrame($frame)));
}
$this->printSection('Trace', $traceLines);

if ([] !== $trace) {
$this->writeln(' Trace:');
foreach (array_slice($trace, 0, $this->runtimeConfig->maxFrames()) as $index => $frame) {
$this->writeln(sprintf(' #%d %s', $index + 1, $this->consoleText->escape($this->traceFrameProcessor->formatFrame($frame))));
}
$stepLines = [];
foreach (array_slice($steps, 0, 2) as $step) {
$stepLines[] = sprintf('- %s', $this->consoleText->escape($step['step']));
}
$this->printSection('Scenario', $stepLines);

if ([] !== $steps) {
$this->writeln(' Scenario:');
foreach (array_slice($steps, 0, 2) as $step) {
$this->writeln(sprintf(' - %s', $this->consoleText->escape($step['step'])));
}
$artifactLines = [];
foreach ($artifacts as $type => $path) {
$artifactLines[] = sprintf('- %s: %s', $this->consoleText->escape($type), $this->consoleText->escape($path));
}
$this->printSection('Artifacts', $artifactLines);

if ([] !== $artifacts) {
$this->writeln(' Artifacts:');
foreach ($artifacts as $type => $path) {
$this->writeln(sprintf(' - %s: %s', $this->consoleText->escape($type), $this->consoleText->escape($path)));
}
$hintLines = [];
foreach (array_slice($hints, 0, 3) as $hint) {
$hintLines[] = sprintf('- %s', $this->consoleText->escape($hint));
}
$this->printSection('Hints', $hintLines);
}

if ([] !== $hints) {
$this->writeln(' Hints:');
foreach (array_slice($hints, 0, 3) as $hint) {
$this->writeln(sprintf(' - %s', $this->consoleText->escape($hint)));
}
/**
* @param list<string> $lines
*/
private function printSection(string $title, array $lines): void
{
if ([] === $lines) {
return;
}

$this->writeln(sprintf(' %s:', $title));
foreach ($lines as $line) {
$this->writeln(sprintf(' %s', $line));
}
}

Expand Down Expand Up @@ -359,12 +369,9 @@ private function normalizeArtifacts(array $reports): array
{
$normalized = [];
foreach ($reports as $type => $path) {
if (is_scalar($path)) {
$normalized[(string) $type] = $this->pathNormalizer->normalize((string) $path);
continue;
}

$normalized[(string) $type] = (string) json_encode($path, JSON_INVALID_UTF8_SUBSTITUTE);
$normalized[(string) $type] = is_scalar($path)
? $this->pathNormalizer->normalize((string) $path)
: (string) json_encode($path, JSON_INVALID_UTF8_SUBSTITUTE);
}

return $normalized;
Expand Down
10 changes: 5 additions & 5 deletions src/Report/PathNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@

final class PathNormalizer
{
private string $normalizedRoot;
private string $normalizedRootLower;
private readonly string $normalizedRoot;
private readonly string $normalizedRootLower;

public function __construct(string $projectRoot, private readonly bool $compactPaths)
{
$normalized = str_replace('\\', '/', rtrim($projectRoot, '/\\'));
$this->normalizedRoot = $normalized . '/';
$this->normalizedRootLower = strtolower($this->normalizedRoot);
$normalized = str_replace('\\', '/', rtrim($projectRoot, '/\\'));
$this->normalizedRoot = $normalized . '/';
$this->normalizedRootLower = strtolower($this->normalizedRoot);
}

/**
Expand Down
8 changes: 2 additions & 6 deletions src/Util/TraceFrameProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,8 @@ private function isNoiseFrame(array $frame): bool
$file = (string)($frame['file'] ?? '');
$call = (string)($frame['call'] ?? '');

if ($file !== '' && !$this->isFrameworkFile($file)) {
return false;
}

if ($file !== '' && $this->isFrameworkFile($file)) {
return true;
if ($file !== '') {
return $this->isFrameworkFile($file);
}

return str_starts_with($call, '[throw] PHPUnit\\Framework\\')
Expand Down
36 changes: 36 additions & 0 deletions tests/Support/Fixture/CapturingOutput.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace WebProject\Codeception\Module\AiReporter\Tests\Support\Fixture;

use Codeception\Lib\Console\Output;

/**
* Buffers console writes so tests can assert on inline AI Context output.
*/
final class CapturingOutput extends Output
{
private string $buffer = '';

public function __construct()
{
parent::__construct(['colors' => false, 'interactive' => false]);
}

protected function doWrite(string $message, bool $newline): void
{
$this->buffer .= $message;
if ($newline) {
$this->buffer .= "\n";
}
}

public function fetch(): string
{
$out = $this->buffer;
$this->buffer = '';

return $out;
}
}
15 changes: 15 additions & 0 deletions tests/Support/Fixture/PathNormalizerFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace WebProject\Codeception\Module\AiReporter\Tests\Support\Fixture;

use WebProject\Codeception\Module\AiReporter\Report\PathNormalizer;

final class PathNormalizerFactory
{
public static function make(bool $compactPaths = true, string $projectRoot = '/repo/project'): PathNormalizer
{
return new PathNormalizer($projectRoot, $compactPaths);
}
}
43 changes: 43 additions & 0 deletions tests/Support/Fixture/StubTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php

declare(strict_types=1);

namespace WebProject\Codeception\Module\AiReporter\Tests\Support\Fixture;

use Codeception\Test\Metadata;
use Codeception\Test\Test;

/**
* Minimal Codeception Test used to drive FailEvent dispatching in unit tests.
*/
final class StubTest extends Test
{
public function __construct(
string $name,
string $filename,
private readonly string $signature,
) {
$metadata = new Metadata();
$metadata->setName($name);
$metadata->setFilename($filename);
$this->setMetadata($metadata);
}

public function test(): void
{
}

public function run(): void
{
}

public function toString(): string
{
return $this->getName();
}

public function getSignature(): string
{
return $this->signature;
}
}
Loading