diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 441a54b..83eef8b 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -41,10 +41,11 @@ jobs:
php src/bin/generate-docs.php src/factories/Asset.php docs/factories/asset.md
php src/bin/generate-docs.php src/dom/NodeList.php docs/dom/node-list.md
php src/bin/generate-docs.php src/dom/Form.php docs/dom/forms.md
- php src/bin/generate-docs.php src/behaviors/TestableResponseBehavior.php docs/assertions/response.md
+ php src/bin/generate-docs.php src/behaviors/TestableResponseBehavior.php docs/assertions/http-response.md
php src/bin/generate-docs.php src/behaviors/TestableElementBehavior.php docs/assertions/element.md
php src/bin/generate-docs.php src/test/DatabaseAssertions.php docs/assertions/database.md
php src/bin/generate-docs.php src/test/RequestBuilders.php docs/making-requests.md
+ php src/bin/generate-docs.php src/console/TestableResponse.php docs/assertions/console-response.md
php src/bin/generate-docs.php src/web/BenchmarkResult.php docs/assertions/benchmark.md
php src/bin/generate-docs.php src/test/CookieState.php docs/cookies.md
php src/bin/generate-docs.php src/test/ActingAs.php docs/logging-in.md
diff --git a/docs/assertions/console-response.md b/docs/assertions/console-response.md
new file mode 100644
index 0000000..59d496e
--- /dev/null
+++ b/docs/assertions/console-response.md
@@ -0,0 +1,39 @@
+# Console Response Assertions
+
+A testable response is returned when running a console command action. This class provides a fluent interface for
+asserting on the response.
+
+## assertSuccesful()
+Assert that the console command exited successfully (with a zero exit code).
+
+```php
+$this->command(ConsoleController::class, 'actionName')->assertSuccessful();
+```
+
+## assertFailed()
+Assert that the console command failed (with a non-zero exit code).
+
+```php
+$this->command(ConsoleController::class, 'actionName')->assertFailed();
+```
+
+## assertExitCode(int $exitCode)
+Assert the integer exit code
+
+```php
+$this->command(ConsoleController::class, 'actionName')->assertExitCode(1337);
+```
+
+## assertSee(string $text)
+Assert that the command contains the passed text in stdout or stderr
+
+```php
+$this->command(ConsoleController::class, 'actionName')->assertSee('text output');
+```
+
+## assertDontSee(string $text)
+Assert that the command does not contain the passed text in stdout or stderr
+
+```php
+$this->command(ConsoleController::class, 'actionName')->assertDontSee('text output');
+```
diff --git a/docs/assertions/http-response.md b/docs/assertions/http-response.md
new file mode 100644
index 0000000..8b701e3
--- /dev/null
+++ b/docs/assertions/http-response.md
@@ -0,0 +1,329 @@
+# HTTP Response Assertions
+
+A testable response is returned whenever you perform a HTTP request
+with Pest. It is an extension of Craft's native Response with a
+number of convience methods added for testing. For example, most
+tests will perform a `get()` and want to check that the response did
+not return an error. You may use `->assertOk()` to check that the
+status code was 200.
+
+## getRequest()
+Get the requesr that triggered this reaponse.
+
+## getJsonContent()
+Fetch the response body as a JSON array. This can be run through the expectation API
+as well for a fluent chain,
+
+```php
+$response->expect()->jsonContent->toBe(['foo' => 'bar']);
+```
+
+## querySelector(string $selector)
+If the response returns HTML you can `querySelector()` to inspect the
+HTML for specific content. The `querySelector()` method takes a
+CSS selector to look for (just like in Javascript).
+
+The return from `querySelector()` is always a `NodeList` containing zero
+or more nodes. You can interact with the `NodeList` regardless of the return
+and you will get back a scalar value or a collection of values.
+
+```php
+$response->querySelector('h1')->text; // returns the string contents of the h1 element
+$response->querySelector('li')->text; // returns a collection containing the text of all list items
+```
+
+## form(?string $selector = NULL)
+The entry point for interactions with forms. This returns a testable
+implementaion of the [Symfony DomCrawler's Form](#) class.
+
+If a response only has one form you may call `->form()` without any parameters
+to get the only form in the response. If the response contains more than
+one form then you must pass in a selector matching a specific form.
+
+To submit the form use `->submit()` or `->click('.button-selector')`.
+
+## expectSelector(string $selector)
+Runs the same `querySelector()` against the response's HTML but instead
+of returning a `NodeList` it returns an expectation against the `NodeList`.
+This allows you to use Pest's expectation API against the found nodes.
+
+```php
+$response->expectSelector('h1')->text->toBe('Hello World!');
+```
+
+## assertCacheTag(string $tags)
+Check that a response contains the given cache tag header. This is commonly used on
+edge-side CDNs to "tag" pages with a unique identifier so that they can be purged
+from the cache by that unique tag later. In Craft this will usually be something like
+the element ID of any entries being rendered.
+
+```php
+$response->assertCacheTag('my-tag');
+$response->assertCacheTag('el1234');
+```
+
+## assertCookie(string $name, ?string $value = NULL)
+Checks that the response contains the given cookie. When not passed a value
+the assertion only checks the presence of the cookie. When passed a value the
+value will be checked for strict equality.
+
+```php
+$response->assertCookie('cookieName'); // checks presence, with any value
+$response->assertCookie('cookieName', 'cookie value'); // checks that the values match
+```
+
+## assertCookieExpired(string $name)
+Checks that the given cookie has an expiration in the past. Cookies are sent in headers and if left
+unset a cookie will persist from request to request. Therefore, the only way to "remove" a cookie
+is to set its expiration to a date in the past (negative number). This is common when logging people out.
+
+```php
+$response->assertCookieExpired('cookieName');
+```
+
+## assertCookieNotExpired(string $name)
+Checks that the given cookie has an expiration in the future.
+
+```php
+$response->assertCookieNotExpired('cookieName');
+```
+
+## assertCookieMissing(string $name)
+Checks that the given cookie is not present in the response
+
+```php
+$response->assertCookieMissing('cookieName');
+```
+
+## assertCreated()
+Checks that the response has a 201 Created status code
+
+```php
+$response->assertCreated();
+```
+
+## assertDontSee(string $text)
+Checks that the given string does not appear in thr response.
+
+```php
+$response->assertDontSee('text that should not be in the response');
+```
+
+## assertDontSeeText(string $text)
+Checks that the given string does not appear in the response after first stripping all non-text elements (like HTML) from the response.
+For example, if the response contains `foo bar` you could check against the text `foo bar` because the `` will be stripped.
+
+```php
+$response->assertDontSeeText('foo bar');
+```
+
+## assertDownload(?string $filename = NULL)
+Checks that the response contains a file download, optionally checking that the filename of the download
+matches the given filename.
+
+```php
+$response->assertDownload(); // checks that any download is returned
+$response->assertDownload('file.jpg'); // checks that a download with the name `file.jpg` is returned
+```
+
+## assertExactJson(array $data)
+Checks that the given JSON exactly matches the returned JSON using PHPUnit's "canonicalizing" logic to
+validate the objects.
+
+```php
+$response->assertExactJson(['foo' => 'bar']);
+```
+
+## assertForbidden()
+Checks that the response has a 403 Forbidden status code
+
+```php
+$response->assertForbidden();
+```
+
+## assertHeader(string $name, ?string $expected = NULL)
+Checks that the given header is present in the response and, if provided, that the value of the
+header matches the given value.
+
+```php
+$response->assertHeader('x-foo'); // checks for presence of header, with any value
+$response->assertHeader('x-foo', 'bar'); // checks for header with matching value
+```
+
+## assertHeaderMissing(string $name)
+Checks that the response headers do not contain the given header.
+
+```php
+$response->assertHeaderMissing('x-foo');
+```
+
+## assertLocation(string $location, ?array $checkParts = NULL)
+Checks that the location header matches the given location
+
+```php
+$response->assertLocation('/foo/bar');
+```
+
+By default the full location will be checked including the path,
+host, port, etc... If you would like to check only a portion of
+the location you can pass in an array of keys in the second
+parameter. The keys take their names from PHP's [`parse_url`](https://www.php.net/parse_url)
+function.
+
+```php
+$response->assertLocation('/foo', ['host', 'path']);
+```
+
+## assertLocationPath(string $uri)
+Assert that the given path marches the path of the returned
+`location` header. The other parts of the location, like the
+host name, are ignored.
+
+```php
+$response->assertLocationPath('/foo');
+```
+
+## assertFlash(?string $message = NULL, ?string $key = NULL)
+Check that the given message/key is present in the flashed data.
+
+```php
+$response->assertFlash('The title is required');
+$response->assertFlash('Field is required', 'title');
+```
+
+## assertNoContent($status = 204)
+Check that the response has the given status code and no content.
+
+```php
+$response->assertNoContent();
+```
+
+## assertNotFound()
+Check that the response returns a 404 Not Found status code
+
+```php
+$response->assertNotFound();
+```
+
+## assertOk()
+Check that the response returns a 200 OK status code
+
+```php
+$response->assertOk();
+```
+
+## assertRedirect()
+Check that the response returns a 300 status code
+
+```php
+$response->assertRedirect();
+```
+
+## assertRedirectTo(string $location)
+A sugar method that checks the status code as well as the location of the redirect.
+
+```php
+$response->assertRedirectTo('/foo/bar');
+```
+
+## followRedirect()
+For a 300 class response with a `Location` header, trigger a new
+request for the redirected page.
+
+```php
+$response->assertRedirect()->followRedirect()->assertOk();
+```
+
+## followRedirects()
+For a 300 class response with a `Location` header, trigger a new
+request for the redirected page. If the redirected page also contains
+a redirect, follow the resulting redirects until you reach a non-300
+response code.
+
+```php
+$response->assertRedirect()->followRedirects()->assertOk();
+```
+
+## assertSee(string $text)
+Checks that the response contains the given text
+
+```php
+$response->assertSee('foo bar');
+```
+
+## assertSeeInOrder(array $texts)
+Checks that the response contains the given text, in successive order
+
+```php
+$response->assertSee(['first', 'second', 'third']);
+```
+
+## assertSeeText(string $text)
+Checks that the response contains the given text stripping tags. This would
+pass against source code of `foo bar`
+
+```php
+$response->assertSeeText('foo bar');
+```
+
+## assertSeeTextInOrder(array $texts)
+Checks that the response contains the given text, in successive order
+while stripping tags.
+
+```php
+$response->assertSeeTextInOrder(['first', 'second', 'third']);
+```
+
+## assertStatus($code)
+Asserts the given status code matches the response status code.
+
+```php
+$response->assertStatus(404);
+```
+
+## assertSuccessful()
+Asserts a successfull (200-class) response code.
+
+## assertTitle(string $title)
+Assert the given title matches the title of the page.
+
+```php
+$response->assertTitle('The Title');
+```
+
+## assertUnauthorized()
+Asserts that the response's status code is 401
+
+## beginBenchmark()
+Benchmarks are started on your test case by calling `->beginBenchmark()`. You are
+free to start as many benchmarks as needed, however, note that starting a new
+benchmark will clear out any existing benchmarks already in progress.
+
+> **Warning**
+> In order to use a benchmark you must enable Craft's `devMode` (which
+will enable the Yii Debug Bar).
+
+## endBenchmark()
+Ending a benchmark returns a testable Benchmark class. You can end a benchmark
+by calling `->endBenchmark()` on the test case or on a response. Either of the
+following will work,
+
+```php
+it('ends on the test case', function () {
+ $this->beginBenchmark();
+ $this->get('/');
+ $benchmark = $this->endBenchmark();
+});
+```
+
+```php
+it('ends on the response', function () {
+ $this->beginBenchmark()
+ ->get('/')
+ ->endBenchmark();
+});
+```
+
+> **Note**
+> Unlike the traditional Craft request/response lifecycle you are
+free to make multiple requests in a single benchmark.
diff --git a/src/behaviors/TestableResponseBehavior.php b/src/behaviors/TestableResponseBehavior.php
index b921adb..b7d41a6 100644
--- a/src/behaviors/TestableResponseBehavior.php
+++ b/src/behaviors/TestableResponseBehavior.php
@@ -17,7 +17,7 @@
use yii\base\Behavior;
/**
- * # Response Assertions
+ * # HTTP Response Assertions
*
* A testable response is returned whenever you perform a HTTP request
* with Pest. It is an extension of Craft's native Response with a
diff --git a/src/console/PestController.php b/src/console/PestController.php
index 4e46c2a..cdcfaf8 100644
--- a/src/console/PestController.php
+++ b/src/console/PestController.php
@@ -194,4 +194,13 @@ public function actionSeed($seeder = null): int
return 0;
}
+
+ // Internal method for testing purposes, not public
+ public function actionInternal()
+ {
+ $this->stdout('stdout');
+ $this->stderr('stderr');
+
+ return ExitCode::OK;
+ }
}
diff --git a/src/console/TestableResponse.php b/src/console/TestableResponse.php
index 0b9314e..9e63933 100644
--- a/src/console/TestableResponse.php
+++ b/src/console/TestableResponse.php
@@ -4,16 +4,87 @@
use PHPUnit\Framework\Assert;
+/**
+ * # Console Response Assertions
+ *
+ * A testable response is returned when running a console command action. This class provides a fluent interface for
+ * asserting on the response.
+ */
class TestableResponse
{
public function __construct(
- protected int $exitCode,
- protected string $stdout,
- protected string $stderr,
+ public int $exitCode,
+ public string $stdout,
+ public string $stderr,
) {}
- public function assertSuccesful()
+ /**
+ * Assert that the console command exited successfully (with a zero exit code).
+ *
+ * ```php
+ * $this->command(ConsoleController::class, 'actionName')->assertSuccessful();
+ * ```
+ */
+ public function assertSuccesful(): self
{
Assert::assertSame(0, $this->exitCode);
+
+ return $this;
+ }
+
+ /**
+ * Assert that the console command failed (with a non-zero exit code).
+ *
+ * ```php
+ * $this->command(ConsoleController::class, 'actionName')->assertFailed();
+ * ```
+ */
+ public function assertFailed(): self
+ {
+ Assert::assertNotSame(0, $this->exitCode);
+
+ return $this;
+ }
+
+ /**
+ * Assert the integer exit code
+ *
+ * ```php
+ * $this->command(ConsoleController::class, 'actionName')->assertExitCode(1337);
+ * ```
+ */
+ public function assertExitCode(int $exitCode): self
+ {
+ Assert::assertSame($exitCode, $this->exitCode);
+
+ return $this;
+ }
+
+ /**
+ * Assert that the command contains the passed text in stdout or stderr
+ *
+ * ```php
+ * $this->command(ConsoleController::class, 'actionName')->assertSee('text output');
+ * ```
+ */
+ public function assertSee(string $text): self
+ {
+ Assert::assertStringContainsString($text, $this->stdout.$this->stderr);
+
+ return $this;
+ }
+
+ /**
+ * Assert that the command does not contain the passed text in stdout or stderr
+ *
+ * ```php
+ * $this->command(ConsoleController::class, 'actionName')->assertDontSee('text output');
+ * ```
+ */
+ public function assertDontSee(string $text): self
+ {
+ Assert::assertStringNotContainsString($text, $this->stdout.$this->stderr);
+
+ return $this;
}
}
diff --git a/src/factories/Asset.php b/src/factories/Asset.php
index 573823b..a767cdd 100644
--- a/src/factories/Asset.php
+++ b/src/factories/Asset.php
@@ -25,7 +25,7 @@
* > **Note**
* > Any assets created during a test will be cleaned up and deleted after the test.
*
- * @extends Factory<\craft\elements\Asset>
+ * @extends Element<\craft\elements\Asset>
*/
class Asset extends Element
{
diff --git a/src/factories/Category.php b/src/factories/Category.php
index dd8bf74..80f8008 100644
--- a/src/factories/Category.php
+++ b/src/factories/Category.php
@@ -4,7 +4,8 @@
/**
* @method self title(string $title)
- * @extends Factory<\craft\elements\Category>
+ *
+ * @extends Element<\craft\elements\Category>
*/
class Category extends Element
{
diff --git a/src/factories/Element.php b/src/factories/Element.php
index 90908a7..e4b1a21 100644
--- a/src/factories/Element.php
+++ b/src/factories/Element.php
@@ -10,6 +10,9 @@
use function markhuot\craftpest\helpers\base\array_wrap;
use function markhuot\craftpest\helpers\base\collection_wrap;
+/**
+ * @template T
+ */
abstract class Element extends Factory
{
use AddsMatrixBlocks;
diff --git a/src/factories/Entry.php b/src/factories/Entry.php
index 0b363ec..e99a106 100644
--- a/src/factories/Entry.php
+++ b/src/factories/Entry.php
@@ -24,6 +24,7 @@
* @phpstan-ignore-next-line ignored because the file is generated
*
* @mixin FactoryFields
+ *
* @extends Factory<\craft\elements\Entry>
*/
class Entry extends Element
@@ -105,7 +106,7 @@ public function setDateField($key, $value)
$this->attributes[$key] = $value;
}
- public function isProvisionalDraft($provisionalDraft=true): self
+ public function isProvisionalDraft($provisionalDraft = true): self
{
$this->attributes['isProvisionalDraft'] = $provisionalDraft;
diff --git a/src/factories/MatrixField.php b/src/factories/MatrixField.php
index 27142d0..d0fb3c4 100644
--- a/src/factories/MatrixField.php
+++ b/src/factories/MatrixField.php
@@ -7,16 +7,18 @@
class MatrixField extends Field
{
- public static function factory()
+ public static function factory(): static
{
if (InstalledVersions::satisfies(new VersionParser, 'craftcms/cms', '~5.0')) {
+ // @phpstan-ignore-next-line
return MatrixFieldEntries::factory();
}
if (InstalledVersions::satisfies(new VersionParser, 'craftcms/cms', '~4.0')) {
+ // @phpstan-ignore-next-line
return MatrixFieldBlocks::factory();
}
- throw new \RuntimeException('bad version');
+ throw new \RuntimeException('Craft Pest is not compatible with this version of Craft CMS.');
}
}
diff --git a/src/factories/MatrixFieldBlocks.php b/src/factories/MatrixFieldBlocks.php
index 3c44d46..a578d87 100644
--- a/src/factories/MatrixFieldBlocks.php
+++ b/src/factories/MatrixFieldBlocks.php
@@ -7,10 +7,15 @@
use function markhuot\craftpest\helpers\base\version_greater_than_or_equal_to;
-class MatrixFieldBlocks extends Field
+class MatrixFieldBlocks extends MatrixField
{
protected $blockTypes = [];
+ public static function factory(): static
+ {
+ return new static;
+ }
+
public function blockTypes(...$blockTypes)
{
if (is_array($blockTypes[0])) {
diff --git a/src/factories/MatrixFieldEntries.php b/src/factories/MatrixFieldEntries.php
index f7377e2..dcc4812 100644
--- a/src/factories/MatrixFieldEntries.php
+++ b/src/factories/MatrixFieldEntries.php
@@ -4,13 +4,18 @@
use craft\fields\Matrix;
-class MatrixFieldEntries extends Field
+class MatrixFieldEntries extends MatrixField
{
/**
* @var EntryType[]
*/
protected $entryTypes = [];
+ public static function factory(): static
+ {
+ return new static;
+ }
+
public function entryTypes(...$entryTypes)
{
if (is_array($entryTypes[0])) {
diff --git a/src/factories/User.php b/src/factories/User.php
index 0899163..a22ea45 100644
--- a/src/factories/User.php
+++ b/src/factories/User.php
@@ -4,7 +4,8 @@
/**
* @method self admin(bool $isAdmin)
- * @extends Factory<\craft\elements\User>
+ *
+ * @extends Element<\craft\elements\User>
*/
class User extends Element
{
diff --git a/src/io/Buffer.php b/src/io/Buffer.php
new file mode 100644
index 0000000..fb128bf
--- /dev/null
+++ b/src/io/Buffer.php
@@ -0,0 +1,38 @@
+streamName = match ($this->filtername) {
+ 'craftpest.buffer.stdout' => 'stdout',
+ 'craftpest.buffer.stderr' => 'stderr',
+ default => throw new \RuntimeException('Unknown stream'),
+ };
+
+ return true;
+ }
+
+ public function filter($in, $out, &$consumed, $closing): int
+ {
+ while ($bucket = stream_bucket_make_writeable($in)) {
+ if ($this->streamName === 'stdout') {
+ test()->storeStdOut($bucket->data);
+ } else {
+ test()->storeStdErr($bucket->data);
+ }
+
+ $bucket->data = '';
+ $consumed += $bucket->datalen;
+ stream_bucket_append($out, $bucket);
+ }
+
+ return PSFS_PASS_ON;
+ }
+}
diff --git a/src/test/ExecuteConsoleCommands.php b/src/test/ExecuteConsoleCommands.php
new file mode 100644
index 0000000..99e4f80
--- /dev/null
+++ b/src/test/ExecuteConsoleCommands.php
@@ -0,0 +1,42 @@
+stdout .= $out;
+ }
+
+ public function storeStdErr(string $err): void
+ {
+ $this->stderr .= $err;
+ }
+
+ public function console(string $className, string $action, array $params = []): TestableResponse
+ {
+ $stdout = stream_filter_append(\STDOUT, 'craftpest.buffer.stdout');
+ $stderr = stream_filter_append(\STDERR, 'craftpest.buffer.stderr');
+
+ $controller = new $className('id', \Craft::$app);
+ $exitCode = call_user_func_array([$controller, 'action'.ucfirst($action)], $params);
+
+ stream_filter_remove($stdout);
+ stream_filter_remove($stderr);
+
+ return new TestableResponse($exitCode, $this->stdout, $this->stderr);
+ }
+}
diff --git a/src/test/TestCase.php b/src/test/TestCase.php
index 5eea605..d8d6fa7 100644
--- a/src/test/TestCase.php
+++ b/src/test/TestCase.php
@@ -6,7 +6,6 @@
use craft\helpers\App;
use Illuminate\Support\Collection;
use markhuot\craftpest\actions\CallSeeders;
-use markhuot\craftpest\console\TestableResponse;
use markhuot\craftpest\interfaces\RenderCompiledClassesInterface;
use Symfony\Component\Process\Process;
@@ -18,6 +17,7 @@ class TestCase extends \PHPUnit\Framework\TestCase
CookieState,
DatabaseAssertions,
Dd,
+ ExecuteConsoleCommands,
Mocks,
RequestBuilders,
SnapshotAssertions,
@@ -233,21 +233,6 @@ public function seed(callable|string ...$seeders): self
return $this;
}
- public function console(array|string $command)
- {
- if (! is_array($command)) {
- $command = [$command];
- }
-
- $craft = getenv('CRAFT_EXE_PATH') ?: './craft';
- $process = new Process([$craft, ...$command]);
- $exitCode = $process->run();
- $stdout = $process->getOutput();
- $stderr = $process->getErrorOutput();
-
- return new TestableResponse($exitCode, $stdout, $stderr);
- }
-
public function renderTemplate(...$args)
{
$content = Craft::$app->getView()->renderTemplate(...$args);
diff --git a/tests/ConsoleCommandTest.php b/tests/ConsoleCommandTest.php
new file mode 100644
index 0000000..f193e17
--- /dev/null
+++ b/tests/ConsoleCommandTest.php
@@ -0,0 +1,18 @@
+console(PestController::class, 'internal')
+ ->assertSuccesful()
+ ->assertSee('stdout')
+ ->assertSee('stderr')
+ ->assertDontSee('missing');
+
+it('gets stdout and stderr', function () {
+ $response = $this->console(PestController::class, 'internal');
+
+ expect($response->exitCode)->toBe(0);
+ expect($response->stdout)->toContain('stdout');
+ expect($response->stderr)->toContain('stderr');
+});