Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Dockerfile export-ignore
infection.* export-ignore
tests export-ignore
docs export-ignore
resources/mock export-ignore
CLAUDE.local.md export-ignore

*.http binary
*.gpg binary
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[submodule "docs/guidelines"]
path = docs/guidelines
url = https://github.com/php-internal/guidelines
10 changes: 10 additions & 0 deletions .php-cs-fixer.dist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

require_once 'vendor/autoload.php';

return \Spiral\CodeStyle\Builder::create()
->include(__DIR__ . '/src')
->include(__FILE__)
->build();
50 changes: 50 additions & 0 deletions CLAUDE.local.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
- Always use guidelines from the `docs/guidelines` folder

## Guidelines Index

### 📖 Documentation Guidelines
**Path:** `docs/guidelines/how-to-translate-readme-docs.md`
**Value:** Standardizes documentation translation process and multilingual content management
**Key Areas:**
- Translation workflow using LLMs for documentation
- Multilanguage README pattern using ISO 639-1 codes (`README-{lang_code}.md`)
- Quality standards: preserve technical content, ensure natural language flow
- Review process for translated content
- MAPS framework for complex translations

### 🖥️ Console Command Development
**Path:** `docs/guidelines/how-to-write-console-command.md`
**Value:** Ensures consistent CLI interface design and proper Symfony console integration
**Key Areas:**
- Command structure: extend `Base` class, use `#[AsCommand]` attribute
- Required methods: `configure()` and `execute()`
- Type system: always use `Path` value object instead of strings for file paths
- Interactive patterns: use `$input->isInteractive()` for detection
- Error handling: proper return codes (`Command::SUCCESS`, `Command::FAILURE`, `Command::INVALID`)
- Best practices: method extraction, confirmation dialogs, file operation patterns
- Available services through DI container (Logger, StyleInterface, etc.)

### 📝 PHP Code Standards
**Path:** `docs/guidelines/how-to-write-php-code-best-practices.md`
**Value:** Maintains modern PHP code quality and leverages latest language features for better performance and maintainability
**Key Areas:**
- Modern PHP 8.1+ features: constructor promotion, union types, match expressions, throw expressions
- Code structure: PER-2 standards, single responsibility, final classes by default
- Enumerations: use enums for fixed value sets, CamelCase naming, backed enums for primitives
- Immutability: readonly properties, `with` prefix for immutable updates
- Type system: precise PHPDoc annotations, generics, non-empty-string types
- Comparison patterns: strict equality (`===`), null coalescing (`??`), avoid `empty()`
- Dependency injection and IoC container patterns

### 🧪 Testing Guidelines
**Path:** `docs/guidelines/how-to-write-tests.md`
**Value:** Ensures comprehensive test coverage with modern PHPUnit practices and proper test isolation
**Key Areas:**
- Test structure: mirror source structure, `final` test classes, Arrange-Act-Assert pattern
- Module testing: independent test areas with dedicated `Stub` directories
- Naming: `{ClassUnderTest}Test`, descriptive method names
- Modern PHPUnit: PHP 8.1+ attributes (`#[CoversClass]`, `#[DataProvider]`), data providers with generators
- Isolation: mock dependencies, use test doubles, reset state between tests
- **Critical restrictions**: DO NOT mock enums or final classes - use real instances
- Error testing: expectException before Act phase
- Test traits for shared functionality
56 changes: 56 additions & 0 deletions bin/testo
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env php
<?php

declare(strict_types=1);

use Symfony\Component\Console\Application;
use Symfony\Component\Console\CommandLoader\FactoryCommandLoader;
use Testo\Internal\Command;
use Testo\Internal\Info;

// Set timeout to 0 to prevent script from timing out
\set_time_limit(0);
\ini_set('memory_limit', '2G');
\error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);

(static function (): void {
$cwd = \getcwd();

$possibleAutoloadPaths = [
// local dev repository
\dirname(__DIR__) . '/vendor/autoload.php',
// running from project root
$cwd . '/vendor/autoload.php',
// running from project bin
\dirname($cwd) . '/autoload.php',
// dependency
\dirname(__DIR__, 4) . '/vendor/autoload.php',
];
$autoloadPath = null;
foreach ($possibleAutoloadPaths as $possibleAutoloadPath) {
if (\file_exists($possibleAutoloadPath)) {
$autoloadPath = $possibleAutoloadPath;
break;
}
}

if ($autoloadPath === null) {
$message = "Unable to find `vendor/autoload.php` in the following paths:\n\n";
$message .= '- ' . \implode("\n- ", $possibleAutoloadPaths) . "\n\n";
\fwrite(STDERR, $message);
exit(1);
}

require_once $autoloadPath;

$application = new Application();
$application->setCommandLoader(
new FactoryCommandLoader([
Command\Run::getDefaultName() => static fn() => new Command\Run(),
]),
);
$application->setDefaultCommand(Command\Run::getDefaultName(), false);
$application->setVersion(Info::version());
$application->setName(Info::NAME);
$application->run();
})();
10 changes: 10 additions & 0 deletions bin/testo.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@echo off
@setlocal

set BIN_PATH=%~dp0

if "%PHP_COMMAND%" == "" set PHP_COMMAND=php

"%PHP_COMMAND%" "%BIN_PATH%testo" %*

@endlocal
11 changes: 8 additions & 3 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,15 @@
"react/async": "^3.2 || ^4.3",
"react/promise": "^2.10 || ^3.2",
"symfony/console": "^6.4 || ^7",
"symfony/finder": "^6.4 || ^7",
"symfony/process": "^6.4 || ^7",
"webmozart/assert": "^1.11",
"yiisoft/injector": "^1.2"
},
"require-dev": {
"buggregator/trap": "^1.10",
"infection/infection": "^0.31",
"internal/dload": "^1.6",
"phpunit/phpunit": "^10.5",
"spiral/code-style": "^2.2.2",
"vimeo/psalm": "^6.10"
},
Expand All @@ -46,7 +47,10 @@
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"files": [
"tests/Fixture/functions.php"
]
},
"bin": [
"bin/testo"
Expand Down Expand Up @@ -74,6 +78,7 @@
],
"psalm": "psalm",
"psalm:baseline": "psalm --set-baseline=psalm-baseline.xml",
"psalm:ci": "psalm --output-format=github --shepherd --show-info=false --stats --threads=4"
"psalm:ci": "psalm --output-format=github --shepherd --show-info=false --stats --threads=4",
"test": "bin/testo"
}
}
1 change: 1 addition & 0 deletions docs/guidelines
Submodule guidelines added at b82212
32 changes: 32 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
cacheResultFile="runtime/.phpunit.result.cache"
backupGlobals="false"
colors="true"
processIsolation="false"
stopOnFailure="false"
stopOnError="false"
stderr="true"
displayDetailsOnIncompleteTests="true"
displayDetailsOnSkippedTests="true"
displayDetailsOnTestsThatTriggerDeprecations="true"
displayDetailsOnTestsThatTriggerErrors="true"
displayDetailsOnTestsThatTriggerNotices="true"
displayDetailsOnTestsThatTriggerWarnings="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
</testsuites>
<php>
<ini name="error_reporting" value="-1"/>
<ini name="memory_limit" value="-1"/>
</php>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
21 changes: 21 additions & 0 deletions psalm.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?xml version="1.0"?>
<psalm
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="https://getpsalm.org/schema/config"
xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
errorLevel="1"
hoistConstants="true"
findUnusedPsalmSuppress="false"
findUnusedVariablesAndParams="true"
findUnusedBaselineEntry="true"
findUnusedCode="false"
ensureArrayStringOffsetsExist="true"
addParamDefaultToDocblockType="true"
>
<projectFiles>
<directory name="src" />
<ignoreFiles>
<directory name="vendor" />
</ignoreFiles>
</projectFiles>
</psalm>
3 changes: 3 additions & 0 deletions resources/version.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "1.0.0"
}
49 changes: 49 additions & 0 deletions src/Application.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

declare(strict_types=1);

namespace Testo;

use Testo\Config\ApplicationConfig;
use Testo\Dto\Filter;
use Testo\Dto\Run\RunResult;
use Testo\Internal\Bootstrap;
use Testo\Internal\Container;
use Testo\Suite\SuiteProvider;
use Testo\Suite\SuiteRunner;

final class Application
{
private function __construct(
/**
* @internal
*/
public readonly Container $container,
) {}

public static function create(
ApplicationConfig $config,
) {
$container = Bootstrap::init()
->withConfig($config->services)
->finish();
$container->set($config);
return new self($container);
}

public function run($filter = new Filter()): RunResult
{
$suiteResults = [];

$suiteProvider = $this->container->get(SuiteProvider::class);
$suiteRunner = $this->container->get(SuiteRunner::class);

# Iterate Test Suites
foreach ($suiteProvider->withFilter($filter)->getSuites() as $suite) {
$suiteResults[] = $suiteRunner->run($suite, $filter);
}

# Run suites
return new RunResult($suiteResults);
}
}
12 changes: 12 additions & 0 deletions src/Attribute/Interceptable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Testo\Attribute;

/**
* Indicates that the attribute is linked to an interceptor.
*
* You must register related interceptor that handles this attribute.
*/
interface Interceptable {}
24 changes: 24 additions & 0 deletions src/Attribute/RetryPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Testo\Attribute;

/**
* Retry test on failure.
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
final class RetryPolicy implements Interceptable
{
public function __construct(
/**
* Maximum number of attempts.
*/
public readonly int $maxAttempts = 3,

/**
* Mark the test as flaky if it passed on retry.
*/
public readonly bool $markFlaky = true,
) {}
}
11 changes: 11 additions & 0 deletions src/Attribute/Test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Testo\Attribute;

/**
* Marks a method or a function as a test.
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
final class Test {}
44 changes: 44 additions & 0 deletions src/Config/ApplicationConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Testo\Config;

/**
* Test Suite configuration.
*/
final class ApplicationConfig
{
public function __construct(
/**
* Source code location.
*/
public readonly ?FinderConfig $src = null,

/**
* Specify one or more Test Suites to be executed.
*
* @var non-empty-list<SuiteConfig>
*/
public readonly array $suites = [
new SuiteConfig(
name: 'default',
location: new FinderConfig(['tests']),
),
],

/**
* Services bindings configuration.
*/
public readonly ServicesConfig $services = new ServicesConfig(),
) {
# Validate suite configs
$suites === [] and throw new \InvalidArgumentException('At least one test suite must be defined.');
\array_walk(
$suites,
static fn(mixed $suite) => $suite instanceof SuiteConfig or throw new \InvalidArgumentException(
'Each suite must be an instance of SuiteConfig.',
),
);
}
}
Loading