diff --git a/README.md b/README.md
index db394fa0..7b7f1519 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ from the community.
| [JsonTrait](STEPS.md#jsontrait) | Assert JSON responses with path and schema checks. |
| [KeyboardTrait](STEPS.md#keyboardtrait) | Simulate keyboard interactions in Drupal browser testing. |
| [LinkTrait](STEPS.md#linktrait) | Verify link elements with attribute and content assertions. |
-| [MetatagTrait](STEPS.md#metatagtrait) | Assert `` tags in page markup. |
+| [MetatagTrait](STEPS.md#metatagtrait) | Assert `` tags and head/SEO markup in page markup. |
| [ModalTrait](STEPS.md#modaltrait) | Interact with and assert modals. |
| [PathTrait](STEPS.md#pathtrait) | Navigate and verify paths with URL validation. |
| [ResponseTrait](STEPS.md#responsetrait) | Verify HTTP responses with status code and header checks. |
diff --git a/STEPS.md b/STEPS.md
index 4a7ab7f0..5653e643 100644
--- a/STEPS.md
+++ b/STEPS.md
@@ -18,7 +18,7 @@
| [JsonTrait](#jsontrait) | Assert JSON responses with path and schema checks. |
| [KeyboardTrait](#keyboardtrait) | Simulate keyboard interactions in Drupal browser testing. |
| [LinkTrait](#linktrait) | Verify link elements with attribute and content assertions. |
-| [MetatagTrait](#metatagtrait) | Assert `` tags in page markup. |
+| [MetatagTrait](#metatagtrait) | Assert `` tags and head/SEO markup in page markup. |
| [ModalTrait](#modaltrait) | Interact with and assert modals. |
| [PathTrait](#pathtrait) | Navigate and verify paths with URL validation. |
| [ResponseTrait](#responsetrait) | Verify HTTP responses with status code and header checks. |
@@ -2154,9 +2154,12 @@ Then the link "Return to site content" should not be an absolute link
[Source](src/MetatagTrait.php), [Example](tests/behat/features/metatag.feature)
-> Assert `` tags in page markup.
+> Assert `` tags and head/SEO markup in page markup.
> - Assert presence and content of meta tags with proper attribute handling.
> - Verify meta tag content is free of HTML markup.
+> - Assert canonical URL, robots directives and indexability.
+> - Assert hreflang alternates are valid and reciprocal.
+> - Assert Open Graph and Twitter Card completeness.
@@ -2206,6 +2209,196 @@ Then the "description" meta tag should not contain any HTML tags
+
+ @Then the canonical URL should be :url
+
+
+Assert the canonical URL equals a value
+
+
+```gherkin
+Then the canonical URL should be "https://example.com/about"
+Then the canonical URL should be "/about"
+
+```
+
+
+
+
+ @Then the canonical URL should exist
+
+
+Assert the canonical URL is present
+
+
+```gherkin
+Then the canonical URL should exist
+
+```
+
+
+
+
+ @Then the canonical URL should not exist
+
+
+Assert the canonical URL is absent
+
+
+```gherkin
+Then the canonical URL should not exist
+
+```
+
+
+
+
+ @Then the page should be indexable
+
+
+Assert the page is indexable
+
+
+```gherkin
+Then the page should be indexable
+
+```
+
+
+
+
+ @Then the page should not be indexable
+
+
+Assert the page is not indexable
+
+
+```gherkin
+Then the page should not be indexable
+
+```
+
+
+
+
+ @Then the meta robots should include :directive
+
+
+Assert the robots meta tag includes a directive
+
+
+```gherkin
+Then the meta robots should include "noindex"
+Then the meta robots should include "nofollow"
+
+```
+
+
+
+
+ @Then the meta robots should not include :directive
+
+
+Assert the robots meta tag does not include a directive
+
+
+```gherkin
+Then the meta robots should not include "noindex"
+Then the meta robots should not include "nofollow"
+
+```
+
+
+
+
+ @Then the hreflang alternates should be valid
+
+
+Assert hreflang alternates are valid
+
+
+```gherkin
+Then the hreflang alternates should be valid
+
+```
+
+
+
+
+ @Then the hreflang alternates should have reciprocal return links
+
+
+Assert hreflang alternates have reciprocal return links
+
+
+```gherkin
+Then the hreflang alternates should have reciprocal return links
+
+```
+
+
+
+
+ @Then the Open Graph tags should be valid
+
+
+Assert the required Open Graph meta tags are present and non-empty
+
+
+```gherkin
+Then the Open Graph tags should be valid
+
+```
+
+
+
+
+ @Then the following Open Graph tags should exist:
+
+
+Assert the listed Open Graph meta tags are present and non-empty
+
+
+```gherkin
+Then the following Open Graph tags should exist:
+ | og:title |
+ | og:image |
+ | og:url |
+
+```
+
+
+
+
+ @Then the Twitter Card tags should be valid
+
+
+Assert the required Twitter Card meta tags are present and non-empty
+
+
+```gherkin
+Then the Twitter Card tags should be valid
+
+```
+
+
+
+
+ @Then the following Twitter Card tags should exist:
+
+
+Assert the listed Twitter Card meta tags are present and non-empty
+
+
+```gherkin
+Then the following Twitter Card tags should exist:
+ | twitter:card |
+ | twitter:title |
+
+```
+
+
+
## ModalTrait
[Source](src/ModalTrait.php), [Example](tests/behat/features/modal.feature)
diff --git a/src/MetatagTrait.php b/src/MetatagTrait.php
index 3a5f30be..c5bb5aca 100644
--- a/src/MetatagTrait.php
+++ b/src/MetatagTrait.php
@@ -6,16 +6,22 @@
use Behat\Step\Then;
use Behat\Gherkin\Node\TableNode;
+use Behat\Mink\Element\NodeElement;
use Behat\Mink\Selector\Xpath\Escaper;
/**
- * Assert `` tags in page markup.
+ * Assert `` tags and head/SEO markup in page markup.
*
* - Assert presence and content of meta tags with proper attribute handling.
* - Verify meta tag content is free of HTML markup.
+ * - Assert canonical URL, robots directives and indexability.
+ * - Assert hreflang alternates are valid and reciprocal.
+ * - Assert Open Graph and Twitter Card completeness.
*/
trait MetatagTrait {
+ use HelperTrait;
+
/**
* Assert that a meta tag with specific attributes and values exists.
*
@@ -103,10 +109,7 @@ public function metatagAssertWithAttributesNotExists(TableNode $table): void {
*/
#[Then('the :metaName meta tag should not contain any HTML tags')]
public function metatagAssertNoHtml(string $meta_name): void {
- $page = $this->getSession()->getPage();
- $escaped_name = (new Escaper())->escapeLiteral($meta_name);
-
- $meta_tag = $page->find('xpath', sprintf('//meta[@name=%s or @property=%s]', $escaped_name, $escaped_name));
+ $meta_tag = $this->metatagFindMeta($meta_name);
if ($meta_tag === NULL) {
throw new \Exception(sprintf('Meta tag with name or property "%s" not found.', $meta_name));
@@ -119,4 +122,581 @@ public function metatagAssertNoHtml(string $meta_name): void {
}
}
+ /**
+ * Assert the canonical URL equals a value.
+ *
+ * Both the actual and expected URLs are resolved to absolute form against
+ * the Mink base URL, so a relative expected value matches an absolute
+ * canonical href for the same page.
+ *
+ * @code
+ * Then the canonical URL should be "https://example.com/about"
+ * Then the canonical URL should be "/about"
+ * @endcode
+ */
+ #[Then('the canonical URL should be :url')]
+ public function metatagAssertCanonicalEquals(string $url): void {
+ $href = $this->metatagGetCanonicalHref();
+
+ if ($href === NULL || $href === '') {
+ throw new \Exception('The canonical URL is not set.');
+ }
+
+ if ($this->metatagResolveUrl($href) !== $this->metatagResolveUrl($url)) {
+ throw new \Exception(sprintf('The canonical URL is "%s", but expected "%s".', $href, $url));
+ }
+ }
+
+ /**
+ * Assert the canonical URL is present.
+ *
+ * @code
+ * Then the canonical URL should exist
+ * @endcode
+ */
+ #[Then('the canonical URL should exist')]
+ public function metatagAssertCanonicalExists(): void {
+ $href = $this->metatagGetCanonicalHref();
+
+ if ($href === NULL || $href === '') {
+ throw new \Exception('The canonical URL is not set.');
+ }
+ }
+
+ /**
+ * Assert the canonical URL is absent.
+ *
+ * @code
+ * Then the canonical URL should not exist
+ * @endcode
+ */
+ #[Then('the canonical URL should not exist')]
+ public function metatagAssertCanonicalNotExists(): void {
+ $href = $this->metatagGetCanonicalHref();
+
+ if ($href !== NULL && $href !== '') {
+ throw new \Exception(sprintf('The canonical URL should not be set, but found "%s".', $href));
+ }
+ }
+
+ /**
+ * Assert the page is indexable.
+ *
+ * The page is indexable when the robots meta tag has no "noindex" (or "none")
+ * directive and the "X-Robots-Tag" response header has no "noindex" directive.
+ *
+ * @code
+ * Then the page should be indexable
+ * @endcode
+ */
+ #[Then('the page should be indexable')]
+ public function metatagAssertIndexable(): void {
+ if (!$this->metatagIsIndexable()) {
+ throw new \Exception('The page is not indexable: a "noindex" directive is present in the robots meta tag or the "X-Robots-Tag" header.');
+ }
+ }
+
+ /**
+ * Assert the page is not indexable.
+ *
+ * @code
+ * Then the page should not be indexable
+ * @endcode
+ */
+ #[Then('the page should not be indexable')]
+ public function metatagAssertNotIndexable(): void {
+ if ($this->metatagIsIndexable()) {
+ throw new \Exception('The page is indexable, but it should not be: no "noindex" directive found in the robots meta tag or the "X-Robots-Tag" header.');
+ }
+ }
+
+ /**
+ * Assert the robots meta tag includes a directive.
+ *
+ * Directives are matched as whole, case-insensitive tokens, so a request for
+ * "follow" never matches a "nofollow" directive.
+ *
+ * @code
+ * Then the meta robots should include "noindex"
+ * Then the meta robots should include "nofollow"
+ * @endcode
+ */
+ #[Then('the meta robots should include :directive')]
+ public function metatagAssertRobotsIncludes(string $directive): void {
+ $directives = $this->metatagGetRobotsDirectives();
+
+ if (!in_array(strtolower(trim($directive)), $directives, TRUE)) {
+ throw new \Exception(sprintf('The robots meta tag does not include the "%s" directive. Found: %s.', $directive, $directives === [] ? '(none)' : implode(', ', $directives)));
+ }
+ }
+
+ /**
+ * Assert the robots meta tag does not include a directive.
+ *
+ * @code
+ * Then the meta robots should not include "noindex"
+ * Then the meta robots should not include "nofollow"
+ * @endcode
+ */
+ #[Then('the meta robots should not include :directive')]
+ public function metatagAssertRobotsNotIncludes(string $directive): void {
+ $directives = $this->metatagGetRobotsDirectives();
+
+ if (in_array(strtolower(trim($directive)), $directives, TRUE)) {
+ throw new \Exception(sprintf('The robots meta tag includes the "%s" directive, but it should not.', $directive));
+ }
+ }
+
+ /**
+ * Assert hreflang alternates are valid.
+ *
+ * Checks, without fetching any alternate page, that at least one hreflang
+ * alternate exists, that a self-referencing alternate for the current URL is
+ * present, and that every hreflang value is a well-formed language code (or
+ * "x-default").
+ *
+ * @code
+ * Then the hreflang alternates should be valid
+ * @endcode
+ */
+ #[Then('the hreflang alternates should be valid')]
+ public function metatagAssertHreflangValid(): void {
+ $alternates = $this->metatagGetHreflangAlternates();
+
+ if ($alternates === []) {
+ throw new \Exception('No hreflang alternate links were found on the page.');
+ }
+
+ foreach ($alternates as $alternate) {
+ if ($alternate['href'] === '') {
+ throw new \Exception(sprintf('The hreflang alternate for "%s" has an empty href.', $alternate['hreflang']));
+ }
+
+ if (!$this->metatagIsValidHreflang($alternate['hreflang'])) {
+ throw new \Exception(sprintf('The hreflang value "%s" is not a valid language code.', $alternate['hreflang']));
+ }
+ }
+
+ $current = $this->metatagResolveUrl($this->getSession()->getCurrentUrl());
+
+ foreach ($alternates as $alternate) {
+ if ($this->metatagResolveUrl($alternate['href']) === $current) {
+ return;
+ }
+ }
+
+ throw new \Exception(sprintf('No self-referencing hreflang alternate was found for the current URL "%s".', $current));
+ }
+
+ /**
+ * Assert hreflang alternates have reciprocal return links.
+ *
+ * Fetches each non-"x-default" alternate page (other than the current page)
+ * and asserts that it links back to the current URL, failing clearly when an
+ * alternate does not reciprocate.
+ *
+ * @code
+ * Then the hreflang alternates should have reciprocal return links
+ * @endcode
+ */
+ #[Then('the hreflang alternates should have reciprocal return links')]
+ public function metatagAssertHreflangReciprocal(): void {
+ $this->metatagAssertHreflangValid();
+
+ $alternates = $this->metatagGetHreflangAlternates();
+ $current = $this->metatagResolveUrl($this->getSession()->getCurrentUrl());
+
+ foreach ($alternates as $alternate) {
+ $target = $this->metatagResolveUrl($alternate['href']);
+
+ if ($target === $current || strtolower((string) $alternate['hreflang']) === 'x-default') {
+ continue;
+ }
+
+ if (!$this->metatagHtmlLinksBackTo($this->metatagFetchUrl($target), $current, $target)) {
+ throw new \Exception(sprintf('The hreflang alternate "%s" (%s) does not link back to the current URL "%s".', $alternate['hreflang'], $target, $current));
+ }
+ }
+ }
+
+ /**
+ * Assert the required Open Graph meta tags are present and non-empty.
+ *
+ * The required set defaults to the Open Graph basics and can be overridden by
+ * the consuming context via metatagOpenGraphRequired().
+ *
+ * @code
+ * Then the Open Graph tags should be valid
+ * @endcode
+ */
+ #[Then('the Open Graph tags should be valid')]
+ public function metatagAssertOpenGraphValid(): void {
+ $this->metatagAssertMetaSetPresent($this->metatagOpenGraphRequired(), 'Open Graph');
+ }
+
+ /**
+ * Assert the listed Open Graph meta tags are present and non-empty.
+ *
+ * @code
+ * Then the following Open Graph tags should exist:
+ * | og:title |
+ * | og:image |
+ * | og:url |
+ * @endcode
+ */
+ #[Then('the following Open Graph tags should exist:')]
+ public function metatagAssertOpenGraphTags(TableNode $table): void {
+ $this->metatagAssertMetaSetPresent($this->metatagTablePropertyNames($table), 'Open Graph');
+ }
+
+ /**
+ * Assert the required Twitter Card meta tags are present and non-empty.
+ *
+ * The required set defaults to the Twitter Card basics and can be overridden
+ * by the consuming context via metatagTwitterCardRequired().
+ *
+ * @code
+ * Then the Twitter Card tags should be valid
+ * @endcode
+ */
+ #[Then('the Twitter Card tags should be valid')]
+ public function metatagAssertTwitterCardValid(): void {
+ $this->metatagAssertMetaSetPresent($this->metatagTwitterCardRequired(), 'Twitter Card');
+ }
+
+ /**
+ * Assert the listed Twitter Card meta tags are present and non-empty.
+ *
+ * @code
+ * Then the following Twitter Card tags should exist:
+ * | twitter:card |
+ * | twitter:title |
+ * @endcode
+ */
+ #[Then('the following Twitter Card tags should exist:')]
+ public function metatagAssertTwitterCardTags(TableNode $table): void {
+ $this->metatagAssertMetaSetPresent($this->metatagTablePropertyNames($table), 'Twitter Card');
+ }
+
+ /**
+ * Find a meta tag by its "name" or "property" attribute.
+ *
+ * @param string $name
+ * The meta tag name or property.
+ *
+ * @return \Behat\Mink\Element\NodeElement|null
+ * The meta element, or NULL when not found.
+ */
+ protected function metatagFindMeta(string $name): ?NodeElement {
+ $escaped_name = (new Escaper())->escapeLiteral($name);
+
+ return $this->getSession()->getPage()->find('xpath', sprintf('//meta[@name=%s or @property=%s]', $escaped_name, $escaped_name));
+ }
+
+ /**
+ * Get the content of a meta tag by its "name" or "property" attribute.
+ *
+ * @param string $name
+ * The meta tag name or property.
+ *
+ * @return string|null
+ * The content attribute value, or NULL when the meta tag is not found.
+ */
+ protected function metatagGetMetaContent(string $name): ?string {
+ $meta = $this->metatagFindMeta($name);
+
+ return $meta === NULL ? NULL : (string) $meta->getAttribute('content');
+ }
+
+ /**
+ * Get the canonical URL href.
+ *
+ * @return string|null
+ * The canonical href, or NULL when no canonical link is present.
+ */
+ protected function metatagGetCanonicalHref(): ?string {
+ $link = $this->getSession()->getPage()->find('xpath', '//link[@rel="canonical"]');
+
+ return $link === NULL ? NULL : (string) $link->getAttribute('href');
+ }
+
+ /**
+ * Get the robots meta tag directives as lower-cased tokens.
+ *
+ * @return array
+ * The directive tokens, or an empty array when no robots meta tag exists.
+ */
+ protected function metatagGetRobotsDirectives(): array {
+ $content = $this->metatagGetMetaContent('robots');
+
+ if ($content === NULL || trim($content) === '') {
+ return [];
+ }
+
+ return array_values(array_filter($this->helperSplitCommaSeparated(strtolower($content)), static fn(string $directive): bool => $directive !== ''));
+ }
+
+ /**
+ * Determine whether the current page is indexable.
+ *
+ * @return bool
+ * TRUE when neither the robots meta tag nor the X-Robots-Tag header carries
+ * a "noindex" directive.
+ */
+ protected function metatagIsIndexable(): bool {
+ $directives = $this->metatagGetRobotsDirectives();
+
+ if (in_array('noindex', $directives, TRUE) || in_array('none', $directives, TRUE)) {
+ return FALSE;
+ }
+
+ return !$this->metatagResponseHasNoindexHeader();
+ }
+
+ /**
+ * Determine whether the X-Robots-Tag response header carries "noindex".
+ *
+ * @return bool
+ * TRUE when any X-Robots-Tag header value includes a "noindex" directive.
+ */
+ protected function metatagResponseHasNoindexHeader(): bool {
+ $headers = $this->getSession()->getResponseHeaders();
+
+ foreach ($headers as $name => $values) {
+ if (strtolower((string) $name) !== 'x-robots-tag') {
+ continue;
+ }
+
+ foreach ((array) $values as $value) {
+ if (preg_match('/\bnoindex\b/i', (string) $value) === 1) {
+ return TRUE;
+ }
+ }
+ }
+
+ return FALSE;
+ }
+
+ /**
+ * Get the hreflang alternates present on the current page.
+ *
+ * @return array
+ * The hreflang alternates, each with its raw hreflang value and href.
+ */
+ protected function metatagGetHreflangAlternates(): array {
+ $alternates = [];
+
+ foreach ($this->getSession()->getPage()->findAll('xpath', '//link[@rel="alternate"][@hreflang]') as $link) {
+ $alternates[] = [
+ 'hreflang' => (string) $link->getAttribute('hreflang'),
+ 'href' => (string) $link->getAttribute('href'),
+ ];
+ }
+
+ return $alternates;
+ }
+
+ /**
+ * Determine whether a hreflang value is a well-formed language code.
+ *
+ * Accepts the special "x-default" value and BCP-47-style tags such as "en",
+ * "en-US" and "zh-Hant". This is a structural check, not a lookup against the
+ * ISO language and region registries.
+ *
+ * @param string $value
+ * The hreflang value to validate.
+ *
+ * @return bool
+ * TRUE when the value is a well-formed language code.
+ */
+ protected function metatagIsValidHreflang(string $value): bool {
+ if (strtolower($value) === 'x-default') {
+ return TRUE;
+ }
+
+ return preg_match('/^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i', $value) === 1;
+ }
+
+ /**
+ * Fetch a URL out of band without disturbing the Mink session.
+ *
+ * @param string $url
+ * The absolute URL to fetch.
+ *
+ * @return string
+ * The response body.
+ */
+ protected function metatagFetchUrl(string $url): string {
+ $handle = curl_init($url);
+
+ if ($handle === FALSE) {
+ // @codeCoverageIgnoreStart
+ throw new \Exception(sprintf('Failed to initialise a request for "%s".', $url));
+ // @codeCoverageIgnoreEnd
+ }
+
+ curl_setopt($handle, CURLOPT_RETURNTRANSFER, TRUE);
+ curl_setopt($handle, CURLOPT_FOLLOWLOCATION, TRUE);
+ curl_setopt($handle, CURLOPT_TIMEOUT, 30);
+
+ $body = curl_exec($handle);
+ $status = curl_getinfo($handle, CURLINFO_HTTP_CODE);
+ curl_close($handle);
+
+ if (!is_string($body)) {
+ // @codeCoverageIgnoreStart
+ throw new \Exception(sprintf('Failed to fetch the hreflang alternate page "%s".', $url));
+ // @codeCoverageIgnoreEnd
+ }
+
+ if ($status >= 400) {
+ throw new \Exception(sprintf('The hreflang alternate page "%s" returned HTTP status %d.', $url, $status));
+ }
+
+ return $body;
+ }
+
+ /**
+ * Determine whether fetched HTML links back to a URL via hreflang.
+ *
+ * @param string $html
+ * The HTML markup to inspect.
+ * @param string $url
+ * The absolute URL the markup should link back to.
+ * @param string $base_url
+ * The URL of the fetched page, used to resolve its relative links.
+ *
+ * @return bool
+ * TRUE when a hreflang alternate resolves to the given URL.
+ */
+ protected function metatagHtmlLinksBackTo(string $html, string $url, string $base_url): bool {
+ $previous = libxml_use_internal_errors(TRUE);
+
+ try {
+ $document = new \DOMDocument();
+ $document->loadHTML($html);
+ libxml_clear_errors();
+
+ $xpath = new \DOMXPath($document);
+ $links = $xpath->query('//link[@rel="alternate"][@hreflang]');
+
+ if ($links === FALSE) {
+ // @codeCoverageIgnoreStart
+ return FALSE;
+ // @codeCoverageIgnoreEnd
+ }
+
+ foreach ($links as $link) {
+ if ($link instanceof \DOMElement && $this->metatagResolveUrl($link->getAttribute('href'), $base_url) === $url) {
+ return TRUE;
+ }
+ }
+
+ return FALSE;
+ }
+ finally {
+ libxml_use_internal_errors($previous);
+ }
+ }
+
+ /**
+ * Resolve an absolute or root-relative URL against a base URL's origin.
+ *
+ * Absolute URLs are returned unchanged and root-relative URLs are resolved
+ * against the base URL's origin. Document-relative URLs (such as "page.html"
+ * or "../en") are resolved against the origin rather than the base path, so
+ * hreflang and canonical markup should use absolute or root-relative URLs, in
+ * line with search-engine guidance to use fully-qualified URLs.
+ *
+ * @param string $url
+ * The URL to resolve.
+ * @param string|null $base
+ * The base URL whose origin relative URLs resolve against. Defaults to the
+ * Mink base URL.
+ *
+ * @return string
+ * The resolved absolute URL.
+ */
+ protected function metatagResolveUrl(string $url, ?string $base = NULL): string {
+ if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) {
+ return $url;
+ }
+
+ $base ??= (string) $this->getMinkParameter('base_url');
+
+ // Resolve root-relative URLs against the base URL's origin so that links on
+ // a fetched alternate page resolve against that page's host rather than the
+ // Mink base URL.
+ $origin = (string) preg_replace('#^(https?://[^/]+).*$#i', '$1', $base);
+
+ return rtrim($origin, '/') . '/' . ltrim($url, '/');
+ }
+
+ /**
+ * Assert that a set of meta tags is present and non-empty.
+ *
+ * @param array $names
+ * The meta tag names or properties that must be present.
+ * @param string $label
+ * A human-readable label for the set, used in the failure message.
+ */
+ protected function metatagAssertMetaSetPresent(array $names, string $label): void {
+ $missing = [];
+
+ foreach ($names as $name) {
+ $content = $this->metatagGetMetaContent($name);
+
+ if ($content === NULL || trim($content) === '') {
+ $missing[] = $name;
+ }
+ }
+
+ if ($missing !== []) {
+ throw new \Exception(sprintf('The following required %s meta tags are missing or empty: %s.', $label, implode(', ', $missing)));
+ }
+ }
+
+ /**
+ * Extract meta tag names from the first column of a table.
+ *
+ * @param \Behat\Gherkin\Node\TableNode $table
+ * The table whose first column lists meta tag names or properties.
+ *
+ * @return array
+ * The trimmed, non-empty meta tag names.
+ */
+ protected function metatagTablePropertyNames(TableNode $table): array {
+ $names = [];
+
+ foreach ($table->getRows() as $row) {
+ $name = trim((string) ($row[0] ?? ''));
+
+ if ($name !== '') {
+ $names[] = $name;
+ }
+ }
+
+ return $names;
+ }
+
+ /**
+ * The Open Graph meta tags required by "the Open Graph tags should be valid".
+ *
+ * @return array
+ * The required Open Graph property names.
+ */
+ protected function metatagOpenGraphRequired(): array {
+ return ['og:title', 'og:type', 'og:image', 'og:url'];
+ }
+
+ /**
+ * The Twitter Card tags required by "the Twitter Card tags should be valid".
+ *
+ * @return array
+ * The required Twitter Card property names.
+ */
+ protected function metatagTwitterCardRequired(): array {
+ return ['twitter:card', 'twitter:title', 'twitter:description'];
+ }
+
}
diff --git a/tests/behat/features/metatag.feature b/tests/behat/features/metatag.feature
index 4cff0352..ef165569 100644
--- a/tests/behat/features/metatag.feature
+++ b/tests/behat/features/metatag.feature
@@ -86,3 +86,337 @@ Feature: Check that MetatagTrait works
"""
Meta tag with name or property "nonexistent" not found.
"""
+
+ Scenario: Assert canonical URL presence and value
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the canonical URL should exist
+ And the canonical URL should be "/sites/default/files/metatags_seo.html"
+
+ Scenario: Assert canonical URL absence
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags.html"
+ Then the canonical URL should not exist
+
+ Scenario: Assert indexability and robots directives via the robots meta tag
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the page should be indexable
+ And the meta robots should include "index"
+ And the meta robots should not include "noindex"
+
+ Scenario: Assert a non-indexable page via the robots meta tag
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags_noindex.html"
+ Then the page should not be indexable
+ And the meta robots should include "noindex"
+
+ @api
+ Scenario: Assert a non-indexable page via the X-Robots-Tag header
+ When I visit "/mysite_core/test-robots-header"
+ Then the page should not be indexable
+
+ @api
+ Scenario: Assert an indexable page with a non-noindex X-Robots-Tag header
+ When I visit "/mysite_core/test-robots-header?value=all"
+ Then the page should be indexable
+
+ Scenario: Assert hreflang alternates are valid
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the hreflang alternates should be valid
+
+ Scenario: Assert hreflang alternates have reciprocal return links
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags_hreflang_en.html"
+ Then the hreflang alternates should be valid
+ And the hreflang alternates should have reciprocal return links
+
+ Scenario: Assert Open Graph tags are valid and present
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the Open Graph tags should be valid
+ And the following Open Graph tags should exist:
+ | og:title |
+ | og:description |
+
+ Scenario: Assert Twitter Card tags are valid and present
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the Twitter Card tags should be valid
+ And the following Twitter Card tags should exist:
+ | twitter:image |
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the canonical URL should be" fails on mismatch
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the canonical URL should be "/wrong-url"
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The canonical URL is "/sites/default/files/metatags_seo.html", but expected "/wrong-url".
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the canonical URL should be" fails when absent
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags.html"
+ Then the canonical URL should be "/some-url"
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The canonical URL is not set.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the canonical URL should exist" fails when absent
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags.html"
+ Then the canonical URL should exist
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The canonical URL is not set.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the canonical URL should not exist" fails when present
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the canonical URL should not exist
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The canonical URL should not be set, but found "/sites/default/files/metatags_seo.html".
+ """
+
+ Scenario: Assert canonical URL absence for an empty canonical link
+ Given I am an anonymous user
+ When I visit "/sites/default/files/metatags_canonical_empty.html"
+ Then the canonical URL should not exist
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the page should be indexable" fails on a noindex page
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_noindex.html"
+ Then the page should be indexable
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The page is not indexable: a "noindex" directive is present in the robots meta tag or the "X-Robots-Tag" header.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the page should not be indexable" fails on an indexable page
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the page should not be indexable
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The page is indexable, but it should not be: no "noindex" directive found in the robots meta tag or the "X-Robots-Tag" header.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the meta robots should include" fails when the directive is missing
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_seo.html"
+ Then the meta robots should include "noindex"
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The robots meta tag does not include the "noindex" directive. Found: index, follow.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the meta robots should not include" fails when the directive is present
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_noindex.html"
+ Then the meta robots should not include "noindex"
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The robots meta tag includes the "noindex" directive, but it should not.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the hreflang alternates should be valid" fails with no alternates
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags.html"
+ Then the hreflang alternates should be valid
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ No hreflang alternate links were found on the page.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the hreflang alternates should be valid" fails on an invalid language code
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_hreflang_invalid.html"
+ Then the hreflang alternates should be valid
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The hreflang value "english" is not a valid language code.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the hreflang alternates should be valid" fails on an empty href
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_hreflang_emptyhref.html"
+ Then the hreflang alternates should be valid
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The hreflang alternate for "en" has an empty href.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the hreflang alternates should be valid" fails without a self-reference
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_hreflang_noself.html"
+ Then the hreflang alternates should be valid
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ No self-referencing hreflang alternate was found for the current URL
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the hreflang alternates should have reciprocal return links" fails without a return link
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_hreflang_noreturn.html"
+ Then the hreflang alternates should have reciprocal return links
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ does not link back to the current URL
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the hreflang alternates should have reciprocal return links" fails when an alternate is missing
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags_hreflang_404.html"
+ Then the hreflang alternates should have reciprocal return links
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ returned HTTP status 404
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the hreflang alternates should have reciprocal return links" fails with no alternates
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags.html"
+ Then the hreflang alternates should have reciprocal return links
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ No hreflang alternate links were found on the page.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the Open Graph tags should be valid" fails when tags are missing
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags.html"
+ Then the Open Graph tags should be valid
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The following required Open Graph meta tags are missing or empty: og:type, og:image, og:url.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the Twitter Card tags should be valid" fails when tags are missing
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags.html"
+ Then the Twitter Card tags should be valid
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The following required Twitter Card meta tags are missing or empty: twitter:card, twitter:title, twitter:description.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the following Open Graph tags should exist" fails when a listed tag is missing
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags.html"
+ Then the following Open Graph tags should exist:
+ | og:title |
+ | og:image |
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The following required Open Graph meta tags are missing or empty: og:image.
+ """
+
+ @trait:MetatagTrait
+ Scenario: Assert that "Then the following Twitter Card tags should exist" fails when a listed tag is missing
+ Given some behat configuration
+ And scenario steps:
+ """
+ When I visit "/sites/default/files/metatags.html"
+ Then the following Twitter Card tags should exist:
+ | twitter:card |
+ """
+ When I run "behat --no-colors"
+ Then it should fail with an error:
+ """
+ The following required Twitter Card meta tags are missing or empty: twitter:card.
+ """
diff --git a/tests/behat/fixtures/metatags_canonical_empty.html b/tests/behat/fixtures/metatags_canonical_empty.html
new file mode 100644
index 00000000..394826a8
--- /dev/null
+++ b/tests/behat/fixtures/metatags_canonical_empty.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Empty canonical
+
+
+
+