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 + + + +

Empty canonical

+ + diff --git a/tests/behat/fixtures/metatags_hreflang_404.html b/tests/behat/fixtures/metatags_hreflang_404.html new file mode 100644 index 00000000..62c52f13 --- /dev/null +++ b/tests/behat/fixtures/metatags_hreflang_404.html @@ -0,0 +1,12 @@ + + + + + Hreflang broken link + + + + +

Hreflang broken link

+ + diff --git a/tests/behat/fixtures/metatags_hreflang_de.html b/tests/behat/fixtures/metatags_hreflang_de.html new file mode 100644 index 00000000..c3fd43a2 --- /dev/null +++ b/tests/behat/fixtures/metatags_hreflang_de.html @@ -0,0 +1,13 @@ + + + + + Hreflang DE + + + + + +

Hreflang DE

+ + diff --git a/tests/behat/fixtures/metatags_hreflang_emptyhref.html b/tests/behat/fixtures/metatags_hreflang_emptyhref.html new file mode 100644 index 00000000..4f7a0419 --- /dev/null +++ b/tests/behat/fixtures/metatags_hreflang_emptyhref.html @@ -0,0 +1,11 @@ + + + + + Hreflang empty href + + + +

Hreflang empty href

+ + diff --git a/tests/behat/fixtures/metatags_hreflang_en.html b/tests/behat/fixtures/metatags_hreflang_en.html new file mode 100644 index 00000000..13f6e054 --- /dev/null +++ b/tests/behat/fixtures/metatags_hreflang_en.html @@ -0,0 +1,13 @@ + + + + + Hreflang EN + + + + + +

Hreflang EN

+ + diff --git a/tests/behat/fixtures/metatags_hreflang_invalid.html b/tests/behat/fixtures/metatags_hreflang_invalid.html new file mode 100644 index 00000000..733f6279 --- /dev/null +++ b/tests/behat/fixtures/metatags_hreflang_invalid.html @@ -0,0 +1,11 @@ + + + + + Hreflang invalid + + + +

Hreflang invalid

+ + diff --git a/tests/behat/fixtures/metatags_hreflang_noreturn.html b/tests/behat/fixtures/metatags_hreflang_noreturn.html new file mode 100644 index 00000000..4c65444a --- /dev/null +++ b/tests/behat/fixtures/metatags_hreflang_noreturn.html @@ -0,0 +1,12 @@ + + + + + Hreflang no return + + + + +

Hreflang no return

+ + diff --git a/tests/behat/fixtures/metatags_hreflang_noself.html b/tests/behat/fixtures/metatags_hreflang_noself.html new file mode 100644 index 00000000..75d42478 --- /dev/null +++ b/tests/behat/fixtures/metatags_hreflang_noself.html @@ -0,0 +1,12 @@ + + + + + Hreflang no self + + + + +

Hreflang no self

+ + diff --git a/tests/behat/fixtures/metatags_noindex.html b/tests/behat/fixtures/metatags_noindex.html new file mode 100644 index 00000000..58c1f984 --- /dev/null +++ b/tests/behat/fixtures/metatags_noindex.html @@ -0,0 +1,11 @@ + + + + + Noindex page + + + +

Noindex page

+ + diff --git a/tests/behat/fixtures/metatags_seo.html b/tests/behat/fixtures/metatags_seo.html new file mode 100644 index 00000000..8d0c7936 --- /dev/null +++ b/tests/behat/fixtures/metatags_seo.html @@ -0,0 +1,25 @@ + + + + + SEO Meta Tag Testing Page + + + + + + + + + + + + + + + + +

SEO Meta Tag Testing Page

+

This page contains a full set of head/SEO meta tags for testing.

+ + diff --git a/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/mysite_core.routing.yml b/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/mysite_core.routing.yml index eac0974e..d4c33d2e 100644 --- a/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/mysite_core.routing.yml +++ b/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/mysite_core.routing.yml @@ -27,3 +27,10 @@ mysite_core.test_config_system_site_name: _access: 'TRUE' defaults: _controller: '\Drupal\mysite_core\Controller\TestContent::testConfigSystemSiteName' + +mysite_core.test_robots_header: + path: '/mysite_core/test-robots-header' + requirements: + _access: 'TRUE' + defaults: + _controller: '\Drupal\mysite_core\Controller\TestContent::testRobotsHeader' diff --git a/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/src/Controller/TestContent.php b/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/src/Controller/TestContent.php index 3db4fac7..b7705542 100644 --- a/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/src/Controller/TestContent.php +++ b/tests/behat/fixtures_drupal/d10/web/modules/custom/mysite_core/src/Controller/TestContent.php @@ -88,4 +88,20 @@ public function testConfigSystemSiteName(Request $request): Response { ]); } + /** + * Returns an HTML page carrying an X-Robots-Tag response header. + * + * The header value comes from the "value" query parameter and defaults to + * "noindex". + */ + public function testRobotsHeader(Request $request): Response { + $value = (string) $request->query->get('value', 'noindex'); + + return new Response('Robots header test

Robots header test

', 200, [ + 'Content-Type' => 'text/html; charset=UTF-8', + 'X-Robots-Tag' => $value, + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + ]); + } + } diff --git a/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/mysite_core.routing.yml b/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/mysite_core.routing.yml index eac0974e..d4c33d2e 100644 --- a/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/mysite_core.routing.yml +++ b/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/mysite_core.routing.yml @@ -27,3 +27,10 @@ mysite_core.test_config_system_site_name: _access: 'TRUE' defaults: _controller: '\Drupal\mysite_core\Controller\TestContent::testConfigSystemSiteName' + +mysite_core.test_robots_header: + path: '/mysite_core/test-robots-header' + requirements: + _access: 'TRUE' + defaults: + _controller: '\Drupal\mysite_core\Controller\TestContent::testRobotsHeader' diff --git a/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/src/Controller/TestContent.php b/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/src/Controller/TestContent.php index 3db4fac7..b7705542 100644 --- a/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/src/Controller/TestContent.php +++ b/tests/behat/fixtures_drupal/d11/web/modules/custom/mysite_core/src/Controller/TestContent.php @@ -88,4 +88,20 @@ public function testConfigSystemSiteName(Request $request): Response { ]); } + /** + * Returns an HTML page carrying an X-Robots-Tag response header. + * + * The header value comes from the "value" query parameter and defaults to + * "noindex". + */ + public function testRobotsHeader(Request $request): Response { + $value = (string) $request->query->get('value', 'noindex'); + + return new Response('Robots header test

Robots header test

', 200, [ + 'Content-Type' => 'text/html; charset=UTF-8', + 'X-Robots-Tag' => $value, + 'Cache-Control' => 'no-cache, no-store, must-revalidate', + ]); + } + }