diff --git a/.github/astro.instructions.md b/.github/astro.instructions.md index 35ba19412..12ea17b21 100644 --- a/.github/astro.instructions.md +++ b/.github/astro.instructions.md @@ -339,7 +339,15 @@ Sidebar topics are defined in `config/sidebar/` as separate modules and aggregat ## Cookie Consent -The site uses Microsoft's **WCP** consent runtime (`wcp-consent.js`, loaded live from `wcpstatic.microsoft.com`), wired up in `src/components/starlight/Head.astro` and fully restyled by `src/styles/wcp-consent.css`. The banner is **geo-gated**: the CDN decides per region whether it appears, so where consent is not required (e.g. the US) **no banner shows** and the "Manage cookies" buttons are hidden (`html[data-consent-not-required]`). When it does appear (e.g. the EU) it is a **fixed strip at the top of the viewport** — not a bottom-right box — with **"Accept"**, **"Reject"**, and **"More info"** actions; "More info" opens a preferences dialog with per-category toggles and "Save changes" / "Reset all". Consent is persisted in the `MSCC` cookie. Because it is geo-gated, you usually will not see it in local/US automation — if it does appear, dismiss it first. +The site uses Microsoft's **WCP** consent runtime (`wcp-consent.js`, loaded live from `wcpstatic.microsoft.com`), wired up in `src/components/starlight/Head.astro` and fully restyled by `src/styles/wcp-consent.css`. The banner is **geo-gated**: the CDN decides per region whether it appears, so where consent is not required (e.g. the US) **no banner shows** and the "Manage cookies" buttons are hidden (`html[data-consent-not-required]`). When it does appear (e.g. the EU) it is a **fixed strip at the top of the viewport** — not a bottom-right box — with **"Accept"**, **"Reject"**, and **"Manage cookies"** actions; "Manage cookies" opens a preferences dialog with per-category toggles and "Save changes" / "Reset all". Consent is persisted in the `MSCC` cookie. Because it is geo-gated, you usually will not see it in local/US automation — if it does appear, dismiss it first unless you are inspecting the consent UI itself. + +Cookie management is also available as a text-style **Manage Cookies** action under **Legal** on all viewport sizes where WCP requires consent. It replaces the mobile footer cookie icon; the desktop header icon remains. The Legal action is server-rendered with the unique ID `c-uhff-footer_managecookies` and uses the existing delegated `[data-cookie-manage-consent]` handler. + +The banner's **Accept** and dialog's **Save changes** buttons use the shared `--aspire-action-primary`, `--aspire-action-primary-text`, and `--aspire-action-primary-hover` tokens, matching **Try Aspire** in both themes. Do not use `--aspire-color-light` for primary action backgrounds: it aliases muted text and becomes gray in light mode. + +For scanners, use `//*[@id="c-uhff-footer_managecookies"]` after deploying this markup. Do not use `//button[contains(@class, 'cookie-consent-btn')]`: it matches multiple controls, including hidden responsive variants. Verify exactly one match and an ordinary click that reopens preferences after a saved choice. WCP must have initialized in a consent-required region; a hidden control in a non-consent region or a blocked WCP CDN is not fixed by a different XPath. Confirm the scanned deployment and rerun the external scan rather than treating local tests as compliance certification. + +Complete the initial banner choice before testing the footer's reopening action, especially on narrow screens where the banner can cover most of the viewport. Use the banner's own management action to inspect preferences before making an initial choice. ## Screenshots and Visual Verification with playwright-cli diff --git a/src/frontend/src/components/FooterLegal.astro b/src/frontend/src/components/FooterLegal.astro index d2dff860f..4baf41159 100644 --- a/src/frontend/src/components/FooterLegal.astro +++ b/src/frontend/src/components/FooterLegal.astro @@ -29,6 +29,19 @@ const t = Astro.locals.t as unknown as (key: string) => string; {t('footer.privacyAndCookies')} +
  • string; gap: 0.4rem; } - .footer-links a { + .footer-links a, + .footer-links button { display: inline-flex; align-items: center; gap: 0.4rem; @@ -87,16 +101,37 @@ const t = Astro.locals.t as unknown as (key: string) => string; font-size: var(--sl-text-sm); } - .footer-links a:hover { + .footer-links button { + width: fit-content; + padding: 0; + border: 0; + background: none; + font-family: inherit; + font-size: var(--sl-text-xs); + line-height: 1.45; + text-align: start; + cursor: pointer; + min-height: 1.5rem; + } + + .footer-links a:hover, + .footer-links button:hover { color: var(--sl-color-white); } + .footer-links button:focus-visible { + outline: 2px solid var(--sl-color-text-accent); + outline-offset: 0.15rem; + border-radius: 0.15rem; + } + .privacy-choice { gap: 0.5rem; } @media (prefers-reduced-motion: no-preference) { - .footer-links a { + .footer-links a, + .footer-links button { transition: color 150ms ease; } } diff --git a/src/frontend/src/components/FooterSocials.astro b/src/frontend/src/components/FooterSocials.astro index dafa330b9..e58c6e678 100644 --- a/src/frontend/src/components/FooterSocials.astro +++ b/src/frontend/src/components/FooterSocials.astro @@ -1,5 +1,4 @@ --- -import CookiesSvg from '@assets/icons/cookies.svg'; import { Icon } from '@astrojs/starlight/components'; import { socialConfig, type SocialLink } from '../../config/socials.config'; @@ -56,22 +55,6 @@ const opensInNewTab = Astro.locals.t('footer.opensInNewTab' as any); )) } - - @@ -92,8 +75,7 @@ const opensInNewTab = Astro.locals.t('footer.opensInNewTab' as any); } .social-actions, - .social-links, - .footer-mobile-tools { + .social-links { display: flex; flex-wrap: wrap; gap: 0.3rem; @@ -109,8 +91,7 @@ const opensInNewTab = Astro.locals.t('footer.opensInNewTab' as any); list-style: none; } - .social-links a, - .footer-mobile-tool-btn { + .social-links a { width: 2rem; height: 2rem; display: grid; @@ -127,21 +108,17 @@ const opensInNewTab = Astro.locals.t('footer.opensInNewTab' as any); } .social-links a:hover, - .social-links a:focus-visible, - .footer-mobile-tool-btn:hover, - .footer-mobile-tool-btn:focus-visible { + .social-links a:focus-visible { color: var(--sl-color-white); background: color-mix(in srgb, var(--sl-color-gray-5) 56%, transparent); } - .social-links a:focus-visible, - .footer-mobile-tool-btn:focus-visible { + .social-links a:focus-visible { outline: 2px solid var(--sl-color-text-accent); outline-offset: 0.15rem; } - .social-links a :global(svg), - .footer-mobile-tool-btn :global(svg) { + .social-links a :global(svg) { --sl-icon-size: 1rem !important; width: 1rem; height: 1rem; @@ -152,16 +129,6 @@ const opensInNewTab = Astro.locals.t('footer.opensInNewTab' as any); display: none; } - .footer-mobile-tools { - display: none; - } - - .footer-mobile-tool-label { - font-size: var(--sl-text-2xs); - font-weight: 700; - line-height: 1; - } - .sr-only { position: absolute; width: 1px; @@ -173,26 +140,8 @@ const opensInNewTab = Astro.locals.t('footer.opensInNewTab' as any); border: 0; } - @media (max-width: 49.999rem) { - .footer-mobile-tools { - display: flex; - flex: 1 0 100%; - margin-top: 0.25rem; - } - - .footer-mobile-tool-btn { - width: auto; - min-width: 3.5rem; - display: inline-flex; - gap: 0.35rem; - padding-inline: 0.55rem; - border-color: color-mix(in srgb, var(--sl-color-gray-5) 72%, transparent); - } - } - @media (prefers-reduced-motion: no-preference) { - .social-links a, - .footer-mobile-tool-btn { + .social-links a { transition: color 160ms ease, background-color 160ms ease; @@ -200,8 +149,7 @@ const opensInNewTab = Astro.locals.t('footer.opensInNewTab' as any); } @media (forced-colors: active) { - .social-links a, - .footer-mobile-tool-btn { + .social-links a { border-color: CanvasText; } } diff --git a/src/frontend/src/components/starlight/Head.astro b/src/frontend/src/components/starlight/Head.astro index 2c91dc79a..f49450567 100644 --- a/src/frontend/src/components/starlight/Head.astro +++ b/src/frontend/src/components/starlight/Head.astro @@ -225,7 +225,7 @@ function computeSourceUrl() { if (window.__aspireWcpConsentInitialized) return; window.__aspireWcpConsentInitialized = true; - // The four "Manage cookies" buttons are always server-rendered. Once WCP + // Cookie controls, including the Legal text action, are server-rendered. Once WCP // tells us whether consent is required for this region, reflect it on // so CSS can hide those buttons where there is nothing to manage (e.g. the // US). Persist the answer so a returning visitor doesn't see the button diff --git a/src/frontend/src/content/i18n/en.json b/src/frontend/src/content/i18n/en.json index 62d070fed..e7a366242 100644 --- a/src/frontend/src/content/i18n/en.json +++ b/src/frontend/src/content/i18n/en.json @@ -411,6 +411,7 @@ "reference": "Reference", "contributorGuide": "Contributor guide", "privacyAndCookies": "Privacy & Cookies", + "manageCookies": "Manage Cookies", "terms": "Terms of Use", "trademarks": "Trademarks", "translatePage": "Translate this page", diff --git a/src/frontend/src/styles/wcp-consent.css b/src/frontend/src/styles/wcp-consent.css index 6ef176c79..7d0a3b9ca 100644 --- a/src/frontend/src/styles/wcp-consent.css +++ b/src/frontend/src/styles/wcp-consent.css @@ -12,7 +12,7 @@ * * We keep WCP's DOM (required: it is what writes the MSCC consent cookie and what * the compliance scan looks for) but replace its entire visual language with - * Aspire's: Poppins type, the site's surface/text tokens, the lavender primary + * Aspire's: Poppins type, the site's surface/text tokens, the purple primary * CTA (mirrors the "Try Aspire" button), ghost secondary actions, and the brand * purple for links, focus, and form controls. All colors come from the * theme-aware custom properties in site.css, so the UI tracks light/dark itself. @@ -21,9 +21,9 @@ /* ========================================================================== * Manage-cookies buttons (site chrome) * - * The four "Manage cookies" buttons (header desktop + mobile, footer tools, and - * the social-icons row) are always server-rendered so they are available the - * instant WCP decides consent is required. Where WCP reports consent is NOT + * The cookie controls (header icons, Legal text action, and social-icons row) + * are server-rendered so they are available the instant WCP decides consent is + * required. Where WCP reports consent is NOT * required (e.g. the US) there is nothing to manage, so we hide them. The * `data-consent-not-required` flag is set on by the WCP runtime in * Head.astro once it knows the region (and primed from localStorage before @@ -121,21 +121,21 @@ html:has(#wcpConsentBannerCtrl) .page { } /* The banner's three actions share one parent group, so :nth-of-type is a stable - way to distinguish them: 1 = Accept (primary), 2 = Reject, 3 = More info. */ + way to distinguish them: 1 = Accept (primary), 2 = Reject, 3 = Manage cookies. */ -/* Primary "Accept" — mirrors the site's lavender "Try Aspire" CTA. */ +/* Primary "Accept" — shares the site's "Try Aspire" action tokens in both themes. */ #wcpConsentBannerCtrl button:nth-of-type(1) { - background: var(--aspire-color-light) !important; - color: var(--aspire-color-black) !important; - border: 1px solid var(--aspire-color-light) !important; + background: var(--aspire-action-primary) !important; + color: var(--aspire-action-primary-text) !important; + border: 1px solid var(--aspire-action-primary) !important; } #wcpConsentBannerCtrl button:nth-of-type(1):hover { - background: color-mix(in srgb, var(--aspire-color-light) 82%, var(--aspire-color-purple)) !important; - border-color: color-mix(in srgb, var(--aspire-color-light) 82%, var(--aspire-color-purple)) !important; + background: var(--aspire-action-primary-hover) !important; + border-color: var(--aspire-action-primary-hover) !important; } -/* Secondary "Reject" / "More info" — ghost buttons that fill toward brand +/* Secondary "Reject" / "Manage cookies" — ghost buttons that fill toward brand purple on hover. The resting border uses --sl-color-text at 55% so the button boundary clears WCAG 1.4.11 non-text contrast (>=3:1) in both themes (3.73:1 light, 5.82:1 dark); 30% failed it (1.90:1 / 2.70:1). */ @@ -188,15 +188,92 @@ html:has(#wcpCookiePreferenceCtrl) { in dark mode. A neutral dark scrim reads correctly in both themes. */ #wcpCookiePreferenceCtrl { background: color-mix(in srgb, var(--aspire-color-black) 60%, transparent) !important; + --wcp-dialog-padding: 1rem; + display: flex !important; + align-items: center; + justify-content: center; + box-sizing: border-box !important; + padding: 1rem !important; + height: 100dvh !important; + overflow: hidden !important; } /* Modal panel — the branded Aspire card. */ #wcpCookiePreferenceCtrl [role='dialog'] { + position: relative !important; + inset: auto !important; + display: flex !important; + flex-direction: column; + box-sizing: border-box !important; + width: 100% !important; + max-width: 40rem !important; + height: auto !important; + max-height: 100% !important; + margin: 0 !important; + overflow: hidden !important; background: var(--sl-color-bg) !important; color: var(--sl-color-text) !important; - border: 1px solid color-mix(in srgb, var(--sl-color-text) 12%, transparent) !important; - border-radius: 12px !important; - box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.5) !important; + border: 1px solid var(--sl-color-gray-5) !important; + border-radius: 0.5rem !important; + box-shadow: var(--sl-shadow-lg) !important; +} + +#wcpCookiePreferenceCtrl [role='document'] { + display: flex !important; + flex-direction: column; + gap: 1rem; + min-height: 0; + height: auto !important; + margin: 0 !important; + padding: var(--wcp-dialog-padding) !important; + overflow: hidden; +} + +#wcpCookiePreferenceCtrl [role='document'] > div:first-child { + display: flex; + align-items: center; + min-height: 2.75rem; + padding-inline-end: 3.25rem; + flex-shrink: 0; +} + +#wcpCookiePreferenceCtrl [role='document'] h1 { + margin: 0 !important; +} + +/* Scroll only the choices so the title, close control, and actions stay reachable. */ +#wcpCookiePreferenceCtrl [role='document'] > form { + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; +} + +#wcpCookiePreferenceCtrl [role='document'] > div:last-child { + display: flex !important; + gap: 0.75rem; + flex-shrink: 0; + margin: 0 !important; +} + +#wcpCookiePreferenceCtrl [role='document'] > div:last-child button { + flex: 1; + float: none !important; + min-width: 0 !important; + width: auto !important; + height: auto !important; + margin: 0 !important; +} + +@media (min-width: 50rem) { + #wcpCookiePreferenceCtrl { + --wcp-dialog-padding: 1.5rem; + } +} + +@media (max-width: 30rem) { + #wcpCookiePreferenceCtrl [role='document'] > div:last-child { + flex-direction: column; + } } /* WCP hardcodes black on its text nodes, so recolor them to the theme text. @@ -221,25 +298,38 @@ html:has(#wcpCookiePreferenceCtrl) { /* Close (✕) button — the direct child button of the dialog panel. */ #wcpCookiePreferenceCtrl [role='dialog'] > button { + position: absolute !important; + inset-block-start: var(--wcp-dialog-padding) !important; + inset-inline-end: var(--wcp-dialog-padding) !important; + float: none !important; + display: flex !important; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: 2.75rem !important; + height: 2.75rem !important; + margin: 0 !important; background: transparent !important; color: var(--sl-color-text) !important; border: none !important; - padding: 0.35rem !important; + border-radius: 0.375rem !important; + padding: 0.5rem !important; } #wcpCookiePreferenceCtrl [role='dialog'] > button:hover { + background: var(--sl-color-gray-5) !important; color: var(--sl-color-text-accent) !important; } /* Footer actions live in the last child of the dialog body: the first button is - the primary "Save changes" (lavender CTA), the second is "Reset all" (ghost). */ + the primary "Save changes" (purple CTA), the second is "Reset all" (ghost). */ #wcpCookiePreferenceCtrl [role='document'] > div:last-child button:first-of-type { - background: var(--aspire-color-light) !important; - color: var(--aspire-color-black) !important; - border: 1px solid var(--aspire-color-light) !important; + background: var(--aspire-action-primary) !important; + color: var(--aspire-action-primary-text) !important; + border: 1px solid var(--aspire-action-primary) !important; } #wcpCookiePreferenceCtrl [role='document'] > div:last-child button:first-of-type:hover { - background: color-mix(in srgb, var(--aspire-color-light) 82%, var(--aspire-color-purple)) !important; - border-color: color-mix(in srgb, var(--aspire-color-light) 82%, var(--aspire-color-purple)) !important; + background: var(--aspire-action-primary-hover) !important; + border-color: var(--aspire-action-primary-hover) !important; } #wcpCookiePreferenceCtrl [role='document'] > div:last-child button:last-of-type { background: transparent !important; @@ -260,6 +350,6 @@ html:has(#wcpCookiePreferenceCtrl) { #wcpCookiePreferenceCtrl button:focus-visible, #wcpCookiePreferenceCtrl a:focus-visible, #wcpCookiePreferenceCtrl input:focus-visible { - outline: 2px solid var(--aspire-color-primary) !important; + outline: 2px solid var(--aspire-action-focus) !important; outline-offset: 2px !important; } diff --git a/src/frontend/tests/e2e/cookie-consent.spec.ts b/src/frontend/tests/e2e/cookie-consent.spec.ts index 8d116bab5..1656a3806 100644 --- a/src/frontend/tests/e2e/cookie-consent.spec.ts +++ b/src/frontend/tests/e2e/cookie-consent.spec.ts @@ -7,6 +7,7 @@ const ANALYTICS_SELECTOR = 'script[data-category="analytics"]'; const INERT_ANALYTICS_SELECTOR = 'script[type="text/plain"][data-category="analytics"]'; const EXECUTABLE_ANALYTICS_SELECTOR = 'script[data-category="analytics"]:not([type="text/plain"])'; const MANAGE_TRIGGER_SELECTOR = '[data-cookie-manage-consent]'; +const SCANNER_XPATH = 'xpath=//*[@id="c-uhff-footer_managecookies"]'; // astro dev fires a one-time full-page reload once Vite finishes pre-bundling // dependencies on a cold server, which can destroy the page's execution context @@ -20,6 +21,9 @@ type WcpStubOptions = { consentRequired: boolean; /** Whether the stubbed consent record grants the Analytics category. */ analyticsGranted: boolean; + /** Delay region resolution to exercise cached state and scanner readiness. */ + initDelayMs?: number; + cachedConsentRequired?: boolean; }; type WcpStubState = { @@ -80,6 +84,9 @@ async function installWcpStub(page: Page, options: WcpStubOptions): Promise { + if (opts.cachedConsentRequired !== undefined) { + localStorage.setItem('aspireConsentRequired', String(opts.cachedConsentRequired)); + } // WCP v2 is key-based: `siteConsent.applyTheme(name)` looks the theme up in // this map and throws on a miss, and `WcpConsent.themes` is a string-keyed // record of theme objects. @@ -160,7 +167,7 @@ async function installWcpStub(page: Page, options: WcpStubOptions): Promise { + sessionStorage.setItem('wcp-test-choice', 'Save changes'); + onConsentChanged?.(); + }); + preferences.append(save); host.append(banner, preferences); // Expose WCP's own consent-changed callback (init's 4th argument) so a @@ -200,7 +215,11 @@ async function installWcpStub(page: Page, options: WcpStubOptions): Promise void } ).__triggerWcpConsentChanged = () => onConsentChanged?.(); - initCallback(null, siteConsent); + if (opts.initDelayMs) { + window.setTimeout(() => initCallback(null, siteConsent), opts.initDelayMs); + } else { + initCallback(null, siteConsent); + } }, }; }, options); @@ -328,7 +347,7 @@ test.describe('WCP cookie consent bridge', () => { ) .toBe(themeCalls + 1); - await page.locator(`${MANAGE_TRIGGER_SELECTOR}:visible`).first().click(); + await page.locator(SCANNER_XPATH).click(); const preferences = page.getByRole('dialog', { name: 'Cookie preferences', exact: true }); await expect(preferences).toBeVisible(); await navigateWithConsent(page, 'home'); @@ -365,6 +384,8 @@ test.describe('WCP cookie consent bridge', () => { await navigateWithConsent(page, destination); await expect(page.locator('html')).toHaveAttribute('data-consent-not-required', ''); await expect(page.locator(`${MANAGE_TRIGGER_SELECTOR}:visible`)).toHaveCount(0); + await expect(page.locator(SCANNER_XPATH)).toHaveCount(1); + await expect(page.locator(SCANNER_XPATH).locator('..')).toBeHidden(); await expect(page.getByRole('region', { name: 'Cookie consent', exact: true })).toBeHidden(); expect(await page.evaluate(() => window.__wcpStub?.initCalls)).toBe(1); await expect @@ -377,6 +398,82 @@ test.describe('WCP cookie consent bridge', () => { } }); + for (const path of ['/', '/support/', '/ja/get-started/install-cli/']) { + test(`exposes one actionable scanner target under Legal on ${path}`, async ({ page }) => { + await installWcpStub(page, { consentRequired: true, analyticsGranted: false }); + const response = await page.goto(path); + expect(response?.ok()).toBe(true); + const html = await response!.text(); + expect(html.match(/id="c-uhff-footer_managecookies"/g)).toHaveLength(1); + await waitForConsentBootstrap(page); + + const trigger = page.locator(SCANNER_XPATH); + await expect(trigger).toHaveCount(1); + await expect(trigger).toHaveAccessibleName('Manage Cookies'); + await expect(trigger).toHaveAttribute('aria-haspopup', 'dialog'); + await expect( + page.locator('nav[aria-labelledby="footer-legal-heading"] #c-uhff-footer_managecookies') + ).toHaveCount(1); + await expect(page.locator('.footer-socials [data-cookie-manage-consent]')).toHaveCount(0); + await trigger.click(); + const dialog = page.getByRole('dialog', { name: 'Cookie preferences', exact: true }); + await expect(dialog).toBeVisible(); + await dialog.getByRole('button', { name: 'Close cookie preferences' }).click(); + await trigger.focus(); + await trigger.press('Enter'); + await expect(dialog).toBeVisible(); + await expect(page.locator(EXECUTABLE_ANALYTICS_SELECTOR)).toHaveCount(0); + }); + } + + for (const choice of ['Accept all', 'Reject all', 'Save changes']) { + test(`the scanner can reopen preferences after ${choice}`, async ({ page }) => { + await installWcpStub(page, { consentRequired: true, analyticsGranted: false }); + await page.goto('/'); + await waitForConsentBootstrap(page); + if (choice === 'Save changes') { + await page.locator(SCANNER_XPATH).click(); + } + await page.getByRole('button', { name: choice, exact: true }).click(); + await expect + .poll(() => safeEvaluate(page, () => sessionStorage.getItem('wcp-test-choice'))) + .toBe(choice); + await page.waitForLoadState('load'); + await waitForConsentBootstrap(page); + await expect(page.getByRole('region', { name: 'Cookie consent' })).toBeHidden(); + await page.locator(SCANNER_XPATH).click(); + await expect(page.getByRole('dialog', { name: 'Cookie preferences' })).toBeVisible(); + }); + } + + test('exposes the scanner target after delayed WCP corrects a cached non-consent region', async ({ + page, + }) => { + await installWcpStub(page, { + consentRequired: true, + analyticsGranted: false, + cachedConsentRequired: false, + initDelayMs: 1500, + }); + await page.goto('/'); + await waitForConsentBootstrap(page); + await expect(page.locator('html')).not.toHaveAttribute('data-consent-not-required'); + await page.locator(SCANNER_XPATH).click(); + await expect(page.getByRole('dialog', { name: 'Cookie preferences' })).toBeVisible(); + }); + + test('does not imply a working dialog or grant analytics when WCP is blocked', async ({ + page, + }) => { + await page.route(/wcpstatic\.microsoft\.com/, (route) => route.abort()); + await page.goto('/'); + const trigger = page.locator(SCANNER_XPATH); + await expect(trigger).toHaveCount(1); + await trigger.click(); + await expect(page.locator('#wcpCookiePreferenceCtrl')).toHaveCount(0); + await expect(page.locator(EXECUTABLE_ANALYTICS_SELECTOR)).toHaveCount(0); + }); + test('promotes inert analytics scripts once Analytics consent is granted', async ({ page }) => { await installWcpStub(page, { consentRequired: false, analyticsGranted: true }); await page.goto('/'); @@ -472,9 +569,8 @@ test.describe('WCP cookie consent bridge', () => { await page.goto('/'); await waitForConsentBootstrap(page); - // At least one manage-cookies control must be server-rendered (the homepage - // ships three: the header's desktop and mobile buttons plus the footer's - // social row). The exact count varies by page/viewport, so assert on the + // Manage-cookies controls are server-rendered in the header and footer Legal + // list. The exact count varies by page/viewport, so assert on the // behavior — every rendered control opens WCP's dialog — rather than a // brittle fixed number. await expect diff --git a/src/frontend/tests/e2e/cookie-dialog-layout.spec.ts b/src/frontend/tests/e2e/cookie-dialog-layout.spec.ts new file mode 100644 index 000000000..433ac78f9 --- /dev/null +++ b/src/frontend/tests/e2e/cookie-dialog-layout.spec.ts @@ -0,0 +1,209 @@ +import { readFileSync } from 'node:fs'; +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; + +const consentStyles = ['aspire-brand.css', 'wcp-consent.css'] + .map((file) => readFileSync(new URL(`../../src/styles/${file}`, import.meta.url), 'utf8')) + .join('\n'); + +// Mirror WCP's panel/document/form structure and conflicting layout defaults. +// Deliberately use different class names: production overrides must not depend on vendor hashes. +const fixture = ` + + +
    +
    + +
    +`; + +for (const theme of ['light', 'dark']) { + for (const surface of ['banner', 'dialog']) { + test(`cookie ${surface} uses accessible primary-action colors in ${theme} mode`, async ({ + page, + }) => { + await page.setContent(fixture); + await page.addStyleTag({ content: consentStyles }); + await page.evaluate( + ({ theme, surface }) => { + document.documentElement.dataset.theme = theme; + if (surface === 'banner') { + document.getElementById('wcpCookiePreferenceCtrl')!.remove(); + const banner = document.createElement('div'); + banner.id = 'wcpConsentBannerCtrl'; + banner.innerHTML = + ''; + document.body.append(banner); + } + }, + { theme, surface } + ); + + const container = surface === 'banner' ? '#wcpConsentBannerCtrl' : '#wcpCookiePreferenceCtrl'; + const primary = page.locator(container).getByRole('button', { + name: surface === 'banner' ? 'Accept' : 'Save changes', + exact: true, + }); + await expect(primary).toHaveCSS('background-color', 'rgb(81, 43, 212)'); + await expect(primary).toHaveCSS('border-top-color', 'rgb(81, 43, 212)'); + await expect(primary).toHaveCSS('color', 'rgb(255, 255, 255)'); + expect( + (await new AxeBuilder({ page }).include(container).withRules(['color-contrast']).analyze()) + .violations + ).toEqual([]); + + await primary.hover(); + await expect(primary).toHaveCSS('background-color', 'rgb(67, 35, 180)'); + await expect(primary).toHaveCSS('border-top-color', 'rgb(67, 35, 180)'); + await expect(primary).toHaveCSS('color', 'rgb(255, 255, 255)'); + expect( + (await new AxeBuilder({ page }).include(container).withRules(['color-contrast']).analyze()) + .violations + ).toEqual([]); + }); + } + + for (const direction of ['ltr', 'rtl']) { + test(`cookie dialog keeps its close control and actions inset in ${theme} ${direction}`, async ({ + page, + }) => { + await page.setContent(fixture); + await page.addStyleTag({ content: consentStyles }); + await page.evaluate( + ({ theme, direction }) => { + document.documentElement.dataset.theme = theme; + document.getElementById('wcpCookiePreferenceCtrl')!.dir = direction; + }, + { theme, direction } + ); + + const projectViewport = page.viewportSize()!; + for (const viewport of [ + projectViewport, + { width: 320, height: 568 }, + { width: 844, height: 390 }, + ]) { + await page.setViewportSize(viewport); + const dialog = page.getByRole('dialog'); + const close = dialog.getByRole('button', { name: 'Close', exact: true }); + const heading = dialog.getByRole('heading'); + const form = dialog.locator('form'); + const actions = dialog.locator('[role="document"] > div:last-child'); + + const panelBox = (await dialog.boundingBox())!; + const closeBox = (await close.boundingBox())!; + const headingBox = (await heading.boundingBox())!; + const actionsBox = (await actions.boundingBox())!; + const padding = viewport.width >= 800 ? 24 : 16; + const clientWidth = await page.evaluate(() => document.documentElement.clientWidth); + expect(panelBox.x).toBeGreaterThanOrEqual(16); + expect(panelBox.y).toBeGreaterThanOrEqual(16); + expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(clientWidth - 16); + expect(panelBox.y + panelBox.height).toBeLessThanOrEqual(viewport.height - 16); + expect(panelBox.width).toBeLessThanOrEqual(640); + expect(closeBox.width).toBeGreaterThanOrEqual(44); + expect(closeBox.height).toBeGreaterThanOrEqual(44); + expect(closeBox.y - panelBox.y).toBeCloseTo(padding + 1, 0); + if (direction === 'ltr') { + expect(panelBox.x + panelBox.width - closeBox.x - closeBox.width).toBeCloseTo( + padding + 1, + 0 + ); + expect(headingBox.x + headingBox.width).toBeLessThanOrEqual(closeBox.x); + } else { + expect(closeBox.x - panelBox.x).toBeCloseTo(padding + 1, 0); + expect(headingBox.x).toBeGreaterThanOrEqual(closeBox.x + closeBox.width); + } + expect(panelBox.y + panelBox.height - actionsBox.y - actionsBox.height).toBeCloseTo( + padding + 1, + 0 + ); + expect(await dialog.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); + expect(await form.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); + await form.evaluate((el) => { + el.scrollTop = el.scrollHeight; + }); + expect(await form.evaluate((el) => el.scrollTop)).toBeGreaterThan(0); + expect((await close.boundingBox())!.y).toBe(closeBox.y); + await expect(dialog.getByRole('button', { name: 'Save changes' })).toBeInViewport(); + await close.focus(); + await expect(close).toBeFocused(); + await expect(close).toHaveCSS('outline-style', 'solid'); + } + }); + } +} + +test('cookie dialog remains usable with larger text', async ({ page }) => { + await page.setViewportSize({ width: 640, height: 720 }); + await page.setContent(fixture); + await page.addStyleTag({ content: consentStyles }); + await page.evaluate(() => { + document.documentElement.style.fontSize = '200%'; + }); + const dialog = page.getByRole('dialog'); + const form = dialog.locator('form'); + expect(await dialog.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true); + expect((await form.boundingBox())!.height).toBeGreaterThan(0); + await expect(dialog.getByRole('button', { name: 'Close', exact: true })).toBeInViewport(); + await expect(dialog.getByRole('button', { name: 'Save changes' })).toBeInViewport(); +}); diff --git a/src/frontend/tests/unit/custom-components.vitest.test.ts b/src/frontend/tests/unit/custom-components.vitest.test.ts index 0be72ca78..26d646fd1 100644 --- a/src/frontend/tests/unit/custom-components.vitest.test.ts +++ b/src/frontend/tests/unit/custom-components.vitest.test.ts @@ -15,6 +15,7 @@ import CustomSelect from '@components/CustomSelect.astro'; import Expand from '@components/Expand.astro'; import FeatureShowcase from '@components/FeatureShowcase.astro'; import FluidGrid from '@components/FluidGrid.astro'; +import FooterLegal from '@components/FooterLegal.astro'; import FooterPreferences from '@components/FooterPreferences.astro'; import FooterSocials from '@components/FooterSocials.astro'; import GitHubRepoStats from '@components/GitHubRepoStats.astro'; @@ -1565,11 +1566,29 @@ describe('custom Astro component render coverage', () => { expect(html).toContain('aria-labelledby="footer-community-heading"'); expect(html).toContain('aria-label="X (opens in new tab)"'); expect(html).toContain('aria-label="GitHub (opens in new tab)"'); - expect(html).toContain('role="group" aria-label="Site tools"'); - expect(html).toContain('data-cookie-manage-consent'); + expect(html).not.toContain('footer-mobile-tool'); + expect(html).not.toContain('data-cookie-manage-consent'); expect(html).not.toContain('data-open-install-modal'); }); + it('renders a unique scanner-addressable Manage Cookies action under Legal', async () => { + const html = normalizeHtml( + await renderComponent(FooterLegal, { + locals: { t: createTestTranslator(enTranslations) }, + }) + ); + + expect(html).toContain('aria-labelledby="footer-legal-heading"'); + expect(html.match(/id="c-uhff-footer_managecookies"/g)).toHaveLength(1); + expect(html).toMatch(/