Browse Source

Initial commit

main
Dustin Wilson 1 year ago
commit
fd8ebbea04
  1. 76
      .gitignore
  2. 20
      README.md
  3. 28
      composer.json
  4. 1936
      composer.lock
  5. 161
      lib/Logger.php
  6. 101
      lib/Logger/Handler.php
  7. 23
      lib/Logger/IOException.php
  8. 29
      lib/Logger/InvalidArgumentException.php
  9. 73
      lib/Logger/Level.php
  10. 29
      lib/Logger/RangeException.php
  11. 110
      lib/Logger/StreamHandler.php
  12. 32
      test
  13. 11
      tests/Bootstrap.php
  14. 86
      tests/cases/TestLogger.php
  15. 21
      tests/phpunit.xml

76
.gitignore

@ -0,0 +1,76 @@
# Project-specific
/test*.*
/test/
/test/*
# General
*.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
/vendor/
/vendor-bin/*/vendor
/tests/html5lib-tests
/tests/.phpunit.*cache
/tests/coverage
cachegrind.out.*

20
README.md

@ -0,0 +1,20 @@
[a]: https://www.php-fig.org/psr/psr-3/
[b]: https://www.php-fig.org/psr/psr-3/#12-message
[c]: https://www.php.net/manual/en/function.sprintf.php
[d]: https://www.php-fig.org/psr/psr-3/#13-context
[e]: https://www.php-fig.org/psr/psr-3/#11-basics
[f]: http://tools.ietf.org/html/rfc5424
# Logger #
_Logger_ is a simple yet configurable logger for PHP. It is an opinionated implementation of [PSR-3 Logger Interface][a]. It uses classes called _handlers_ to handle messages to log. Currently there is only one handler: `StreamHandler` which allows for logging to files or to streams such as `php://stdout` or `php://stderr`. Handlers can be easily written and plugged into Logger.
## Opinionated? ##
This library attempts what we're calling an "opinionated" implementation of PSR-3. This is because while it successfully implements `Psr\Log\LoggerInterface` Logger deviates from the true spirit of the specification in various ways:
1. In [section 1.1][e] PSR-3 states that when calling the `log` method with one of the log level constants (later shown to be in `Psr\Log\LogLevel`) it must have the same result as calling the level-specific methods. The log level constants in `Psr\Log\LogLevel` are strings, but the `$level` parameter of the `log` method in `Psr\Log\LoggerInterface` is typeless. The words of the specification suggest that the `$level` parameter should be a string, but the actual code implementors are to use doesn't specify a type. The same section also references [RFC 5424][f] when mentioning the log level methods, but why use strings when there are standardized integers used to identify severity? Since the interface allows for any type for `$level`, _Logger_ will prefer the actual RFC standard's integers but will accept and convert PSR-3's strings internally to the integers just so it can remain PSR-3 compatible.
2. In [section 1.2][b] of the specification it describes an optional feature, placeholders, and requires the implementor to write code to parse out and replace placeholders using a syntax and a method that's not present anywhere else in the entirety of PHP. _Logger_ won't support this feature because a logging library's place is to log messages and not to interpolate template strings. A separate library or a built-in function such as `sprintf` should be used instead. _Logger_ provides a way to transform messages that can be used to hook in a preferred interpolation method if desired, though.
3. The specification in [section 1.3][d] also specifies that if an `Exception` object is passed in the `$context` parameter it must be within an `exception` key. This makes sense, but curiously there's nary a mention of what to do with an `Error` object. They've existed since PHP 7 and can be thrown just like exceptions. _Logger_ will accept any `Throwable` in the `exception` key, but at present does nothing with it. Theoretically future handlers could be written to take advantage of it for structured data.

28
composer.json

@ -0,0 +1,28 @@
{
"name": "mensbeam/logger",
"description": "A simple yet configurable logger",
"type": "library",
"require": {
"php": ">=8.1",
"psr/log": "^3.0",
"mensbeam/filesystem": "^1.0",
"nikic/php-parser": "^4.15"
},
"license": "MIT",
"autoload": {
"psr-4": {
"MensBeam\\": "lib/"
}
},
"authors": [
{
"name": "Dustin Wilson",
"email": "dustin@dustinwilson.com"
}
],
"require-dev": {
"ext-pcov": "*",
"docopt/docopt": "^1.0",
"phpunit/phpunit": "^10.0"
}
}

1936
composer.lock

File diff suppressed because it is too large

161
lib/Logger.php

@ -0,0 +1,161 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam;
use MensBeam\Logger\{
Handler,
Level,
StreamHandler
};
use Psr\Log\{
InvalidArgumentException,
LoggerInterface
};
class Logger implements LoggerInterface {
public const EMERGENCY = 0;
public const ALERT = 1;
public const CRITICAL = 2;
public const ERROR = 3;
public const WARNING = 4;
public const NOTICE = 5;
public const INFO = 6;
public const DEBUG = 7;
/** The channel name identifier used for this instance of Logger */
protected ?string $channel;
/**
* Array of handlers the exceptions are passed to
*
* @var Handler[]
*/
protected array $handlers;
public function __construct(?string $channel = null, Handler ...$handlers) {
$this->setChannel($channel);
if (count($handlers) === 0) {
$handlers[] = new StreamHandler('php://stdout');
}
$this->handlers = $handlers;
}
public function getChannel(): ?string {
return $this->channel;
}
public function setChannel(?string $value): void {
$this->channel = ($value !== null) ? substr($value, 0, 29) : null;
}
public function getHandlers(): array {
return $this->handlers;
}
/** System is unusable. */
public function emergency(string|\Stringable $message, array $context = []): void {
$this->log(Level::Emergency->value, $message, $context);
}
/** Action must be taken immediately. */
public function alert(string|\Stringable $message, array $context = []): void {
$this->log(Level::Alert->value, $message, $context);
}
/**
* Critical conditions.
* Example: Application component unavailable, unexpected exception.
*/
public function critical(string|\Stringable $message, array $context = []): void {
$this->log(Level::Critical->value, $message, $context);
}
/**
* Runtime errors that do not require immediate action but should typically
* be logged and monitored.
*/
public function error(string|\Stringable $message, array $context = []): void {
$this->log(Level::Error->value, $message, $context);
}
/** Exceptional occurrences that are not errors. */
public function warning(string|\Stringable $message, array $context = []): void {
$this->log(Level::Warning->value, $message, $context);
}
/** Normal but significant events. */
public function notice(string|\Stringable $message, array $context = []): void {
$this->log(Level::Notice->value, $message, $context);
}
/**
* Interesting events.
* Example: User logs in, SQL logs.
*/
public function info(string|\Stringable $message, array $context = []): void {
$this->log(Level::Info->value, $message, $context);
}
/** Detailed debug information. */
public function debug(string|\Stringable $message, array $context = []): void {
$this->log(Level::Debug->value, $message, $context);
}
/**
* Logs with an arbitrary level.
* @throws \Psr\Log\InvalidArgumentException
*/
public function log($level, string|\Stringable $message, array $context = []): void {
// Because the interface won't allow limiting $level to just int|string this is
// necessary.
if (!is_int($level) && !is_string($level)) {
$type = gettype($level);
$type = ($type === 'object') ? $level::class : $type;
throw new \TypeError(sprintf("Expected type 'int|string'. Found '%s'.", $type));
}
// If the level is a string convert it to a RFC5424 level integer.
$origLevel = $level;
$level = (is_string($level)) ? Level::fromPSR3($level) : $level;
if ($level < 0 || $level > 7) {
throw new InvalidArgumentException(sprintf('Invalid log level %s', $origLevel));
}
# PSR-3: Logger Interface
# §1.3 Context
#
# * Every method accepts an array as context data. This is meant to hold any
# extraneous information that does not fit well in a string. The array can
# contain anything. Implementors MUST ensure they treat context data with as
# much lenience as possible. A given value in the context MUST NOT throw an
# exception nor raise any php error, warning or notice.
#
# * If an Exception object is passed in the context data, it MUST be in the
# 'exception' key. Logging exceptions is a common pattern and this allows
# implementors to extract a stack trace from the exception when the log
# backend supports it. Implementors MUST still verify that the 'exception' key
# is actually an Exception before using it as such, as it MAY contain
# anything.
// We're not doing interpolation :)
foreach ($this->handlers as $h) {
$h($level, $this->channel, $message, $context);
if (!$h->getOption('bubbles')) {
break;
}
}
}
}

101
lib/Logger/Handler.php

@ -0,0 +1,101 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Logger;
abstract class Handler {
protected array $levels;
protected \DateTimeImmutable $timestamp;
protected bool $_bubbles = true;
protected string $_datetimeFormat = 'M d H:i:s';
protected string|\Closure|null $_messageTransform = null;
public function __construct(array $levels = [ 0, 1, 2, 3, 4, 5, 6, 7 ], array $options = []) {
$levelsCount = count($levels);
if ($levelsCount > 8) {
throw new InvalidArgumentException(sprintf('Argument #%s ($levels) cannot have more than 8 values', $this->getParamPosition()));
}
if (count($levels) === 0) {
throw new InvalidArgumentException(sprintf('Argument #%s ($levels) must not be empty', $this->getParamPosition()));
}
$levels = array_unique($levels, \SORT_NUMERIC);
foreach ($levels as $k => $v) {
if (!is_int($v)) {
$type = gettype($v);
$type = ($type === 'object') ? $v::class : $type;
throw new InvalidArgumentException(sprintf('Value #%s of argument #%s ($levels) must be of type int, %s given', $k, $this->getParamPosition(), $type));
}
if ($v < 0 || $v > 7) {
throw new RangeException(sprintf('Argument #%s ($levels) cannot be %s; it is not in the range 0 - 7', $this->getParamPosition(), $v));
}
}
$this->levels = array_values($levels);
foreach ($options as $key => $value) {
$key = "_$key";
$this->$key = $value;
}
}
public function getOption(string $name): mixed {
$class = get_class($this);
if (!property_exists($class, "_$name")) {
trigger_error(sprintf('Undefined option in %s: %s', $class, $name), \E_USER_WARNING);
return null;
}
$name = "_$name";
return $this->$name;
}
public function setOption(string $name, mixed $value): void {
$class = get_class($this);
if (!property_exists($class, "_$name")) {
trigger_error(sprintf('Undefined option in %s: %s', $class, $name), \E_USER_WARNING);
}
$name = "_$name";
$this->$name = $value;
}
public function __invoke(int $level, string $channel, string $message, array $context = []): void {
$datetime = \DateTimeImmutable::createFromFormat('U.u', (string)microtime(true))->format($this->_datetimeFormat);
$message = trim($message);
if ($this->_messageTransform !== null) {
$t = $this->_messageTransform;
$message = $t($message, $context);
}
$this->invokeCallback($datetime, $level, $channel, $message, $context);
}
abstract protected function invokeCallback(string $datetime, int $level, string $channel, string $message, array $context = []): void;
private function getParamPosition(): int {
$params = (new \ReflectionClass(get_called_class()))->getConstructor()->getParameters();
foreach ($params as $k => $p) {
if ($p->getName() === 'levels') {
return $k + 1;
}
}
return -1;
}
}

23
lib/Logger/IOException.php

@ -0,0 +1,23 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Logger;
class IOException extends \RuntimeException {
protected ?string $path;
public function __construct(string $message, int $code = 0, \Throwable $previous = null, string $path = null) {
$this->path = $path;
parent::__construct($message, $code, $previous);
}
public function getPath(): ?string {
return $this->path;
}
}

29
lib/Logger/InvalidArgumentException.php

@ -0,0 +1,29 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Logger;
class InvalidArgumentException extends \InvalidArgumentException {
public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null) {
// Make output a bit more useful by making it show the file and line of where the constructor was called.
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 5);
$b = null;
foreach ($backtrace as $k => $v) {
if ($v['function'] === '__construct' && $v['class'] === __NAMESPACE__ . '\Handler') {
$b = $backtrace[$k + 1] ?? null;
break;
}
}
if ($b !== null) {
$this->file = $b['file'];
$this->line = $b['line'];
}
parent::__construct($message, $code, $previous);
}
}

73
lib/Logger/Level.php

@ -0,0 +1,73 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Logger;
use Psr\Log\LogLevel;
enum Level: int {
/** System is unusable */
case Emergency = 0;
/** Action must be taken immediately */
case Alert = 1;
/**
* Critical conditions.
* Example: Application component unavailable, unexpected exception.
*/
case Critical = 2;
/**
* Runtime errors that do not require immediate action but should typically
* be logged and monitored.
*/
case Error = 3;
/** Exceptional occurrences that are not errors. */
case Warning = 4;
/** Normal but significant events. */
case Notice = 5;
/**
* Interesting events.
* Example: User logs in, SQL logs.
*/
case Info = 6;
/** Detailed debug information. */
case Debug = 7;
public static function fromPSR3(string $level): self {
return match ($level) {
LogLevel::EMERGENCY => self::Emergency,
LogLevel::ALERT => self::Alert,
LogLevel::CRITICAL => self::Critical,
LogLevel::ERROR => self::Error,
LogLevel::WARNING => self::Warning,
LogLevel::NOTICE => self::Notice,
LogLevel::INFO => self::Info,
LogLevel::DEBUG => self::Debug
};
}
public function toPSR3(): string {
return match ($this) {
self::Emergency => LogLevel::EMERGENCY,
self::Alert => LogLevel::ALERT,
self::Critical => LogLevel::CRITICAL,
self::Error => LogLevel::ERROR,
self::Warning => LogLevel::WARNING,
self::Notice => LogLevel::NOTICE,
self::Info => LogLevel::INFO,
self::Debug => LogLevel::DEBUG
};
}
}

29
lib/Logger/RangeException.php

@ -0,0 +1,29 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Logger;
class RangeException extends \RangeException {
public function __construct(string $message = '', int $code = 0, ?\Throwable $previous = null) {
// Make output a bit more useful by making it show the file and line of where the constructor was called.
$backtrace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 5);
$b = null;
foreach ($backtrace as $k => $v) {
if ($v['function'] === '__construct' && $v['class'] === __NAMESPACE__ . '\Handler') {
$b = $backtrace[$k + 1] ?? null;
break;
}
}
if ($b !== null) {
$this->file = $b['file'];
$this->line = $b['line'];
}
parent::__construct($message, $code, $previous);
}
}

110
lib/Logger/StreamHandler.php

@ -0,0 +1,110 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Logger;
use MensBeam\{
Filesystem as Fs,
Path
};
class StreamHandler extends Handler {
protected int $chunkSize = 10 * 1024 * 1024;
protected $resource = null;
protected ?string $url = null;
protected ?string $urlScheme = null;
protected ?string $_entryFormat = '%datetime% %channel% %level_name% %message%';
public function __construct($stream = 'php://stdout', array $levels = [ 0, 1, 2, 3, 4, 5, 6, 7 ], array $options = []) {
// Get the memory limit to determine the chunk size.
// The memory limit is in a shorthand format
// (https://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes), so we
// need it as a integer representation in bytes.
if (preg_match('/^\s*(?<num>\d+)(?:\.\d+)?\s*(?<unit>[gkm])\s*$/i', strtolower(ini_get('memory_limit')), $matches) === 1) {
$num = (int)$matches['num'];
switch ($matches['unit'] ?? '') {
case 'g': $num *= 1024;
case 'm': $num *= 1024;
case 'k': $num *= 1024;
}
// Use 10% of allowed memory or 100K, whichever is largest
$this->chunkSize = min($this->chunkSize, max((int)($num / 10), 100 * 1024));
}
if (is_resource($stream)) {
$this->resource = $stream;
stream_set_chunk_size($this->resource, $this->chunkSize);
} elseif(is_string($stream)) {
$stream = Path::canonicalize($stream);
// This wouldn't be useful for validating a URI schema, but it's fine for what this needs
preg_match('/^(?:(?<scheme>[^:\s\/]+):)?(?<slashes>\/*)/i', $stream, $matches);
if (in_array($matches['scheme'], [ 'file', '' ])) {
$slashCount = strlen($matches['slashes'] ?? '');
$relative = ($matches['scheme'] === 'file') ? ($slashCount === 0 || $slashCount === 2) : ($slashCount === 0);
$stream = (($relative) ? getcwd() : '') . '/' . substr($stream, strlen($matches[0]));
}
$this->url = $stream;
$this->urlScheme = $matches['scheme'] ?: 'file';
} else {
$type = gettype($stream);
$type = ($type === 'object') ? $stream::class : $stream;
throw new \TypeError(sprintf("Expected type 'resource|string'. Found '%s'", $type));
}
parent::__construct($levels, $options);
}
public function getStream() {
return $this->resource ?? $this->url;
}
protected function invokeCallback(string $datetime, int $level, string $channel, string $message, array $context = []): void {
if (!in_array($level, $this->levels)) {
return;
}
// Do output formatting here.
$output = trim(preg_replace_callback('/%([a-z_]+)%/', function($m) use ($datetime, $level, $channel, $message) {
switch ($m[1]) {
case 'channel': return $channel;
case 'datetime': return $datetime;
case 'level': return (string)$level;
case 'level_name': return strtoupper(Level::from($level)->name);
case 'message': return $message;
default: return '';
}
}, $this->_entryFormat));
// If output contains any newlines then add an additional newline to aid readability.
if (str_contains($output, \PHP_EOL)) {
$output .= \PHP_EOL;
}
$output .= \PHP_EOL;
if ($this->resource === null) {
if ($this->urlScheme === 'file') {
Fs::mkdir(dirname($this->url));
}
$fp = fopen($this->url, 'a');
stream_set_chunk_size($fp, $this->chunkSize);
fwrite($fp, $output);
fclose($fp);
} else {
fwrite($this->resource, $output);
}
}
}

32
test

@ -0,0 +1,32 @@
#!/usr/bin/env php
<?php
$dir = ini_get('extension_dir');
$php = escapeshellarg(\PHP_BINARY);
$code = escapeshellarg(__DIR__ . '/lib');
array_shift($argv);
foreach ($argv as $k => $v) {
if (in_array($v, ['--coverage', '--coverage-html'])) {
$argv[$k] = '--coverage-html tests/coverage';
}
}
$cmd = [
$php,
'-d opcache.enable_cli=0',
];
if (!extension_loaded('pcov')) {
$cmd[] = '-d extension=pcov.so';
}
$cmd = implode(' ', [
...$cmd,
'-d pcov.enabled=1',
"-d pcov.directory=$code",
escapeshellarg(__DIR__ . '/vendor/bin/phpunit'),
'--configuration tests/phpunit.xml',
...$argv
]);
passthru($cmd);

11
tests/Bootstrap.php

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace MensBeam\Logger\Test;
ini_set('memory_limit', '-1');
ini_set('zend.assertions', '1');
ini_set('assert.exception', 'true');
error_reporting(\E_ALL);
require_once dirname(__DIR__) . '/vendor/autoload.php';
define('CWD', getcwd());

86
tests/cases/TestLogger.php

@ -0,0 +1,86 @@
<?php
/**
* @license MIT
* Copyright 2022 Dustin Wilson, et al.
* See LICENSE and AUTHORS files for details
*/
declare(strict_types=1);
namespace MensBeam\Logger\Test;
use MensBeam\Logger,
MensBeam\Logger\StreamHandler,
Psr\Log\InvalidArgumentException;
/** @covers \MensBeam\Logger */
class TestLogger extends \PHPUnit\Framework\TestCase {
public function testConstructor(): void {
$l = new Logger();
$this->assertNull($l->getChannel());
}
public function testDefaultHandler(): void {
$l = new Logger();
$h = $l->getHandlers();
$this->assertEquals(1, count($h));
$this->assertInstanceOf(StreamHandler::class, $h[0]);
$s = $h[0]->getStream();
$this->assertIsString($s);
$this->assertSame('php://stdout', $s);
}
public function testSettingChannelAndHandlers(): void {
$l = new Logger('oooooooooooooooooooooooooooook', new StreamHandler('ook.log'), new StreamHandler('eek.log'));
$h = $l->getHandlers();
// Should truncate the channel to 30 characters
$this->assertSame('ooooooooooooooooooooooooooooo', $l->getChannel());
$this->assertEquals(2, count($h));
$this->assertSame(CWD . '/ook.log', $h[0]->getStream());
$this->assertSame(CWD . '/eek.log', $h[1]->getStream());
}
/** @dataProvider provideLoggingTests */
public function testLogging(string $levelName): void {
$s = fopen('php://memory', 'r+');
// Break after first handler to test breaking in Logger->log
$l = new Logger('ook', new StreamHandler(stream: $s, options: [ 'bubbles' => false ]), new StreamHandler($s));
$l->$levelName('Ook!');
rewind($s);
$o = stream_get_contents($s);
$this->assertEquals(1, preg_match('/^' . (new \DateTimeImmutable())->format('M d') . ' \d{2}:\d{2}:\d{2} ook ' . strtoupper($levelName) . ' Ook!\n/', $o));
}
/** @dataProvider provideErrorTests */
public function testErrors(string $throwableClassName, \Closure $closure): void {
$this->expectException($throwableClassName);
$closure(new Logger());
}
public static function provideLoggingTests(): iterable {
foreach ([ 'emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug' ] as $l) {
yield [ $l ];
}
}
public static function provideErrorTests(): iterable {
$iterable = [
[
\TypeError::class,
function (Logger $l): void {
$l->log(3.14, 'Ook!');
}
],
[
InvalidArgumentException::class,
function (Logger $l): void {
$l->log(42, 'Ook!');
}
]
];
foreach ($iterable as $i) {
yield $i;
}
}
}

21
tests/phpunit.xml

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
beStrictAboutOutputDuringTests="true"
beStrictAboutTestsThatDoNotTestAnything="true"
bootstrap="bootstrap.php"
cacheDirectory=".phpunit.cache"
colors="true"
executionOrder="defects"
requireCoverageMetadata="true"
>
<testsuites>
<testsuite name="Main">
<directory prefix="Test" suffix=".php">./cases</directory>
</testsuite>
</testsuites>
<coverage>
<include>
<directory suffix=".php">../lib</directory>
</include>
</coverage>
</phpunit>
Loading…
Cancel
Save