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
22 changes: 22 additions & 0 deletions src/Concerns/AssertsMany.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Ziadoz\AssertableHtml\Concerns;

use PHPUnit\Framework\AssertionFailedError;

trait AssertsMany
{
/** Perform many PHPUnit assertions in a callback, but capture any failures into a single exception. */
public function assertMany(callable $callback, ?string $message = null): static
{
try {
$callback(...)->call($this);
} catch (AssertionFailedError $assertion) {
throw new AssertionFailedError(message: $message ?? $assertion->getMessage(), previous: $assertion);
}

return $this;
}
}
14 changes: 14 additions & 0 deletions src/Contracts/PromotableAssertableElement.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Ziadoz\AssertableHtml\Contracts;

use Dom\Element;
use Dom\HTMLElement;

interface PromotableAssertableElement
{
/** Return if the HTML element should be promoted by this element-specific assertable element. */
public static function shouldPromote(HTMLElement|Element $element): bool;
}
14 changes: 7 additions & 7 deletions src/Dom/AssertableDocument.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ public function dd(): never
/** Create an assertable document from a file. */
public static function createFromFile(string $path, int $options = 0, ?string $overrideEncoding = null): self
{
return self::promoteErrorsToExceptions(fn () => new self(HTMLDocument::createFromFile($path, $options, $overrideEncoding)));
return self::convertErrorsToExceptions(fn () => new self(HTMLDocument::createFromFile($path, $options, $overrideEncoding)));
}

/** Create an assertable document from a string. */
public static function createFromString(string $source, int $options = 0, ?string $overrideEncoding = null): self
{
return self::promoteErrorsToExceptions(fn () => new self(HTMLDocument::createFromString($source, $options, $overrideEncoding)));
return self::convertErrorsToExceptions(fn () => new self(HTMLDocument::createFromString($source, $options, $overrideEncoding)));
}

/** Return the assertable element matching the given selector. */
Expand All @@ -76,7 +76,7 @@ public function querySelector(string $selector): AssertableElement
));
}

return new AssertableElement($element);
return new AssertableElement($element)->promote();
}

/** Return assertable elements matching the given selector. */
Expand All @@ -95,7 +95,7 @@ public function getElementById(string $id): AssertableElement
));
}

return new AssertableElement($element);
return new AssertableElement($element)->promote();
}

/** Return assertable elements matching the given tag. */
Expand All @@ -104,14 +104,14 @@ public function getElementsByTagName(string $tag): AssertableElementsList
return new AssertableElementsList($this->document->getElementsByTagName($tag));
}

/** Promote any PHP errors that occur in the given callback to custom exceptions. */
private static function promoteErrorsToExceptions(callable $callback): mixed
/** Convert any PHP errors that occur in the given callback to custom exceptions. */
private static function convertErrorsToExceptions(callable $callback): mixed
{
try {
set_error_handler(function (int $severity, string $message, string $file, int $line): never {
throw new UnableToCreateAssertableDocument(
'Unable to create assertable HTML document.',
previous: new ErrorException($message, $severity, $severity, $file, $line),
previous: new ErrorException($message, 0, $severity, $file, $line),
);
});

Expand Down
6 changes: 6 additions & 0 deletions src/Dom/AssertableElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ private function getElement(): HTMLElement|Element
return $this->element;
}

/** Promote this assertable element to an element-specific equivalent assertable element, if possible. */
public function promote(): static
{
return new AssertableElementPromoter($this->getElement())->promote() ?? $this;
}

/** Get the assertable element HTML. */
public function getHtml(): string
{
Expand Down
33 changes: 33 additions & 0 deletions src/Dom/AssertableElementPromoter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Ziadoz\AssertableHtml\Dom;

use Dom\Element;
use Dom\HTMLElement;
use Ziadoz\AssertableHtml\Contracts\PromotableAssertableElement;
use Ziadoz\AssertableHtml\Dom\Elements\AssertableForm;

final readonly class AssertableElementPromoter
{
private const array CUSTOM_ELEMENTS = [
AssertableForm::class,
];

/** Create a core assertable element. */
public function __construct(private HTMLElement|Element $element)
{
}

/** Promote and return the first matching assertable element that matches the given HTML element. */
public function promote(): (PromotableAssertableElement&AssertableElement)|null
{
$match = array_find(
self::CUSTOM_ELEMENTS,
fn (string $customElement): bool => $customElement::shouldPromote($this->element),
);

return $match ? new $match($this->element) : null;
}
}
2 changes: 1 addition & 1 deletion src/Dom/AssertableElementsList.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function __construct(NodeList|HTMLCollection $nodes)
{
$this->elements = array_values(
array_map(
fn (HTMLElement|Element $element): AssertableElement => new AssertableElement($element),
fn (HTMLElement|Element $element): AssertableElement => new AssertableElement($element)->promote(),
$nodes instanceof NodeList
? iterator_to_array($nodes)
: $this->htmlCollectionToArray($nodes),
Expand Down
133 changes: 133 additions & 0 deletions src/Dom/Elements/AssertableForm.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

namespace Ziadoz\AssertableHtml\Dom\Elements;

use Dom\Element;
use Dom\HTMLElement;
use Ziadoz\AssertableHtml\Concerns\AssertsMany;
use Ziadoz\AssertableHtml\Contracts\PromotableAssertableElement;
use Ziadoz\AssertableHtml\Dom\AssertableElement;

readonly class AssertableForm extends AssertableElement implements PromotableAssertableElement
{
use AssertsMany;

/*
|--------------------------------------------------------------------------
| Interface
|--------------------------------------------------------------------------
*/

/** {@inheritDoc} */
public static function shouldPromote(Element|HTMLElement $element): bool
{
return $element->tagName === 'FORM';
}

/*
|--------------------------------------------------------------------------
| Assert Method
|--------------------------------------------------------------------------
*/

/** Assert the form has the given method attribute. */
public function assertMethod(string $method, ?string $message = null): static
{
$method = trim(mb_strtolower($method));

$this->assertAttribute(
'method',
fn (?string $value): bool => trim(mb_strtolower((string) $value)) === $method,
$message ?? sprintf("The form method doesn't equal [%s].", $method),
);

return $this;
}

/** Assert the form has the GET method attribute. */
public function assertMethodGet(?string $message = null): static
{
$this->assertMethod('get', $message);

return $this;
}

/** Assert the form has the POST method attribute. */
public function assertMethodPost(?string $message = null): static
{
$this->assertMethod('post', $message);

return $this;
}

/** Assert the form has the DIALOG method attribute. */
public function assertMethodDialog(?string $message = null): static
{
$this->assertMethod('dialog', $message);

return $this;
}

/*
|--------------------------------------------------------------------------
| Assert Hidden Method
|--------------------------------------------------------------------------
*/

/** Assert the form has the given hidden input method. */
public function assertHiddenInputMethod(string $selector, string $method, ?string $message = null): static
{
$this->assertMany(function () use ($selector, $method): void {
$method = trim(mb_strtolower($method));

$this->querySelector($selector)
->assertMatchesSelector('input[type="hidden"]')
->assertAttribute('value', fn (?string $value): bool => trim(mb_strtolower((string) $value)) === $method);
}, $message ?? sprintf("The form hidden input method doesn't equal [%s].", $method));

return $this;
}

/** Assert the form has the PUT hidden input method. */
public function assertMethodPut(?string $message = null): static
{
$this->assertHiddenInputMethod('input[type="hidden"][name="_method"]', 'put', $message);

return $this;
}

/** Assert the form has the PATCH hidden input method. */
public function assertMethodPatch(?string $message = null): static
{
$this->assertHiddenInputMethod('input[type="hidden"][name="_method"]', 'patch', $message);

return $this;
}

/** Assert the form has the DELETE hidden input method. */
public function assertMethodDelete(?string $message = null): static
{
$this->assertHiddenInputMethod('input[type="hidden"][name="_method"]', 'delete', $message);

return $this;
}

/*
|--------------------------------------------------------------------------
| Assert Upload
|--------------------------------------------------------------------------
*/

/** Assert the form accepts uploads (has correct enctype and at least one file input. */
public function assertAcceptsUpload(?string $message = null): static
{
$this->assertMany(function (): void {
$this->assertAttribute('enctype', fn (?string $value): bool => trim(mb_strtolower((string) $value)) === 'multipart/form-data')
->assertElementsCountGreaterThanOrEqual('input[type="file"]', 1);
}, $message ?? "The form doesn't accept uploads.");

return $this;
}
}
44 changes: 44 additions & 0 deletions tests/Integration/AssertableFormTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace Ziadoz\AssertableHtml\Tests\Integration;

use PHPUnit\Framework\TestCase;
use Ziadoz\AssertableHtml\Dom\AssertableDocument;

class AssertableFormTest extends TestCase
{
public function test_assertable_form(): void
{
// Form Methods
AssertableDocument::createFromString('<form method="GET"></form>', LIBXML_HTML_NOIMPLIED)
->querySelector('form')
->assertMethodGet();

AssertableDocument::createFromString('<form method="POST"></form>', LIBXML_HTML_NOIMPLIED)
->querySelector('form')
->assertMethodPost();

AssertableDocument::createFromString('<form method="DIALOG"></form>', LIBXML_HTML_NOIMPLIED)
->querySelector('form')
->assertMethodDialog();

AssertableDocument::createFromString('<form><input type="hidden" name="_method" value="PUT"></form>', LIBXML_HTML_NOIMPLIED)
->querySelector('form')
->assertMethodPut();

AssertableDocument::createFromString('<form><input type="hidden" name="_method" value="PATCH"></form>', LIBXML_HTML_NOIMPLIED)
->querySelector('form')
->assertMethodPatch();

AssertableDocument::createFromString('<form><input type="hidden" name="_method" value="DELETE"></form>', LIBXML_HTML_NOIMPLIED)
->querySelector('form')
->assertMethodDelete();

// Form Uploads
AssertableDocument::createFromString('<form enctype="multipart/form-data"><input type="file"></form>', LIBXML_HTML_NOIMPLIED)
->querySelector('form')
->assertAcceptsUpload();
}
}
34 changes: 34 additions & 0 deletions tests/Unit/Concerns/Asserts/AssertsManyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Ziadoz\AssertableHtml\Tests\Unit\Concerns\Asserts;

use PHPUnit\Framework\Assert as PHPUnit;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\TestCase;
use Ziadoz\AssertableHtml\Concerns\AssertsMany;

class AssertsManyTest extends TestCase
{
public function test_asserts_many(): void
{
try {
$object = $this->getAssertsMany();
$object->assertMany(function () {
PHPUnit::assertSame('Foo', 'Bar', 'Foo is not Bar');
}, 'The test assertion failed');
} catch (AssertionFailedError $exception) {
$this->assertSame('The test assertion failed', $exception->getMessage());
$this->assertSame('Foo is not Bar' . "\n" . 'Failed asserting that two strings are identical.', $exception->getPrevious()->getMessage());
}
}

private function getAssertsMany(): object
{
return new class
{
use AssertsMany;
};
}
}
Loading