mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 14:23:34 +02:00
Compare commits
4 Commits
375b92e93b
..
v5.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 208f447a10 | |||
| 6b7ca13660 | |||
| bf7c6b3134 | |||
| 617226ca01 |
@@ -190,6 +190,24 @@ async function listPageFiles(pagesDir) {
|
||||
return out
|
||||
}
|
||||
|
||||
// `existsSync()` ignores casing on APFS and NTFS, and on a macOS bind mount seen from
|
||||
// inside a Linux container, so the `resources/js/Pages` candidate resolves in a project
|
||||
// whose directory is really `resources/js/pages`. Every page path derives from the
|
||||
// directory accepted below, so a wrong-cased one detaches the pages tree from the paths
|
||||
// the PHP side reports. `readdir()` is case-exact everywhere.
|
||||
export async function matchesDiskCasing(projectRoot, rel) {
|
||||
let current = projectRoot
|
||||
|
||||
for (const segment of rel.split('/')) {
|
||||
let entries
|
||||
try { entries = await readdir(current) } catch { return false }
|
||||
if (!entries.includes(segment)) return false
|
||||
current = join(current, segment)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function discoverPagesDir() {
|
||||
const override = process.env.TIA_VITE_PAGES_DIR
|
||||
if (override && override.length > 0) {
|
||||
@@ -199,6 +217,7 @@ async function discoverPagesDir() {
|
||||
for (const rel of PAGE_DIR_CANDIDATES) {
|
||||
const abs = resolve(PROJECT_ROOT, rel)
|
||||
if (!existsSync(abs)) continue
|
||||
if (!(await matchesDiskCasing(PROJECT_ROOT, rel))) continue
|
||||
const files = await listPageFiles(abs)
|
||||
if (files.length > 0) return abs
|
||||
}
|
||||
|
||||
+2
-2
@@ -23,7 +23,7 @@
|
||||
"nunomaduro/termwind": "^2.4.0",
|
||||
"pestphp/pest-plugin": "^5.0.0",
|
||||
"pestphp/pest-plugin-arch": "^5.0.0",
|
||||
"pestphp/pest-plugin-mutate": "^5.0.1",
|
||||
"pestphp/pest-plugin-mutate": "^5.0.2",
|
||||
"pestphp/pest-plugin-profanity": "^5.0.0",
|
||||
"phpunit/phpunit": "^13.3.0",
|
||||
"symfony/process": "^8.1.0"
|
||||
@@ -60,7 +60,7 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"pestphp/pest-dev-tools": "^5.0.0",
|
||||
"pestphp/pest-plugin-browser": "^5.0.0",
|
||||
"pestphp/pest-plugin-browser": "^5.0.1",
|
||||
"pestphp/pest-plugin-phpstan": "^5.0.2",
|
||||
"pestphp/pest-plugin-rector": "^5.0.3",
|
||||
"pestphp/pest-plugin-type-coverage": "^5.0.2",
|
||||
|
||||
+1
-1
@@ -37,9 +37,9 @@ final class Kernel
|
||||
private const array BOOTSTRAPPERS = [
|
||||
Bootstrappers\BootOverrides::class,
|
||||
Bootstrappers\BootPhpUnitConfiguration::class,
|
||||
Plugins\Tia\Bootstrapper::class,
|
||||
Bootstrappers\BootSubscribers::class,
|
||||
Bootstrappers\BootFiles::class,
|
||||
Plugins\Tia\Bootstrapper::class,
|
||||
Bootstrappers\BootView::class,
|
||||
Bootstrappers\BootKernelDump::class,
|
||||
Bootstrappers\BootExcludeList::class,
|
||||
|
||||
@@ -734,6 +734,12 @@ final class Expectation
|
||||
};
|
||||
|
||||
if (! $snapshots->has()) {
|
||||
if (! Snapshot::shouldCreateMissingSnapshots()) {
|
||||
$filename = $snapshots->filename();
|
||||
|
||||
Assert::fail($message === '' ? "Snapshot is missing at [$filename]. Run Pest with --update-snapshots to create it." : $message);
|
||||
}
|
||||
|
||||
$filename = $snapshots->save($string);
|
||||
|
||||
TestSuite::getInstance()->registerSnapshotChange("Snapshot created at [$filename]");
|
||||
|
||||
@@ -16,6 +16,36 @@ final class Snapshot implements HandlesArguments
|
||||
|
||||
public static bool $updateSnapshots = false;
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array CI_ENVIRONMENT_VARIABLES = [
|
||||
'CI',
|
||||
'GITHUB_ACTIONS',
|
||||
'GITLAB_CI',
|
||||
'CIRCLECI',
|
||||
'TRAVIS',
|
||||
'APPVEYOR',
|
||||
'BITBUCKET_BUILD_NUMBER',
|
||||
'BUILDKITE',
|
||||
'TEAMCITY_VERSION',
|
||||
'JENKINS_URL',
|
||||
'SYSTEM_COLLECTIONURI',
|
||||
'CI_NAME',
|
||||
'TASKCLUSTER_ROOT_URL',
|
||||
'DRONE',
|
||||
'WERCKER',
|
||||
'NEVERCODE',
|
||||
'SEMAPHORE',
|
||||
'NETLIFY',
|
||||
'NOW_BUILDER',
|
||||
];
|
||||
|
||||
public static function shouldCreateMissingSnapshots(): bool
|
||||
{
|
||||
return self::$updateSnapshots || ! self::runningOnCI();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@@ -119,4 +149,13 @@ final class Snapshot implements HandlesArguments
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function runningOnCI(): bool
|
||||
{
|
||||
if (Environment::name() === Environment::CI) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return array_any(self::CI_ENVIRONMENT_VARIABLES, fn (string $environmentVariable): bool => getenv($environmentVariable) !== false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,16 @@ use Pest\Support\Container;
|
||||
*/
|
||||
final class Configuration
|
||||
{
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
public function directory(string $directory): self
|
||||
{
|
||||
Storage::useDirectory($directory);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return $this
|
||||
*/
|
||||
|
||||
@@ -85,6 +85,10 @@ final class JsModuleGraph
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! self::matchesDiskCasing($projectRoot, $rel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::dirHasPageFile($abs)) {
|
||||
return $abs;
|
||||
}
|
||||
@@ -93,12 +97,30 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function matchesDiskCasing(string $projectRoot, string $relative): bool
|
||||
{
|
||||
$current = rtrim($projectRoot, DIRECTORY_SEPARATOR);
|
||||
|
||||
foreach (explode('/', $relative) as $segment) {
|
||||
$entries = @scandir($current);
|
||||
|
||||
if ($entries === false || ! in_array($segment, $entries, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$current .= DIRECTORY_SEPARATOR.$segment;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function dirHasPageFile(string $dir): bool
|
||||
{
|
||||
try {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||
\RecursiveIteratorIterator::CATCH_GET_CHILD,
|
||||
);
|
||||
} catch (\UnexpectedValueException) {
|
||||
return false;
|
||||
@@ -265,20 +287,25 @@ final class JsModuleGraph
|
||||
if ($jsRoot !== null && is_dir($jsRoot)) {
|
||||
$entries = [];
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||
);
|
||||
try {
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::LEAVES_ONLY,
|
||||
\RecursiveIteratorIterator::CATCH_GET_CHILD,
|
||||
);
|
||||
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
/** @var \SplFileInfo $file */
|
||||
foreach ($iterator as $file) {
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entries[] = $file->getPathname()
|
||||
.':'.$file->getSize()
|
||||
.':'.$file->getMTime();
|
||||
}
|
||||
|
||||
$entries[] = $file->getPathname()
|
||||
.':'.$file->getSize()
|
||||
.':'.$file->getMTime();
|
||||
} catch (\UnexpectedValueException|\RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sort($entries);
|
||||
|
||||
@@ -9,8 +9,17 @@ namespace Pest\Plugins\Tia;
|
||||
*/
|
||||
final class Storage
|
||||
{
|
||||
private static ?string $directory = null;
|
||||
|
||||
public static function tempDir(string $projectRoot): string
|
||||
{
|
||||
if (self::$directory !== null) {
|
||||
$isAbsolute = str_starts_with(self::$directory, DIRECTORY_SEPARATOR)
|
||||
|| preg_match('/^[a-z]:[\\\\\/]/i', self::$directory) === 1;
|
||||
|
||||
return $isAbsolute ? self::$directory : $projectRoot.DIRECTORY_SEPARATOR.self::$directory;
|
||||
}
|
||||
|
||||
$home = self::homeDir();
|
||||
|
||||
if ($home === null) {
|
||||
@@ -25,6 +34,11 @@ final class Storage
|
||||
.DIRECTORY_SEPARATOR.self::projectKey($projectRoot);
|
||||
}
|
||||
|
||||
public static function useDirectory(?string $directory): void
|
||||
{
|
||||
self::$directory = $directory;
|
||||
}
|
||||
|
||||
public static function purge(string $projectRoot): void
|
||||
{
|
||||
$dir = self::tempDir($projectRoot);
|
||||
|
||||
@@ -56,7 +56,12 @@ final class SnapshotRepository
|
||||
|
||||
file_put_contents($snapshotFilename, $snapshot);
|
||||
|
||||
return str_replace(dirname($this->testsPath).'/', '', $snapshotFilename);
|
||||
return $this->filename();
|
||||
}
|
||||
|
||||
public function filename(): string
|
||||
{
|
||||
return str_replace(dirname($this->testsPath).'/', '', $this->getSnapshotFilename());
|
||||
}
|
||||
|
||||
public function flush(): void
|
||||
|
||||
@@ -1906,6 +1906,14 @@
|
||||
✓ does not throw when an integer --random-order-seed is passed as a separate argv element
|
||||
✓ still detects --tia when an integer argument is present
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\JsModuleGraph
|
||||
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "exact"
|
||||
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "wrong leaf"
|
||||
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "wrong parent"
|
||||
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "absent"
|
||||
✓ it resolves the pages directory with the casing it has on disk
|
||||
✓ it fingerprints a project whose js tree holds a directory it cannot open
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\Lockfiles\PackageLock
|
||||
✓ it applies only to package-lock.json
|
||||
✓ it returns null for contents that are not an npm lockfile
|
||||
@@ -1928,6 +1936,10 @@
|
||||
✓ it records assertions against the same per-dataset key
|
||||
✓ it leaves a test without a dataset keyed by class and method
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\Storage
|
||||
✓ it uses a project-relative configured directory
|
||||
✓ it uses an absolute configured directory
|
||||
|
||||
PASS Tests\Unit\Plugins\Tia\TableExtractor
|
||||
✓ fromSql() → it extracts tables from plain DML
|
||||
✓ fromSql() → it extracts tables from joins
|
||||
@@ -2021,6 +2033,10 @@
|
||||
✓ it builds the expected alias map from a vite config with ('laravel-plugin-default')
|
||||
✓ it builds the expected alias map from a vite config with ('no-alias-no-plugin')
|
||||
✓ it builds the expected alias map from a vite config with ('no-config')
|
||||
✓ it accepts a page directory candidate only when it matches the casing on disk with ('exact')
|
||||
✓ it accepts a page directory candidate only when it matches the casing on disk with ('wrong leaf')
|
||||
✓ it accepts a page directory candidate only when it matches the casing on disk with ('wrong parent')
|
||||
✓ it accepts a page directory candidate only when it matches the casing on disk with ('absent')
|
||||
|
||||
PASS Tests\Unit\Preset
|
||||
✓ preset invalid name
|
||||
@@ -2209,4 +2225,4 @@
|
||||
✓ pass with dataset with ('my-datas-set-value')
|
||||
✓ within describe → pass with dataset with ('my-datas-set-value')
|
||||
|
||||
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1560 passed (3400 assertions)
|
||||
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1572 passed (3413 assertions)
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Plugins\Tia\JsModuleGraph;
|
||||
|
||||
function tiaJsModuleGraphCall(string $method, mixed ...$arguments): mixed
|
||||
{
|
||||
return new ReflectionMethod(JsModuleGraph::class, $method)->invoke(null, ...$arguments);
|
||||
}
|
||||
|
||||
function tiaJsModuleGraphProject(): string
|
||||
{
|
||||
$root = sys_get_temp_dir().'/pest-tia-js-module-graph-'.bin2hex(random_bytes(6));
|
||||
|
||||
mkdir($root.'/resources/js/pages', 0755, true);
|
||||
file_put_contents($root.'/vite.config.ts', "export default {}\n");
|
||||
file_put_contents($root.'/resources/js/pages/Dashboard.vue', "<template>ok</template>\n");
|
||||
|
||||
return $root;
|
||||
}
|
||||
|
||||
function tiaJsModuleGraphRemove(string $path): void
|
||||
{
|
||||
if (! is_dir($path)) {
|
||||
@unlink($path);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@chmod($path, 0755);
|
||||
|
||||
$entries = @scandir($path);
|
||||
|
||||
foreach ($entries === false ? [] : $entries as $entry) {
|
||||
if ($entry === '.') {
|
||||
continue;
|
||||
}
|
||||
if ($entry === '..') {
|
||||
continue;
|
||||
}
|
||||
tiaJsModuleGraphRemove($path.'/'.$entry);
|
||||
}
|
||||
|
||||
@rmdir($path);
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->projectRoot = tiaJsModuleGraphProject();
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
tiaJsModuleGraphRemove($this->projectRoot);
|
||||
});
|
||||
|
||||
it('accepts a page directory candidate only when every segment matches the casing on disk', function (string $candidate, bool $expected): void {
|
||||
expect(tiaJsModuleGraphCall('matchesDiskCasing', $this->projectRoot, $candidate))->toBe($expected);
|
||||
})->with([
|
||||
'exact' => ['resources/js/pages', true],
|
||||
'wrong leaf' => ['resources/js/Pages', false],
|
||||
'wrong parent' => ['Resources/js/pages', false],
|
||||
'absent' => ['assets/js/pages', false],
|
||||
]);
|
||||
|
||||
it('resolves the pages directory with the casing it has on disk', function (): void {
|
||||
$expected = $this->projectRoot
|
||||
.DIRECTORY_SEPARATOR.'resources'
|
||||
.DIRECTORY_SEPARATOR.'js'
|
||||
.DIRECTORY_SEPARATOR.'pages';
|
||||
|
||||
expect(tiaJsModuleGraphCall('firstExistingPagesDir', $this->projectRoot))->toBe($expected);
|
||||
});
|
||||
|
||||
it('fingerprints a project whose js tree holds a directory it cannot open', function (): void {
|
||||
$locked = $this->projectRoot.'/resources/js/locked';
|
||||
|
||||
mkdir($locked, 0755, true);
|
||||
file_put_contents($locked.'/Secret.vue', "<template>ok</template>\n");
|
||||
chmod($locked, 0000);
|
||||
|
||||
if (is_readable($locked)) {
|
||||
$this->markTestSkipped('the current user reads directories regardless of their mode.');
|
||||
}
|
||||
|
||||
expect(tiaJsModuleGraphCall('fingerprint', $this->projectRoot))->toBeString();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Plugins\Tia\Bootstrapper;
|
||||
use Pest\Plugins\Tia\Configuration;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Plugins\Tia\FileState;
|
||||
use Pest\Plugins\Tia\Storage;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
|
||||
afterEach(function (): void {
|
||||
Storage::useDirectory(null);
|
||||
});
|
||||
|
||||
it('uses a project-relative configured directory', function (): void {
|
||||
$container = new Container;
|
||||
$testSuite = new TestSuite(sys_get_temp_dir(), 'tests');
|
||||
$container->add(TestSuite::class, $testSuite);
|
||||
|
||||
(new Configuration)->directory('.pest/tia');
|
||||
new Bootstrapper($container)->boot();
|
||||
|
||||
$state = $container->get(State::class);
|
||||
|
||||
if (! $state instanceof FileState) {
|
||||
throw new RuntimeException('Expected the TIA state to use file storage.');
|
||||
}
|
||||
|
||||
expect(Storage::tempDir('/project'))->toBe('/project'.DIRECTORY_SEPARATOR.'.pest/tia')
|
||||
->and($state->pathFor('graph.json'))->toBe($testSuite->rootPath.DIRECTORY_SEPARATOR.'.pest/tia/graph.json');
|
||||
});
|
||||
|
||||
it('uses an absolute configured directory', function (): void {
|
||||
(new Configuration)->directory('/tmp/pest-tia');
|
||||
|
||||
expect(Storage::tempDir('/project'))->toBe('/tmp/pest-tia');
|
||||
});
|
||||
@@ -357,6 +357,63 @@ function tiaViteAliasResults(): array
|
||||
return $cache = ['roots' => $roots, 'aliases' => $aliases];
|
||||
}
|
||||
|
||||
function tiaViteCasingFixtures(): array
|
||||
{
|
||||
return [
|
||||
'exact' => ['resources/js/pages', true],
|
||||
'wrong leaf' => ['resources/js/Pages', false],
|
||||
'wrong parent' => ['Resources/js/pages', false],
|
||||
'absent' => ['assets/js/pages', false],
|
||||
];
|
||||
}
|
||||
|
||||
function tiaViteCasingResults(): array
|
||||
{
|
||||
static $cache = null;
|
||||
if ($cache !== null) {
|
||||
return $cache;
|
||||
}
|
||||
|
||||
$root = sys_get_temp_dir().'/pest-tia-vite-casing-'.bin2hex(random_bytes(6));
|
||||
mkdir($root.'/resources/js/pages', 0755, true);
|
||||
file_put_contents($root.'/resources/js/pages/Dashboard.vue', "<template>ok</template>\n");
|
||||
|
||||
$helper = str_replace('\\', '/', tiaViteHelperPath());
|
||||
$normalized = str_replace('\\', '/', $root);
|
||||
|
||||
$payload = [];
|
||||
foreach (tiaViteCasingFixtures() as $name => [$candidate]) {
|
||||
$payload[] = ['name' => $name, 'candidate' => $candidate];
|
||||
}
|
||||
|
||||
$inputFile = tempnam(sys_get_temp_dir(), 'tia-vite-casing-');
|
||||
file_put_contents($inputFile, json_encode($payload));
|
||||
$input = str_replace('\\', '/', $inputFile);
|
||||
|
||||
$script = <<<JS
|
||||
import { matchesDiskCasing } from '{$helper}'
|
||||
import { readFileSync } from 'node:fs'
|
||||
const cases = JSON.parse(readFileSync('{$input}', 'utf8'))
|
||||
const out = {}
|
||||
for (const c of cases) out[c.name] = await matchesDiskCasing('{$normalized}', c.candidate)
|
||||
process.stdout.write(JSON.stringify(out))
|
||||
JS;
|
||||
|
||||
$process = new Process(['node', '--input-type=module', '-e', $script]);
|
||||
$process->mustRun();
|
||||
|
||||
$results = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
@unlink($inputFile);
|
||||
@unlink($root.'/resources/js/pages/Dashboard.vue');
|
||||
@rmdir($root.'/resources/js/pages');
|
||||
@rmdir($root.'/resources/js');
|
||||
@rmdir($root.'/resources');
|
||||
@rmdir($root);
|
||||
|
||||
return $cache = $results;
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
if ((new ExecutableFinder)->find('node') === null) {
|
||||
$this->markTestSkipped('node is not available.');
|
||||
@@ -409,3 +466,9 @@ it('builds the expected alias map from a vite config', function (string $name):
|
||||
|
||||
expect($results['aliases'][$name])->toEqual($expected);
|
||||
})->with(array_keys(tiaViteAliasFixtures()));
|
||||
|
||||
it('accepts a page directory candidate only when it matches the casing on disk', function (string $name): void {
|
||||
[, $expected] = tiaViteCasingFixtures()[$name];
|
||||
|
||||
expect(tiaViteCasingResults()[$name])->toBe($expected);
|
||||
})->with(array_keys(tiaViteCasingFixtures()));
|
||||
|
||||
@@ -26,13 +26,13 @@ test('parallel', function () use ($run): void {
|
||||
$file = file_get_contents(__FILE__);
|
||||
$file = preg_replace(
|
||||
'/\$expected = \'.*?\';/',
|
||||
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1542 passed (3345 assertions)';",
|
||||
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1554 passed (3358 assertions)';",
|
||||
$file,
|
||||
);
|
||||
file_put_contents(__FILE__, $file);
|
||||
}
|
||||
|
||||
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1542 passed (3345 assertions)';
|
||||
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1554 passed (3358 assertions)';
|
||||
|
||||
expect($output)
|
||||
->toContain("Tests: {$expected}")
|
||||
|
||||
Reference in New Issue
Block a user